/*----- 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 ----------------------------------------------*/
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-<SEQ>" },
"repos-url" -> { conf => conf("base-url") + conf("repos-base") },
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}"
}
"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
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>",
+ 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 -------------------------------------------------*/