X-Git-Url: https://git.distorted.org.uk/~mdw/tripe-android/blobdiff_plain/04a5abaece151705e9bd7026653f79938a7a2fbc..3bb2303d42adb3f37420f168b009ecfe64f888cd:/keys.scala diff --git a/keys.scala b/keys.scala index cec56a9..bdbede9 100644 --- a/keys.scala +++ b/keys.scala @@ -29,13 +29,17 @@ package uk.org.distorted.tripe; package object keys { import scala.collection.mutable.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}; @@ -49,6 +53,14 @@ private final val RX_KEYVAL = """(?x) ^ \s* \s* $""".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 ----------------------------------------------*/ class ConfigSyntaxError(val file: String, val lno: Int, val msg: String) @@ -108,7 +120,7 @@ private val DEFAULTS: Seq[(String, Config => String)] = "sig-fresh" -> { _ => "always" }, "fingerprint-hash" -> { _("hash") }); -private def readConfig(file: File): Config = { +private def parseConfig(file: File): HashMap[String, String] = { /* Build the new configuration in a temporary place. */ var m = HashMap[String, String](); @@ -119,7 +131,7 @@ private def readConfig(file: File): Config = { for (line <- lines(in)) { line match { case RX_COMMENT() => ok; - case RX_KEYVAL(key, value) => m += key -> value; + case RX_KEYVAL(key, value) => m(key) = value; case _ => throw new ConfigSyntaxError(file.getPath, lno, "failed to parse line"); @@ -128,10 +140,17 @@ private def readConfig(file: File): Config = { } } + /* 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); } + try { m(key) = dflt(m); } catch { case e: DefaultFailed => throw new ConfigDefaultFailed(file.getPath, key, @@ -225,10 +244,15 @@ private def keyFingerprint(kr: File, tag: String, hash: String): String = { nextToken(out) match { case Some((fp, _)) => fp case _ => - throw new java.io.IOException("unexpected output from `key fingerprint"); + 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; @@ -250,24 +274,80 @@ def checkConfigSanity(file: File, ic: Eyecandy) { } } +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 circumolution. + */ + (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 configdir = root/"config"; + private[this] val livedir = configdir/"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 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.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; @@ -296,6 +376,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 " + @@ -304,7 +385,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 { @@ -315,7 +396,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 => @@ -323,8 +405,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. */ @@ -335,21 +418,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(ic: Eyecandy) { /* Clear out the entire repository. Everything. It's all gone. */ ic.operation("clearing configuration") - { _ => root.foreachFile { f => if (f.getName != "lk") f.rmTree(); } } + { _ => root foreachFile { f => if (f.getName != "lk") f.rmTree(); } } } def clearTmp() { @@ -361,15 +449,15 @@ 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"); + case _ => ??? } /* And then read the configuration. */ @@ -388,9 +476,11 @@ class Repository(val root: File) extends Closeable { 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(ic: Eyecandy) { @@ -411,6 +501,7 @@ class Repository(val root: File) extends Closeable { * against the known fingerprint; and check the signature on the bundle. */ + cleanup(ic); checkState(Confirmed, Live); val conf = config; clearTmp(); @@ -435,8 +526,10 @@ class Repository(val root: File) extends Closeable { for (e <- tar) { /* Check the filename to make sure it's not evil. */ - if (e.name(0) == '/' || e.name.split('/').exists { _ == ".." }) - throw new KeyConfigException("invalid path in tarball"); + if (e.name(0) == '/' || e.name.split('/').exists { _ == ".." }) { + throw new KeyConfigException( + s"invalid path `${e.name}' in tarball"); + } /* Report on progress. */ or.step(s"entry `${e.name}'"); @@ -445,24 +538,26 @@ class Repository(val root: File) extends Closeable { val f = unpkdir/e.name; /* Unpack it. */ - if (e.isdir) { - /* A directory. Create it if it doesn't exist already. */ + e.typ match { + case DIR => + /* A directory. Create it if it doesn't exist already. */ + + f.mkdirNew_!(); - try { f.mkdir_!(); } - catch { case SystemError(EEXIST, _) => ok; } - } else if (e.isreg) { - /* A regular file. Write stuff to it. */ + 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); + 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( - s"entry `${e.name}' has unexpected object type"); + case ty => + /* Something else. Be paranoid and reject it. */ + + throw new KeyConfigException( + s"entry `${e.name}' has unexpected object type $ty"); } } } @@ -500,6 +595,19 @@ class Repository(val root: File) extends Closeable { /* Confirm that the configuration in the new archive is sane. */ checkConfigSanity(unpkdir/"tripe-keys.conf", ic); + /* Build the public keyring. (Observe the quadratic performance.) */ + ic.operation("collecting public keys") { or => + val pubkeys = unpkdir/"keyring.pub"; + pubkeys.remove_!(); + reposdir foreachFile { file => file.getName match { + case RX_PUBKEY(peer) if file.isreg_! => + or.step(peer); + runCommand("key", "-k", pubkeys.getPath, "merge", file.getPath); + case _ => ok; + } } + (unpkdir/"keyring.pub.old").remove_!(); + } + /* Now we just have to juggle the files about. */ ic.operation("committing new configuration") { _ => unpkdir.rename_!(newdir); @@ -507,8 +615,56 @@ class Repository(val root: File) extends Closeable { 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 -------------------------------------------------*/