diff --git a/src/java/com/threerings/getdown/data/Application.java b/src/java/com/threerings/getdown/data/Application.java index 50082b0..a409d95 100644 --- a/src/java/com/threerings/getdown/data/Application.java +++ b/src/java/com/threerings/getdown/data/Application.java @@ -36,6 +36,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; +import java.io.FileReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -43,6 +44,9 @@ import java.io.InputStreamReader; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.security.GeneralSecurityException; +import java.security.Signature; +import java.security.cert.Certificate; import java.util.ArrayList; import java.util.HashMap; @@ -58,6 +62,7 @@ import com.samskivert.util.ArrayIntSet; import com.samskivert.util.RunAnywhere; import com.samskivert.util.StringUtil; +import org.apache.commons.codec.binary.Base64; import org.apache.commons.io.IOUtils; import com.threerings.getdown.Log; @@ -82,6 +87,9 @@ public class Application * application (minus this prefix). */ public static final String PROP_PASSTHROUGH_PREFIX = "app."; + /** Suffix used for control file signatures. */ + public static final String SIGNATURE_SUFFIX = ".sig"; + /** Used to communicate information about the UI displayed when updating the application. */ public static class UpdateInterface { @@ -135,6 +143,16 @@ public class Application public void updateStatus (String message); } + /** + * Creates an application instance with no signers. + * + * @see #Application(File, String, Object[]) + */ + public Application (File appdir, String appid) + { + this(appdir, appid, null); + } + /** * Creates an application instance which records the location of the getdown.txt * configuration file from the supplied application directory. @@ -143,11 +161,13 @@ public class Application * be launched. That application will use appid.class and * appid.apparg to configure itself but all other parameters will be the same as * the primary application. + * @param signers an array of possible signers of this application. Used to verify the digest. */ - public Application (File appdir, String appid) + public Application (File appdir, String appid, Object[] signers) { _appdir = appdir; _appid = appid; + _signers = signers; _config = getLocalPath(CONFIG_FILE); } @@ -650,7 +670,7 @@ public class Application // now re-download our control files; we download the digest first so that if it fails, our // config file will still reference the old version and re-running the updater will start // the whole process over again - downloadControlFile(Digest.DIGEST_FILE); + downloadControlFile(Digest.DIGEST_FILE, true); downloadControlFile(CONFIG_FILE); } @@ -835,7 +855,7 @@ public class Application String olddig = (_digest == null) ? "" : _digest.getMetaDigest(); try { status.updateStatus("m.checking"); - downloadControlFile(Digest.DIGEST_FILE); + downloadControlFile(Digest.DIGEST_FILE, true); _digest = new Digest(_appdir); if (!olddig.equals(_digest.getMetaDigest())) { Log.info("Unversioned digest changed. Revalidating..."); @@ -853,7 +873,7 @@ public class Application // exceptions to propagate up to the caller as there is nothing else we can do if (_digest == null) { status.updateStatus("m.updating_metadata"); - downloadControlFile(Digest.DIGEST_FILE); + downloadControlFile(Digest.DIGEST_FILE, true); _digest = new Digest(_appdir); } @@ -864,7 +884,7 @@ public class Application // attempt to redownload both of our metadata files; again we pass errors up to our // caller because there's nothing we can do to automatically recover downloadControlFile(CONFIG_FILE); - downloadControlFile(Digest.DIGEST_FILE); + downloadControlFile(Digest.DIGEST_FILE, true); _digest = new Digest(_appdir); // revalidate everything if we end up downloading new metadata clearValidationMarkers(); @@ -985,13 +1005,114 @@ public class Application } /** - * Downloads a new copy of the specified control file and, if the download is successful, moves - * it over the old file on the filesystem. + * Downloads a new copy of the specified control file and does not check any signature. */ protected void downloadControlFile (String path) throws IOException + { + downloadControlFile(path, false); + } + + /** + * Downloads a new copy of the specified control file, optionally validating its signature. + * If the download is successful, moves it over the old file on the filesystem. + * + *

We implement simple signing of the digest.txt file for use with the Getdown applet, but + * this should never be used as-is with a non-applet getdown installation, as the signing + * format has no provisions for declaring arbitrary signing key IDs, signature algorithm, et al + * -- it is entirely reliant on the ability to upgrade the Getdown applet, and its signature + * validation implementation, at-will (ie, via an Applet). + * + *

TODO: Switch to PKCS #7 or CMS. + */ + protected void downloadControlFile (String path, boolean validateSignature) + throws IOException + { + File target = downloadFile(path); + + if (validateSignature) { + if (_signers == null) { + Log.info("No signers, not verifying file [path=" + path + "]."); + + } else { + File signatureFile = downloadFile(path + SIGNATURE_SUFFIX); + byte[] signature = null; + try { + signature = IOUtils.toByteArray(new FileReader(signatureFile), "utf8"); + } finally { + // delete the file regardless + signatureFile.delete(); + } + + FileInputStream dataInput = new FileInputStream(target); + byte[] buffer = new byte[8192]; + int length, validated = 0; + for (Object signer : _signers) { + if (!(signer instanceof Certificate)) { + continue; + } + + Certificate cert = (Certificate)signer; + try { + Signature sig = Signature.getInstance("SHA1withRSA"); + sig.initVerify(cert); + while ((length = dataInput.read(buffer)) != -1) { + sig.update(buffer, 0, length); + } + + if (!sig.verify(Base64.decodeBase64(signature))) { + Log.info("Signature does not match [cert=" + cert.getPublicKey() + "]"); + continue; + } else { + Log.info("Signature matches [cert=" + cert.getPublicKey() + "]"); + validated++; + } + + } catch (GeneralSecurityException gse) { + // no problem! + } + } + + // if we couldn't find a key that validates our digest, we are the hosed! + if (validated == 0) { + // delete the temporary digest file as we know it is invalid + target.delete(); + throw new IOException("m.corrupt_digest_signature_error"); + } + } + } + + // Windows is a wonderful operating system, it won't let you rename a file overtop of + // another one; thus to avoid running the risk of getting royally fucked, we have to do + // this complicated backup bullshit; this way if the shit hits the fan before we get the + // new copy into place, we should be able to read from the backup copy; yay! + File original = getLocalPath(path); + if (RunAnywhere.isWindows() && original.exists()) { + File backup = getLocalPath(path + "_old"); + if (backup.exists() && !backup.delete()) { + Log.warning("Failed to delete " + backup + "."); + } + if (!original.renameTo(backup)) { + Log.warning("Failed to move " + original + " to backup. We will likely fail " + + "to replace it with " + target + "."); + } + } + + // now attempt to replace the current file with the new one + if (!target.renameTo(original)) { + throw new IOException("Failed to rename(" + target + ", " + original + ")"); + } + } + + /** + * Download a path to a temporary file, returning a {@link File} instance with the path + * contents. + */ + protected File downloadFile (String path) + throws IOException { File target = getLocalPath(path + "_new"); + URL targetURL = null; try { targetURL = getRemoteURL(path); @@ -1015,26 +1136,7 @@ public class Application StreamUtil.close(fout); } - // Windows is a wonderful operating system, it won't let you rename a file overtop of - // another one; thus to avoid running the risk of getting royally fucked, we have to do - // this complicated backup bullshit; this way if the shit hits the fan before we get the - // new copy into place, we should be able to read from the backup copy; yay! - File original = getLocalPath(path); - if (RunAnywhere.isWindows() && original.exists()) { - File backup = getLocalPath(path + "_old"); - if (backup.exists() && !backup.delete()) { - Log.warning("Failed to delete " + backup + "."); - } - if (!original.renameTo(backup)) { - Log.warning("Failed to move " + original + " to backup. We will likely fail " + - "to replace it with " + target + "."); - } - } - - // now attempt to replace the current file with the new one - if (!target.renameTo(original)) { - throw new IOException("Failed to rename(" + target + ", " + original + ")"); - } + return target; } /** Helper function for creating {@link Resource} instances. */ @@ -1131,5 +1233,7 @@ public class Application protected ArrayList _jvmargs = new ArrayList(); protected ArrayList _appargs = new ArrayList(); + protected Object[] _signers; + protected static final String[] SA_PROTO = new String[0]; } diff --git a/src/java/com/threerings/getdown/launcher/Getdown.java b/src/java/com/threerings/getdown/launcher/Getdown.java index c6a02e4..598ea4d 100644 --- a/src/java/com/threerings/getdown/launcher/Getdown.java +++ b/src/java/com/threerings/getdown/launcher/Getdown.java @@ -80,6 +80,11 @@ public abstract class Getdown extends Thread } public Getdown (File appDir, String appId) + { + this(appDir, appId, null); + } + + public Getdown (File appDir, String appId, Object[] signers) { super("Getdown"); try { @@ -96,7 +101,7 @@ public abstract class Getdown extends Thread updateStatus(errmsg); _dead = true; } - _app = new Application(appDir, appId); + _app = new Application(appDir, appId, signers); _startup = System.currentTimeMillis(); } diff --git a/src/java/com/threerings/getdown/launcher/GetdownApplet.java b/src/java/com/threerings/getdown/launcher/GetdownApplet.java index 1a50853..ce9610c 100644 --- a/src/java/com/threerings/getdown/launcher/GetdownApplet.java +++ b/src/java/com/threerings/getdown/launcher/GetdownApplet.java @@ -23,9 +23,6 @@ package com.threerings.getdown.launcher; import java.awt.Container; import java.awt.Image; import java.net.URL; -import java.security.GeneralSecurityException; -import java.security.Signature; -import java.security.cert.Certificate; import javax.swing.JApplet; import javax.swing.JPanel; @@ -38,8 +35,6 @@ import java.io.PrintStream; import netscape.javascript.JSObject; import netscape.javascript.JSException; -import org.apache.commons.codec.binary.Base64; - import com.samskivert.util.RunAnywhere; import com.samskivert.util.StringUtil; @@ -103,7 +98,10 @@ public class GetdownApplet extends JApplet } try { - _getdown = new Getdown(appdir, null) { + // XXX getSigners() returns all certificates used to sign this applet which may allow + // a third party to insert a trusted certificate. This should be replaced with + // statically included trusted keys. + _getdown = new Getdown(appdir, null, GetdownApplet.class.getSigners()) { protected Container createContainer () { getContentPane().removeAll(); return getContentPane(); @@ -191,42 +189,6 @@ public class GetdownApplet extends JApplet } } - - Object[] signers = GetdownApplet.class.getSigners(); - if (signers.length == 0) { - Log.info("No signers, not verifying param signature."); - - } else { - String signature = getParameter("signature"); - if (signature == null) { - signature = ""; - } - - Log.info("Verifying signature '" + signature + "'."); - String params = appbase + appname + imgpath; - int validated = 0; - for (Object signer : signers) { - if (signer instanceof Certificate) { - Certificate cert = (Certificate)signer; - try { - Signature sig = Signature.getInstance("SHA1withRSA"); - sig.initVerify(cert); - sig.update(params.getBytes()); - if (!sig.verify(Base64.decodeBase64(signature.getBytes()))) { - Log.info("Signature does not match '" + cert.getPublicKey() + "'."); - } - validated++; - } catch (GeneralSecurityException gse) { - // no problem! - } - } - } - // if we couldn't find a key that validates our parameters, we are the hosed! - if (validated == 0) { - throw new Exception("m.corrupt_param_signature_error"); - } - } - // pass through properties parameters String properties = getParameter("properties"); if (properties != null) { diff --git a/src/java/com/threerings/getdown/messages.properties b/src/java/com/threerings/getdown/messages.properties index b6bf685..8ac1ed6 100644 --- a/src/java/com/threerings/getdown/messages.properties +++ b/src/java/com/threerings/getdown/messages.properties @@ -89,7 +89,7 @@ m.insufficient_permissions_error = You did not accept this application's \ security dialog is shown, click the button to accept the digital signature \ and grant this application the privileges it needs to run. -m.corrupt_param_signature_error = We couldn't verify the application's digital \ +m.corrupt_digest_signature_error = We couldn't verify the application's digital \ signature.\nPlease check that you are launching the application from\nthe \ correct website. diff --git a/src/java/com/threerings/getdown/tools/AppletParamTask.java b/src/java/com/threerings/getdown/tools/AppletParamTask.java deleted file mode 100644 index 0975126..0000000 --- a/src/java/com/threerings/getdown/tools/AppletParamTask.java +++ /dev/null @@ -1,128 +0,0 @@ -// -// $Id$ -// -// Getdown - application installer, patcher and launcher -// Copyright (C) 2004-2006 Three Rings Design, Inc. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along with -// this program; if not, write to the: Free Software Foundation, Inc., -// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - -package com.threerings.getdown.tools; - -import java.io.BufferedInputStream; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.PrintWriter; - -import java.security.KeyStore; -import java.security.PrivateKey; -import java.security.Signature; - -import org.apache.commons.codec.binary.Base64; - -import org.apache.tools.ant.BuildException; -import org.apache.tools.ant.Task; - -/** - * Computes and fills in signed applet parameters. The input file should contain the following - * tokens: @APPBASE@, @APPNAME@, @IMGPATH@, @SIGNATURE@ which will be filled in based - * on supplied and computed values. - */ -public class AppletParamTask extends Task -{ - public void setAppbase (String appbase) - { - _appbase = appbase; - } - - public void setAppname (String appname) - { - _appname = appname; - } - - public void setImgpath (String imgpath) - { - _imgpath = imgpath; - } - - public void setKeystore (File keystore) - { - _keystore = keystore; - } - - public void setStorepass (String storepass) - { - _storepass = storepass; - } - - public void setAlias (String alias) - { - _alias = alias; - } - - public void setKeypass (String keypass) - { - _keypass = keypass; - } - - public void setFile (File file) - { - _input = file; - } - - public void setTofile (File tofile) - { - _output = tofile; - } - - public void execute () throws BuildException - { - String params = _appbase + _appname + _imgpath; - - try { - KeyStore store = KeyStore.getInstance("JKS"); - store.load(new BufferedInputStream(new FileInputStream(_keystore)), - _storepass.toCharArray()); - PrivateKey key = (PrivateKey)store.getKey(_alias, _keypass.toCharArray()); - Signature sig = Signature.getInstance("SHA1withRSA"); - sig.initSign(key); - sig.update(params.getBytes()); - String signature = new String(Base64.encodeBase64(sig.sign())); - - BufferedReader bin = new BufferedReader(new FileReader(_input)); - PrintWriter pout = new PrintWriter(new FileWriter(_output)); - String line; - while ((line = bin.readLine()) != null) { - line = line.replaceAll("@APPNAME@", _appname); - line = line.replaceAll("@APPBASE@", _appbase); - line = line.replaceAll("@IMGPATH@", _imgpath); - line = line.replaceAll("@SIGNATURE@", signature); - pout.println(line); - } - - bin.close(); - pout.close(); - - } catch (Exception e) { - throw new BuildException(e); - } - } - - protected String _appbase, _appname, _imgpath; - protected File _keystore, _input, _output; - protected String _storepass = "", _alias, _keypass = ""; -} diff --git a/src/java/com/threerings/getdown/tools/DigesterTask.java b/src/java/com/threerings/getdown/tools/DigesterTask.java index a1aaca4..92c2a6a 100644 --- a/src/java/com/threerings/getdown/tools/DigesterTask.java +++ b/src/java/com/threerings/getdown/tools/DigesterTask.java @@ -21,9 +21,17 @@ package com.threerings.getdown.tools; import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Signature; + +import org.apache.commons.codec.binary.Base64; import org.apache.tools.ant.BuildException; import org.apache.tools.ant.Task; @@ -45,6 +53,30 @@ public class DigesterTask extends Task _appdir = appdir; } + /** + * Sets the digest signing keystore. + */ + public void setKeystore (File path) + { + _storepath = path; + } + + /** + * Sets the keystore decryption key. + */ + public void setStorepass (String password) + { + _storepass = password; + } + + /** + * Sets the private key alias. + */ + public void setAlias (String alias) + { + _storealias = alias; + } + /** * Performs the actual work of the task. */ @@ -52,16 +84,27 @@ public class DigesterTask extends Task { // make sure appdir is set if (_appdir == null) { - String errmsg = "Must specify the path to the application " + - "directory via the 'appdir' attribute."; - throw new BuildException(errmsg); + throw new BuildException("Must specify the path to the application directory " + + "via the 'appdir' attribute."); + } + + // make sure _storepass and _keyalias are set, if _storepath is set + if (_storepath != null) { + if (_storepass == null || _storealias == null) { + throw new BuildException( + "Must specify both a keystore password and a private key alias."); + } } try { createDigest(_appdir); + if (_storepath != null) { + signDigest(_appdir, _storepath, _storepass, _storealias); + } } catch (IOException ioe) { - throw new BuildException("Error creating digest: " + - ioe.getMessage(), ioe); + throw new BuildException("Error creating digest: " + ioe.getMessage(), ioe); + } catch (GeneralSecurityException gse) { + throw new BuildException("Error creating signature: " + gse.getMessage(), gse); } } @@ -90,6 +133,47 @@ public class DigesterTask extends Task Digest.createDigest(rsrcs, target); } + /** + * Creates a digest file in the specified application directory. + */ + public static void signDigest (File appdir, File storePath, String storePass, String storeAlias) + throws IOException, GeneralSecurityException + { + File inputFile = new File(appdir, Digest.DIGEST_FILE); + File signatureFile = new File(appdir, Digest.DIGEST_FILE + Application.SIGNATURE_SUFFIX); + + // initialize the keystore + KeyStore store = KeyStore.getInstance("JKS"); + FileInputStream storeInput = new FileInputStream(storePath); + store.load(storeInput, storePass.toCharArray()); + PrivateKey key = (PrivateKey)store.getKey(storeAlias, storePass.toCharArray()); + + // sign the digest file + Signature sig = Signature.getInstance("SHA1withRSA"); + FileInputStream dataInput = new FileInputStream(inputFile); + byte[] buffer = new byte[8192]; + int length; + + sig.initSign(key); + while ((length = dataInput.read(buffer)) != -1) { + sig.update(buffer, 0, length); + } + + // Write out the signature + FileOutputStream signatureOutput = new FileOutputStream(signatureFile); + String signed = new String(Base64.encodeBase64(sig.sign())); + signatureOutput.write(signed.getBytes("utf8")); + } + /** The application directory in which we're creating a digest file. */ protected File _appdir; + + /** The path to the keystore we'll use to sign the digest file, if any. */ + protected File _storepath; + + /** The decryption key for the keystore. */ + protected String _storepass; + + /** The private key alias. */ + protected String _storealias; }