X-Git-Url: https://git.distorted.org.uk/~mdw/tripe-android/blobdiff_plain/25c3546915ef2105c0d53983939da840ddbde795..c8292b34485a2e00e676023d4164dd5841e4659f:/keys.scala diff --git a/keys.scala b/keys.scala index f075159..b49e334 100644 --- a/keys.scala +++ b/keys.scala @@ -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-" }, "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))); + + /* 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 -------------------------------------------------*/