X-Git-Url: https://git.distorted.org.uk/~mdw/tripe-android/blobdiff_plain/c8292b34485a2e00e676023d4164dd5841e4659f..HEAD:/keys.scala?ds=sidebyside diff --git a/keys.scala b/keys.scala index b49e334..544462e 100644 --- a/keys.scala +++ b/keys.scala @@ -27,25 +27,40 @@ package uk.org.distorted.tripe; package object keys { /*----- Imports -----------------------------------------------------------*/ -import scala.collection.mutable.HashMap; +import scala.collection.mutable.{ArrayBuffer, HashMap}; -import java.io.{Closeable, File}; +import java.io.{Closeable, File, IOException}; +import java.lang.{Long => JLong}; import java.net.{URL, URLConnection}; +import java.text.SimpleDateFormat; +import java.util.Date; import java.util.zip.GZIPInputStream; import sys.{SystemError, hashsz, runCommand}; import sys.Errno.EEXIST; import sys.FileImplicits._; +import sys.FileInfo.{DIR, REG}; + +import progress.{Eyecandy, SimpleModel, DataModel, DetailedModel}; +import Implicits.truish; /*----- Useful regular expressions ----------------------------------------*/ -private val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r; -private val RX_KEYVAL = """(?x) ^ \s* +private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r; +private final val RX_KEYVAL = """(?x) ^ \s* ([-\w]+) (?:\s+(?!=)|\s*=\s*) (|\S|\S.*\S) \s* $""".r; -private val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r; +private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r; + +private final val RX_PUBKEY = """(?x) ^ peer- (.*) \.pub $""".r; + +private final val RX_KEYINFO = """(?x) ^ ([^:]*) : \s* (\S.*) $""".r +private final val RX_KEYATTR = """(?x) ^ \s* + ([^\s=] | [^\s=][^=]*[^\s=]) + \s* = \s* + (\S.*) $""".r; /*----- Things that go wrong ----------------------------------------------*/ @@ -106,26 +121,77 @@ private val DEFAULTS: Seq[(String, Config => String)] = "sig-fresh" -> { _ => "always" }, "fingerprint-hash" -> { _("hash") }); -/*----- Managing a key repository -----------------------------------------*/ +private def parseConfig(file: File): HashMap[String, String] = { -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"); - } - var totlen: Long = 0; - override def preflight(conn: URLConnection) { - totlen = conn.getContentLength; - if (totlen > maxlen) toobig(); + /* Build the new configuration in a temporary place. */ + val 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; } - 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); + } + + /* Done. */ + m +} + +private def readConfig(file: File): Config = { + var m = parseConfig(file); + + /* 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)); + } } - }); + } + + /* And we're done. */ + m +} + +/*----- Managing a key repository -----------------------------------------*/ + +def downloadToFile(file: File, url: URL, + maxlen: Long = Long.MaxValue, + ic: Eyecandy) { + ic.job(new SimpleModel(s"connecting to `$url'", -1)) { jr => + fetchURL(url, new URLFetchCallbacks { + val out = file.openForOutput(); + private def toobig() { + throw new KeyConfigException( + s"remote file `$url' is suspiciously large"); + } + var totlen: Long = 0; + override def preflight(conn: URLConnection) { + totlen = conn.getContentLength; + if (totlen > maxlen) toobig(); + jr.change(new SimpleModel(s"downloading `$url'", totlen) + with DataModel, + 0); + } + 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); + jr.step(len + n); + } + }) + } } /* Lifecycle notes @@ -161,36 +227,129 @@ def downloadToFile(file: File, url: URL, maxlen: Long = Long.MaxValue) { * (delete old/) */ +class RepositoryStateException(val state: Repository.State.Value, + msg: String) + extends Exception(msg); + +class KeyConfigException(msg: String) extends Exception(msg); + +private def launderFingerprint(fp: String): String = + fp filter { _.isLetterOrDigit }; + +private def fingerprintsEqual(a: String, b: String) = + launderFingerprint(a) == launderFingerprint(b); + +private def keyFingerprint(kr: File, tag: String, hash: String): String = { + val (out, _) = runCommand("key", "-k", kr.getPath, "fingerprint", + "-a", hash, "-f", "-secret", tag); + nextToken(out) match { + case Some((fp, _)) => fp + case _ => + throw new IOException("unexpected output from `key fingerprint'"); + } +} + +private def checkIdent(id: String) { + if (id exists { ch => ch == ':' || ch == '.' || ch.isWhitespace }) + throw new IllegalArgumentException(s"bad key tag `$id'"); +} + 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); +def checkConfigSanity(file: File, ic: Eyecandy) { + ic.operation("checking new configuration") { _ => -class KeyConfigException(msg: String) extends Exception(msg); + /* Make sure we can read and understand the file. */ + val conf = readConfig(file); + + /* Make sure there are entries which we can use to update. This won't + * guarantee that we can reliably update, but it will help. + */ + conf("repos-url"); conf("sig-url"); + conf("fingerprint-hash"); conf("sig-fresh"); + conf("master-sequence"); conf("hk-master"); + } +} + +private val keydatefmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z"); + +class PrivateKey private[keys](repo: Repository, dir: File) { + private[this] lazy val keyring = dir/"keyring"; + private[this] lazy val meta = parseConfig(dir/"meta"); + lazy val tag = meta("tag"); + lazy val time = datefmt synchronized { datefmt.parse(meta("time")); }; + lazy val fingerprint = keyFingerprint(keyring, tag, + repo.config("fingerprint-hash")); + + def remove() { dir.rmTree(); } + + private[this] lazy val (info, _attr) = { + val m = Map.newBuilder[String, String]; + val a = Map.newBuilder[String, String]; + val (out, _) = runCommand("key", "-k", keyring.getPath, + "list", "-vv", tag); + val lines = out.lines; + while (lines.hasNext) lines.next match { + case "attributes:" => + while (lines.hasNext) lines.next match { + case RX_KEYATTR(k, v) => a += k -> v; + case line => throw new IOException( + s"unexpected output from `key list': $line"); + } + case RX_KEYINFO(k, v) => + m += k -> v; + case line => throw new IOException( + s"unexpected output from `key list': $line"); + } + (m.result, a.result) + } + + lazy val expires = info("expiry") match { + case "forever" => None + case d => Some(keydatefmt synchronized { keydatefmt.parse(d) }) + } + lazy val ty = info("type"); + lazy val comment = info("comment"); + lazy val keyid = { + + /* Ugh. Using `Int' throws an exception on words whose top bit is set + * because Java doesn't have proper unsigned integers. There's + * `parseUnsignedInt' in Java 1.8, but that limits our Android targets. + * And Scala has put its own `Long' object in the way of Java's so we + * need this circumlocution. + */ + (JLong.parseLong(info("keyid"), 16)&0xffffffff).toInt; + } + lazy val attr = _attr; +} class Repository(val root: File) extends Closeable { import Repository.State.{Value => State, _}; /* 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"; + private[this] val configdir = root/"config"; + private[this] val livedir = configdir/"live"; + private[this] val livereposdir = livedir/"repos"; + private[this] val newdir = configdir/"new"; + private[this] val olddir = configdir/"old"; + private[this] val pendingdir = configdir/"pending"; + private[this] val tmpdir = root/"tmp"; + private[this] val keysdir = root/"keys"; /* Take out a lock in case of other instances. */ + private[this] var open = false; private[this] val lock = { - try { root.mkdir_!(); } - catch { case SystemError(EEXIST, _) => ok; } - (root + "lk").lock_!() + root.mkdirNew_!(); + open = true; + (root/"lk").lock_!() } - def close() { lock.close(); } + def close() { lock.close(); open = false; } + private[this] def checkLocked() + { if (!open) throw new IllegalStateException("repository is unlocked"); } /* Maintain a cache of some repository state. */ private var _state: State = null; @@ -219,6 +378,7 @@ class Repository(val root: File) extends Closeable { def checkState(wanted: State*) { /* Ensure we're in a particular state. */ + checkLocked(); val st = state; if (wanted.forall(_ != st)) { throw new RepositoryStateException(st, s"Repository is $st, not " + @@ -227,7 +387,7 @@ class Repository(val root: File) extends Closeable { } } - def cleanup() { + def cleanup(ic: Eyecandy) { /* If we're part-way through an update then back out or press forward. */ state match { @@ -238,7 +398,8 @@ class Repository(val root: File) extends Closeable { * either way. */ - newdir.rmTree(); + ic.operation("rolling back failed update") + { _ => newdir.rmTree(); } invalidate(); // should move back to `Live' or `Confirmed' case Committing => @@ -246,8 +407,9 @@ class Repository(val root: File) extends Closeable { * to have to move one of them. Let's try committing the new tree. */ - newdir.rename_!(livedir); // should move on to `Live' - invalidate(); + ic.operation("committing interrupted update") + { _ => newdir.rename_!(livedir); } + invalidate(); // should move on to `Live' case _ => /* Other states are stable. */ @@ -258,20 +420,26 @@ class Repository(val root: File) extends Closeable { * 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(); + ic.operation("cleaning up configuration area") { or => + val st = state; + root foreachFile { f => f.getName match { + case "lk" | "keys" => ok; + case "config" => configdir foreachFile { f => (f.getName, st) match { + case ("live", Live | Confirmed) => ok; + case ("pending", Pending) => ok; + case (_, Updating | Committing) => + unreachable(s"unexpectedly still in `$st' state"); + case _ => or.step(s"delete `$f'"); f.rmTree(); + } } + case _ => or.step(s"delete `$f'"); f.rmTree(); + } } } - } } + } - def destroy() { + def destroy(ic: Eyecandy) { /* Clear out the entire repository. Everything. It's all gone. */ - root.foreachFile { f => if (f.getName != "lk") f.rmTree(); } + ic.operation("clearing configuration") + { _ => root foreachFile { f => if (f.getName != "lk") f.rmTree(); } } } def clearTmp() { @@ -283,148 +451,239 @@ class Repository(val root: File) extends Closeable { def config: Config = { /* Return the repository configuration. */ + checkLocked(); if (_config == null) { /* Firstly, decide where to find the configuration file. */ - cleanup(); + checkState(Pending, Confirmed, Live); 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)); - } - } + case _ => ??? } - /* All done. */ - _config = m; + /* And then read the configuration. */ + _config = readConfig(dir/"tripe-keys.conf"); } _config } - def fetchConfig(url: URL) { + def fetchConfig(url: URL, ic: Eyecandy) { /* Fetch an initial configuration file from a given URL. */ checkState(Empty); clearTmp(); - downloadToFile(tmpdir + "tripe-keys.conf", url); - tmpdir.rename_!(pendingdir); + + val conffile = tmpdir/"tripe-keys.conf"; + downloadToFile(conffile, url, 16*1024, ic); + checkConfigSanity(conffile, ic); + configdir.mkdirNew_!(); + ic.operation("committing configuration") + { _ => tmpdir.rename_!(pendingdir); } invalidate(); // should move to `Pending' + cleanup(ic); } - def confirm() { + def confirm(ic: Eyecandy) { /* The user has approved the master key fingerprint in the `Pending' * configuration. Advance to `Confirmed'. */ checkState(Pending); - pendingdir.rename_!(livedir); + ic.operation("confirming configuration") + { _ => pendingdir.rename_!(livedir); } invalidate(); // should move to `Confirmed' } - def update() { + def update(ic: Eyecandy) { /* 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. */ + cleanup(ic); 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 tarfile = tmpdir/"tripe-keys.tar.gz"; + downloadToFile(tarfile, new URL(conf("repos-url")), 256*1024, ic); + val sigfile = tmpdir/"tripe-keys.sig"; val seq = conf("master-sequence"); downloadToFile(sigfile, new URL(conf("sig-url").replaceAllLiterally("", - seq))); + seq)), + 4*1024, ic); /* 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); - } + val unpkdir = tmpdir/"unpk"; + ic.operation("unpacking archive") { or => + 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(0) == '/' || e.name.split('/').exists { _ == ".." }) { + throw new KeyConfigException( + s"invalid path `${e.name}' in tarball"); } - } else { - /* Something else. Be paranoid and reject it. */ - throw new KeyConfigException("unexpected object type in tarball"); + /* Report on progress. */ + or.step(s"entry `${e.name}'"); + + /* Find out where this file points. */ + val f = unpkdir/e.name; + + /* Unpack it. */ + e.typ match { + case DIR => + /* A directory. Create it if it doesn't exist already. */ + + f.mkdirNew_!(); + + case REG => + /* A regular file. Write stuff to it. */ + + e.withStream { in => + f.withOutput { out => + for ((b, n) <- blocks(in)) out.write(b, 0, n); + } + } + + case ty => + /* Something else. Be paranoid and reject it. */ + + throw new KeyConfigException( + s"entry `${e.name}' has unexpected object type $ty"); + } } } } /* There ought to be a file in here called `repos/master.pub'. */ - val reposdir = unpkdir + "repos"; + val reposdir = unpkdir/"repos"; + val masterfile = reposdir/"master.pub"; + 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"); + val mastertag = s"master-$seq"; /* 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"); + ic.operation("checking master key fingerprint") { _ => + val foundfp = keyFingerprint(masterfile, mastertag, + conf("fingerprint-hash")); + val wantfp = conf("hk-master"); + if (!fingerprintsEqual(wantfp, foundfp)) { + throw new KeyConfigException( + s"master key #$seq has wrong fingerprint: " + + s"expected $wantfp but found $foundfp"); + } + } + + /* Check the archive signature. */ + ic.operation("verifying archive signature") { or => + runCommand("catsign", "-k", masterfile.getPath, "verify", "-aqC", + "-k", mastertag, "-t", conf("sig-fresh"), + sigfile.getPath, tarfile.getPath); + } + + /* Confirm that the configuration in the new archive is sane. */ + checkConfigSanity(unpkdir/"tripe-keys.conf", ic); + + /* Build the public keyring. */ + ic.job(new SimpleModel("counting public keys", -1)) { jr => + + /* Delete the accumulated keyring. */ + val pubkeys = unpkdir/"keyring.pub"; + pubkeys.remove_!(); + + /* Figure out which files we need to hack. */ + var kv = ArrayBuffer[File](); + reposdir.foreachFile { file => file.getName match { + case RX_PUBKEY(peer) if file.isreg_! => kv += file; + case _ => ok; + } } + kv = kv.sorted; + val m = new DetailedModel("collecting public keys", kv.length); + var i: Long = 0; + + /* Work through the key files. */ + for (k <- kv) { + m.detail = k.getName; + if (!i) jr.change(m, i); + else jr.step(i); + runCommand("key", "-k", pubkeys.getPath, "merge", k.getPath); + i += 1; + } + + /* Clean up finally. */ + (unpkdir/"keyring.pub.old").remove_!(); + } + + /* Now we just have to juggle the files about. */ + ic.operation("committing new configuration") { _ => + unpkdir.rename_!(newdir); + livedir.rename_!(olddir); + newdir.rename_!(livedir); + } + + /* All done. */ + invalidate(); // should move to `Live' + cleanup(ic); + } + + def generateKey(tag: String, label: String, ic: Eyecandy) { + checkIdent(tag); + if (label.exists { _ == '/' }) + throw new IllegalArgumentException(s"invalid label string `$label'"); + if ((keysdir/label).isdir_!) + throw new IllegalArgumentException(s"key `$label' already exists"); + + cleanup(ic); + checkState(Live); + val conf = config; + clearTmp(); + + val now = datefmt synchronized { datefmt.format(new Date) }; + val kr = tmpdir/"keyring"; + val pub = tmpdir/s"peer-$tag.pub"; + val param = livereposdir/"param"; + + keysdir.mkdirNew_!(); + + ic.operation("fetching key-generation parameters") { _ => + runCommand("key", "-k", kr.getPath, "merge", param.getPath); + } + ic.operation("generating new key") { _ => + runCommand("key", "-k", kr.getPath, "add", + "-a", conf("kx-genalg"), "-p", "param", + "-e", conf("kx-expire"), "-t", tag, "tripe"); + } + ic.operation("extracting public key") { _ => + runCommand("key", "-k", kr.getPath, "extract", + "-f", "-secret", pub.getPath, tag); + } + ic.operation("writing metadata") { _ => + tmpdir/"meta" withWriter { w => + w.write(s"tag = $tag\n"); + w.write(s"time = $now\n"); + } + } + ic.operation("installing new key") { _ => + tmpdir.rename_!(keysdir/label); + } } + + def key(label: String): PrivateKey = new PrivateKey(this, keysdir/label); + def keyLabels: Seq[String] = (keysdir.files_! map { _.getName }).toStream; + def keys: Seq[PrivateKey] = keyLabels map { k => key(k) }; } /*----- That's all, folks -------------------------------------------------*/