rough work in progress; may not build
[tripe-android] / keys.scala
index f075159..b49e334 100644 (file)
@@ -27,19 +27,25 @@ package uk.org.distorted.tripe; package object keys {
 
 /*----- Imports -----------------------------------------------------------*/
 
-import java.io.{Closeable, File, FileOutputStream, FileReader, IOException};
-
 import scala.collection.mutable.HashMap;
 
+import java.io.{Closeable, File};
+import java.net.{URL, URLConnection};
+import java.util.zip.GZIPInputStream;
+
+import sys.{SystemError, hashsz, runCommand};
+import sys.Errno.EEXIST;
+import sys.FileImplicits._;
+
 /*----- Useful regular expressions ----------------------------------------*/
 
-val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
-val RX_KEYVAL = """(?x) ^ \s*
+private val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
+private val RX_KEYVAL = """(?x) ^ \s*
       ([-\w]+)
       (?:\s+(?!=)|\s*=\s*)
       (|\S|\S.*\S)
       \s* $""".r;
-val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
+private val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
 
 /*----- Things that go wrong ----------------------------------------------*/
 
@@ -62,7 +68,7 @@ class DefaultFailed(val key: String) extends Exception;
 
 type Config = scala.collection.Map[String, String];
 
-val DEFAULTS: Seq[(String, Config => String)] =
+private val DEFAULTS: Seq[(String, Config => String)] =
   Seq("repos-base" -> { _ => "tripe-keys.tar.gz" },
       "sig-base" -> { _ => "tripe-keys.sig-<SEQ>" },
       "repos-url" -> { conf => conf("base-url") + conf("repos-base") },
@@ -85,7 +91,7 @@ val DEFAULTS: Seq[(String, Config => String)] =
        case "naclbox" => "poly1305/128"
        case _ =>
          val h = conf("hash");
-         JNI.hashsz(h) match {
+         hashsz(h) match {
            case -1 => throw new DefaultFailed("hash")
            case hsz => s"${h}-hmac/${4*hsz}"
          }
@@ -100,36 +106,28 @@ val DEFAULTS: Seq[(String, Config => String)] =
       "sig-fresh" -> { _ => "always" },
       "fingerprint-hash" -> { _("hash") });
 
-def readConfig(path: String): Config = {
-  var m = HashMap[String, String]();
-  withCleaner { clean =>
-    var in = new FileReader(path); clean { in.close(); }
-    var lno = 1;
-    for (line <- lines(in)) {
-      line match {
-       case RX_COMMENT() => ();
-       case RX_KEYVAL(key, value) => m += key -> value;
-       case _ =>
-         throw new ConfigSyntaxError(path, lno, "failed to parse line");
-      }
-      lno += 1;
-    }
-  }
+/*----- Managing a key repository -----------------------------------------*/
 
-  for ((key, dflt) <- DEFAULTS) {
-    if (!(m contains key)) {
-      try { m += key -> dflt(m); }
-      catch {
-       case e: DefaultFailed =>
-         throw new ConfigDefaultFailed(path, key, e.key, m(e.key));
-      }
+def downloadToFile(file: File, url: URL, maxlen: Long = Long.MaxValue) {
+  fetchURL(url, new URLFetchCallbacks {
+    val out = file.openForOutput();
+    private def toobig() {
+      throw new KeyConfigException(s"remote file `$url' is " +
+                                  "suspiciously large");
     }
-  }
-  m
+    var totlen: Long = 0;
+    override def preflight(conn: URLConnection) {
+      totlen = conn.getContentLength;
+      if (totlen > maxlen) toobig();
+    }
+    override def done(win: Boolean) { out.close(); }
+    def write(buf: Array[Byte], n: Int, len: Long) {
+      if (len + n > maxlen) toobig();
+      out.write(buf, 0, n);
+    }
+  });
 }
 
-/*----- Managing a key repository -----------------------------------------*/
-
 /* Lifecycle notes
  *
  *   -> empty
@@ -167,55 +165,266 @@ object Repository {
   object State extends Enumeration {
     val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
   }
-
 }
 
+class RepositoryStateException(val state: Repository.State.Value,
+                              msg: String)
+       extends Exception(msg);
+
+class KeyConfigException(msg: String) extends Exception(msg);
+
 class Repository(val root: File) extends Closeable {
   import Repository.State.{Value => State, _};
 
-  val livedir = new File(root, "live");
-  val livereposdir = new File(livedir, "repos");
-  val newdir = new File(root, "new");
-  val olddir = new File(root, "old");
-  val pendingdir = new File(root, "pending");
-  val tmpdir = new File(root, "tmp");
-
-  val lock = {
-    if (!root.isDirectory && !root.mkdir()) ???;
-    val chan = new FileOutputStream(new File(root, "lk")).getChannel;
-    chan.tryLock() match {
-      case null =>
-       throw new IOException(s"repository `${root.getPath}' locked")
-      case lk => lk
+  /* Important directories and files. */
+  private[this] val livedir = root + "live";
+  private[this] val livereposdir = livedir + "repos";
+  private[this] val newdir = root + "new";
+  private[this] val olddir = root + "old";
+  private[this] val pendingdir = root + "pending";
+  private[this] val tmpdir = root + "tmp";
+
+  /* Take out a lock in case of other instances. */
+  private[this] val lock = {
+    try { root.mkdir_!(); }
+    catch { case SystemError(EEXIST, _) => ok; }
+    (root + "lk").lock_!()
+  }
+  def close() { lock.close(); }
+
+  /* Maintain a cache of some repository state. */
+  private var _state: State = null;
+  private var _config: Config = null;
+  private def invalidate() {
+    _state = null;
+    _config = null;
+  }
+
+  def state: State = {
+    /* Determine the current repository state. */
+
+    if (_state == null)
+      _state = if (livedir.isdir_!) {
+       if (!livereposdir.isdir_!) Confirmed
+       else if (newdir.isdir_!) Updating
+       else Live
+      } else {
+       if (newdir.isdir_!) Committing
+       else if (pendingdir.isdir_!) Pending
+       else Empty
+      }
+
+    _state
+  }
+
+  def checkState(wanted: State*) {
+    /* Ensure we're in a particular state. */
+    val st = state;
+    if (wanted.forall(_ != st)) {
+      throw new RepositoryStateException(st, s"Repository is $st, not " +
+                                        oxford("or",
+                                               wanted.map(_.toString)));
+    }
+  }
+
+  def cleanup() {
+
+    /* If we're part-way through an update then back out or press forward. */
+    state match {
+
+      case Updating =>
+       /* We have a new tree allegedly ready, but the current one is still
+        * in place.  It seems safer to zap the new one here, but we could go
+        * either way.
+        */
+
+       newdir.rmTree();
+       invalidate();            // should move back to `Live' or `Confirmed'
+
+      case Committing =>
+       /* We have a new tree ready, and an old one moved aside.  We're going
+        * to have to move one of them.  Let's try committing the new tree.
+        */
+
+       newdir.rename_!(livedir);       // should move on to `Live'
+       invalidate();
+
+      case _ =>
+       /* Other states are stable. */
+       ok;
     }
+
+    /* Now work through the things in our area of the filesystem and zap the
+     * ones which don't belong.  In particular, this will always erase
+     * `tmpdir'.
+     */
+    val st = state;
+    root.foreachFile { f => (f.getName, st) match {
+      case ("lk", _) => ok;
+      case ("live", Live | Confirmed) => ok;
+      case ("pending", Pending) => ok;
+      case (_, Updating | Committing) =>
+       unreachable(s"unexpectedly still in `$st' state");
+      case _ => f.rmTree();
+    }
+  } }
+
+  def destroy() {
+    /* Clear out the entire repository.  Everything.  It's all gone. */
+    root.foreachFile { f => if (f.getName != "lk") f.rmTree(); }
+  }
+
+  def clearTmp() {
+    /* Arrange to have an empty `tmpdir'. */
+    tmpdir.rmTree();
+    tmpdir.mkdir_!();
+  }
+
+  def config: Config = {
+    /* Return the repository configuration. */
+
+    if (_config == null) {
+
+      /* Firstly, decide where to find the configuration file. */
+      cleanup();
+      val dir = state match {
+       case Live | Confirmed => livedir
+       case Pending => pendingdir
+       case Empty =>
+         throw new RepositoryStateException(Empty, "repository is Empty");
+      }
+      val file = dir + "tripe-keys.conf";
+
+      /* Build the new configuration in a temporary place. */
+      var m = HashMap[String, String]();
+
+      /* Read the config file into our map. */
+      file.withReader { in =>
+       var lno = 1;
+       for (line <- lines(in)) {
+         line match {
+           case RX_COMMENT() => ok;
+           case RX_KEYVAL(key, value) => m += key -> value;
+           case _ =>
+             throw new ConfigSyntaxError(file.getPath, lno,
+                                         "failed to parse line");
+         }
+         lno += 1;
+       }
+      }
+
+      /* Fill in defaults where things have been missed out. */
+      for ((key, dflt) <- DEFAULTS) {
+       if (!(m contains key)) {
+         try { m += key -> dflt(m); }
+         catch {
+           case e: DefaultFailed =>
+             throw new ConfigDefaultFailed(file.getPath, key,
+                                           e.key, m(e.key));
+         }
+       }
+      }
+
+      /* All done. */
+      _config = m;
+    }
+
+    _config
   }
 
-  def close() {
-    lock.release();
-    lock.channel.close();
+  def fetchConfig(url: URL) {
+    /* Fetch an initial configuration file from a given URL. */
+
+    checkState(Empty);
+    clearTmp();
+    downloadToFile(tmpdir + "tripe-keys.conf", url);
+    tmpdir.rename_!(pendingdir);
+    invalidate();                      // should move to `Pending'
   }
 
-  def state: State =
-    if (livedir.isDirectory) {
-      if (!livereposdir.isDirectory) Confirmed
-      else if (newdir.isDirectory && olddir.isDirectory) Committing
-      else Live
-    } else {
-      if (newdir.isDirectory) Updating
-      else if (pendingdir.isDirectory) Pending
-      else Empty
+  def confirm() {
+    /* The user has approved the master key fingerprint in the `Pending'
+     * configuration.  Advance to `Confirmed'.
+     */
+
+    checkState(Pending);
+    pendingdir.rename_!(livedir);
+    invalidate();                      // should move to `Confirmed'
+  }
+
+  def update() {
+    /* Update the repository from the master.
+     *
+     * Fetch a (possibly new) archive; unpack it; verify the master key
+     * against the known fingerprint; and check the signature on the bundle.
+     */
+
+    checkState(Confirmed, Live);
+    val conf = config;
+    clearTmp();
+
+    /* First thing is to download the tarball and signature. */
+    val tarfile = tmpdir + "tripe-keys.tar.gz";
+    downloadToFile(tarfile, new URL(conf("repos-url")));
+    val sigfile = tmpdir + "tripe-keys.sig";
+    val seq = conf("master-sequence");
+    downloadToFile(sigfile,
+                  new URL(conf("sig-url").replaceAllLiterally("<SEQ>",
+                                                              seq)));
+
+    /* Unpack the tarball.  Carefully. */
+    val unpkdir = tmpdir + "unpk";
+    unpkdir.mkdir_!();
+    withCleaner { clean =>
+      val tar = new TarFile(new GZIPInputStream(tarfile.open()));
+      clean { tar.close(); }
+      for (e <- tar) {
+
+       /* Check the filename to make sure it's not evil. */
+       if (e.name.split('/').exists { _ == ".." })
+         throw new KeyConfigException("invalid path in tarball");
+
+       /* Find out where this file points. */
+       val f = unpkdir + e.name;
+
+       /* Unpack it. */
+       if (e.isdir) {
+         /* A directory.  Create it if it doesn't exist already. */
+
+         try { f.mkdir_!(); }
+         catch { case SystemError(EEXIST, _) => ok; }
+       } else if (e.isreg) {
+         /* A regular file.  Write stuff to it. */
+
+         e.withStream { in =>
+           f.withOutput { out =>
+             for ((b, n) <- blocks(in)) out.write(b, 0, n);
+           }
+         }
+       } else {
+         /* Something else.  Be paranoid and reject it. */
+
+         throw new KeyConfigException("unexpected object type in tarball");
+       }
+      }
     }
 
-  def commitState(): State = state match {
-    case Updating => rmTree(newdir); state
-    case Committing =>
-      if (!newdir.renameTo(livedir) && !olddir.renameTo(livedir))
-       throw new IOException("failed to commit update");
-      state
-    case st => st;
+    /* There ought to be a file in here called `repos/master.pub'. */
+    val reposdir = unpkdir + "repos";
+    if (!reposdir.isdir_!)
+      throw new KeyConfigException("missing `repos/' directory");
+    val masterfile = reposdir + "master.pub";
+    if (!masterfile.isreg_!)
+      throw new KeyConfigException("missing `repos/master.pub' file");
 
-  def clean() {
-       
+    /* Fetch the master key's fingerprint. */
+    val (out, _) = runCommand("key", "-k", masterfile.getPath,
+                             "fingerprint",
+                             "-f", "-secret",
+                             "-a", conf("fingerprint-hash"),
+                             s"master-$seq");
+    println(s";; $out");
+  }
 }
 
 /*----- That's all, folks -------------------------------------------------*/