X-Git-Url: https://git.distorted.org.uk/~mdw/tripe-android/blobdiff_plain/04a5abaece151705e9bd7026653f79938a7a2fbc..HEAD:/keys.scala diff --git a/keys.scala b/keys.scala index cec56a9..544462e 100644 --- a/keys.scala +++ b/keys.scala @@ -27,17 +27,22 @@ 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}; +import progress.{Eyecandy, SimpleModel, DataModel, DetailedModel}; +import Implicits.truish; /*----- Useful regular expressions ----------------------------------------*/ @@ -49,6 +54,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,10 +121,10 @@ 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](); + val m = HashMap[String, String](); /* Read the config file into our map. */ file.withReader { in => @@ -119,7 +132,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 +141,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 +245,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 +275,81 @@ 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 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 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 +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 " + @@ -304,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 { @@ -315,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 => @@ -323,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. */ @@ -335,21 +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(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 +451,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 +478,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 +503,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 +528,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 +540,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 +597,36 @@ 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. */ + 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); @@ -507,8 +634,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 -------------------------------------------------*/