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};
\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)
"sig-fresh" -> { _ => "always" },
"fingerprint-hash" -> { _("hash") });
-private def readConfig(file: File): Config = {
+private def parseConfig(file: File): Config = {
/* Build the new configuration in a temporary place. */
var m = HashMap[String, String]();
}
}
+ /* 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)) {
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;
}
}
+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;
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 " +
}
}
- def cleanup() {
+ def cleanup(ic: Eyecandy) {
/* If we're part-way through an update then back out or press forward. */
state match {
* either way.
*/
- newdir.rmTree();
+ ic.operation("rolling back failed update")
+ { _ => newdir.rmTree(); }
invalidate(); // should move back to `Live' or `Confirmed'
case Committing =>
* 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. */
* 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() {
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. */
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) {
* against the known fingerprint; and check the signature on the bundle.
*/
+ cleanup(ic);
checkState(Confirmed, Live);
val conf = config;
clearTmp();
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}'");
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");
}
}
}
/* 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);
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 -------------------------------------------------*/