5 * (c) 2018 Straylight/Edgeware
8 /*----- Licensing notice --------------------------------------------------*
10 * This file is part of the Trivial IP Encryption (TrIPE) Android app.
12 * TrIPE is free software: you can redistribute it and/or modify it under
13 * the terms of the GNU General Public License as published by the Free
14 * Software Foundation; either version 3 of the License, or (at your
15 * option) any later version.
17 * TrIPE is distributed in the hope that it will be useful, but WITHOUT
18 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
19 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
22 * You should have received a copy of the GNU General Public License
23 * along with TrIPE. If not, see <https://www.gnu.org/licenses/>.
26 package uk.org.distorted.tripe; package object keys {
28 /*----- Imports -----------------------------------------------------------*/
30 import scala.collection.mutable.{ArrayBuffer, HashMap};
32 import java.io.{Closeable, File, IOException};
33 import java.lang.{Long => JLong};
34 import java.net.{URL, URLConnection};
35 import java.text.SimpleDateFormat;
36 import java.util.Date;
37 import java.util.zip.GZIPInputStream;
39 import sys.{SystemError, hashsz, runCommand};
40 import sys.Errno.EEXIST;
41 import sys.FileImplicits._;
42 import sys.FileInfo.{DIR, REG};
44 import progress.{Eyecandy, SimpleModel, DataModel, DetailedModel};
45 import Implicits.truish;
47 /*----- Useful regular expressions ----------------------------------------*/
49 private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
50 private final val RX_KEYVAL = """(?x) ^ \s*
55 private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
57 private final val RX_PUBKEY = """(?x) ^ peer- (.*) \.pub $""".r;
59 private final val RX_KEYINFO = """(?x) ^ ([^:]*) : \s* (\S.*) $""".r
60 private final val RX_KEYATTR = """(?x) ^ \s*
61 ([^\s=] | [^\s=][^=]*[^\s=])
65 /*----- Things that go wrong ----------------------------------------------*/
67 class ConfigSyntaxError(val file: String, val lno: Int, val msg: String)
69 override def getMessage(): String = s"$file:$lno: $msg";
72 class ConfigDefaultFailed(val file: String, val dfltkey: String,
73 val badkey: String, val badval: String)
75 override def getMessage(): String =
76 s"$file: can't default `$dfltkey' because " +
77 s"`$badval' is not a recognized value for `$badkey'";
80 class DefaultFailed(val key: String) extends Exception;
82 /*----- Parsing a configuration -------------------------------------------*/
84 type Config = scala.collection.Map[String, String];
86 private val DEFAULTS: Seq[(String, Config => String)] =
87 Seq("repos-base" -> { _ => "tripe-keys.tar.gz" },
88 "sig-base" -> { _ => "tripe-keys.sig-<SEQ>" },
89 "repos-url" -> { conf => conf("base-url") + conf("repos-base") },
90 "sig-url" -> { conf => conf("base-url") + conf("sig-base") },
91 "kx" -> { _ => "dh" },
92 "kx-genalg" -> { conf => conf("kx") match {
93 case alg@("dh" | "ec" | "x25519" | "x448") => alg
94 case _ => throw new DefaultFailed("kx")
96 "kx-expire" -> { _ => "now + 1 year" },
97 "kx-warn-days" -> { _ => "28" },
98 "bulk" -> { _ => "iiv" },
99 "cipher" -> { conf => conf("bulk") match {
100 case "naclbox" => "salsa20"
101 case _ => "rijndael-cbc"
103 "hash" -> { _ => "sha256" },
104 "mgf" -> { conf => conf("hash") + "-mgf" },
105 "mac" -> { conf => conf("bulk") match {
106 case "naclbox" => "poly1305/128"
108 val h = conf("hash");
110 case -1 => throw new DefaultFailed("hash")
111 case hsz => s"${h}-hmac/${4*hsz}"
114 "sig" -> { conf => conf("kx") match {
117 case "x25519" => "ed25519"
118 case "x448" => "ed448"
119 case _ => throw new DefaultFailed("kx")
121 "sig-fresh" -> { _ => "always" },
122 "fingerprint-hash" -> { _("hash") });
124 private def parseConfig(file: File): HashMap[String, String] = {
126 /* Build the new configuration in a temporary place. */
127 val m = HashMap[String, String]();
129 /* Read the config file into our map. */
130 file.withReader { in =>
132 for (line <- lines(in)) {
134 case RX_COMMENT() => ok;
135 case RX_KEYVAL(key, value) => m(key) = value;
137 throw new ConfigSyntaxError(file.getPath, lno,
138 "failed to parse line");
148 private def readConfig(file: File): Config = {
149 var m = parseConfig(file);
151 /* Fill in defaults where things have been missed out. */
152 for ((key, dflt) <- DEFAULTS) {
153 if (!(m contains key)) {
154 try { m(key) = dflt(m); }
156 case e: DefaultFailed =>
157 throw new ConfigDefaultFailed(file.getPath, key,
163 /* And we're done. */
167 /*----- Managing a key repository -----------------------------------------*/
169 def downloadToFile(file: File, url: URL,
170 maxlen: Long = Long.MaxValue,
172 ic.job(new SimpleModel(s"connecting to `$url'", -1)) { jr =>
173 fetchURL(url, new URLFetchCallbacks {
174 val out = file.openForOutput();
175 private def toobig() {
176 throw new KeyConfigException(
177 s"remote file `$url' is suspiciously large");
179 var totlen: Long = 0;
180 override def preflight(conn: URLConnection) {
181 totlen = conn.getContentLength;
182 if (totlen > maxlen) toobig();
183 jr.change(new SimpleModel(s"downloading `$url'", totlen)
187 override def done(win: Boolean) { out.close(); }
188 def write(buf: Array[Byte], n: Int, len: Long) {
189 if (len + n > maxlen) toobig();
190 out.write(buf, 0, n);
201 * insert config file via URL or something
203 * -> pending (pending/tripe-keys.conf)
205 * verify master key fingerprint (against barcode?)
207 * -> confirmed (live/tripe-keys.conf; no live/repos)
217 * -> updating (live/...; new/...)
219 * rename old repository aside
221 * -> committing (old/...; new/...)
223 * rename verified repository
230 class RepositoryStateException(val state: Repository.State.Value,
232 extends Exception(msg);
234 class KeyConfigException(msg: String) extends Exception(msg);
236 private def launderFingerprint(fp: String): String =
237 fp filter { _.isLetterOrDigit };
239 private def fingerprintsEqual(a: String, b: String) =
240 launderFingerprint(a) == launderFingerprint(b);
242 private def keyFingerprint(kr: File, tag: String, hash: String): String = {
243 val (out, _) = runCommand("key", "-k", kr.getPath, "fingerprint",
244 "-a", hash, "-f", "-secret", tag);
245 nextToken(out) match {
246 case Some((fp, _)) => fp
248 throw new IOException("unexpected output from `key fingerprint'");
252 private def checkIdent(id: String) {
253 if (id exists { ch => ch == ':' || ch == '.' || ch.isWhitespace })
254 throw new IllegalArgumentException(s"bad key tag `$id'");
258 object State extends Enumeration {
259 val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
263 def checkConfigSanity(file: File, ic: Eyecandy) {
264 ic.operation("checking new configuration") { _ =>
266 /* Make sure we can read and understand the file. */
267 val conf = readConfig(file);
269 /* Make sure there are entries which we can use to update. This won't
270 * guarantee that we can reliably update, but it will help.
272 conf("repos-url"); conf("sig-url");
273 conf("fingerprint-hash"); conf("sig-fresh");
274 conf("master-sequence"); conf("hk-master");
278 private val keydatefmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
280 class PrivateKey private[keys](repo: Repository, dir: File) {
281 private[this] lazy val keyring = dir/"keyring";
282 private[this] lazy val meta = parseConfig(dir/"meta");
283 lazy val tag = meta("tag");
284 lazy val time = datefmt synchronized { datefmt.parse(meta("time")); };
285 lazy val fingerprint = keyFingerprint(keyring, tag,
286 repo.config("fingerprint-hash"));
288 def remove() { dir.rmTree(); }
290 private[this] lazy val (info, _attr) = {
291 val m = Map.newBuilder[String, String];
292 val a = Map.newBuilder[String, String];
293 val (out, _) = runCommand("key", "-k", keyring.getPath,
295 val lines = out.lines;
296 while (lines.hasNext) lines.next match {
297 case "attributes:" =>
298 while (lines.hasNext) lines.next match {
299 case RX_KEYATTR(k, v) => a += k -> v;
300 case line => throw new IOException(
301 s"unexpected output from `key list': $line");
303 case RX_KEYINFO(k, v) =>
305 case line => throw new IOException(
306 s"unexpected output from `key list': $line");
311 lazy val expires = info("expiry") match {
312 case "forever" => None
313 case d => Some(keydatefmt synchronized { keydatefmt.parse(d) })
315 lazy val ty = info("type");
316 lazy val comment = info("comment");
319 /* Ugh. Using `Int' throws an exception on words whose top bit is set
320 * because Java doesn't have proper unsigned integers. There's
321 * `parseUnsignedInt' in Java 1.8, but that limits our Android targets.
322 * And Scala has put its own `Long' object in the way of Java's so we
323 * need this circumlocution.
325 (JLong.parseLong(info("keyid"), 16)&0xffffffff).toInt;
327 lazy val attr = _attr;
330 class Repository(val root: File) extends Closeable {
331 import Repository.State.{Value => State, _};
333 /* Important directories and files. */
334 private[this] val configdir = root/"config";
335 private[this] val livedir = configdir/"live";
336 private[this] val livereposdir = livedir/"repos";
337 private[this] val newdir = configdir/"new";
338 private[this] val olddir = configdir/"old";
339 private[this] val pendingdir = configdir/"pending";
340 private[this] val tmpdir = root/"tmp";
341 private[this] val keysdir = root/"keys";
343 /* Take out a lock in case of other instances. */
344 private[this] var open = false;
345 private[this] val lock = {
350 def close() { lock.close(); open = false; }
351 private[this] def checkLocked()
352 { if (!open) throw new IllegalStateException("repository is unlocked"); }
354 /* Maintain a cache of some repository state. */
355 private var _state: State = null;
356 private var _config: Config = null;
357 private def invalidate() {
363 /* Determine the current repository state. */
366 _state = if (livedir.isdir_!) {
367 if (!livereposdir.isdir_!) Confirmed
368 else if (newdir.isdir_!) Updating
371 if (newdir.isdir_!) Committing
372 else if (pendingdir.isdir_!) Pending
379 def checkState(wanted: State*) {
380 /* Ensure we're in a particular state. */
383 if (wanted.forall(_ != st)) {
384 throw new RepositoryStateException(st, s"Repository is $st, not " +
386 wanted.map(_.toString)));
390 def cleanup(ic: Eyecandy) {
392 /* If we're part-way through an update then back out or press forward. */
396 /* We have a new tree allegedly ready, but the current one is still
397 * in place. It seems safer to zap the new one here, but we could go
401 ic.operation("rolling back failed update")
402 { _ => newdir.rmTree(); }
403 invalidate(); // should move back to `Live' or `Confirmed'
406 /* We have a new tree ready, and an old one moved aside. We're going
407 * to have to move one of them. Let's try committing the new tree.
410 ic.operation("committing interrupted update")
411 { _ => newdir.rename_!(livedir); }
412 invalidate(); // should move on to `Live'
415 /* Other states are stable. */
419 /* Now work through the things in our area of the filesystem and zap the
420 * ones which don't belong. In particular, this will always erase
423 ic.operation("cleaning up configuration area") { or =>
425 root foreachFile { f => f.getName match {
426 case "lk" | "keys" => ok;
427 case "config" => configdir foreachFile { f => (f.getName, st) match {
428 case ("live", Live | Confirmed) => ok;
429 case ("pending", Pending) => ok;
430 case (_, Updating | Committing) =>
431 unreachable(s"unexpectedly still in `$st' state");
432 case _ => or.step(s"delete `$f'"); f.rmTree();
434 case _ => or.step(s"delete `$f'"); f.rmTree();
439 def destroy(ic: Eyecandy) {
440 /* Clear out the entire repository. Everything. It's all gone. */
441 ic.operation("clearing configuration")
442 { _ => root foreachFile { f => if (f.getName != "lk") f.rmTree(); } }
446 /* Arrange to have an empty `tmpdir'. */
451 def config: Config = {
452 /* Return the repository configuration. */
455 if (_config == null) {
457 /* Firstly, decide where to find the configuration file. */
458 checkState(Pending, Confirmed, Live);
459 val dir = state match {
460 case Live | Confirmed => livedir
461 case Pending => pendingdir
465 /* And then read the configuration. */
466 _config = readConfig(dir/"tripe-keys.conf");
472 def fetchConfig(url: URL, ic: Eyecandy) {
473 /* Fetch an initial configuration file from a given URL. */
478 val conffile = tmpdir/"tripe-keys.conf";
479 downloadToFile(conffile, url, 16*1024, ic);
480 checkConfigSanity(conffile, ic);
481 configdir.mkdirNew_!();
482 ic.operation("committing configuration")
483 { _ => tmpdir.rename_!(pendingdir); }
484 invalidate(); // should move to `Pending'
488 def confirm(ic: Eyecandy) {
489 /* The user has approved the master key fingerprint in the `Pending'
490 * configuration. Advance to `Confirmed'.
494 ic.operation("confirming configuration")
495 { _ => pendingdir.rename_!(livedir); }
496 invalidate(); // should move to `Confirmed'
499 def update(ic: Eyecandy) {
500 /* Update the repository from the master.
502 * Fetch a (possibly new) archive; unpack it; verify the master key
503 * against the known fingerprint; and check the signature on the bundle.
507 checkState(Confirmed, Live);
511 /* First thing is to download the tarball and signature. */
512 val tarfile = tmpdir/"tripe-keys.tar.gz";
513 downloadToFile(tarfile, new URL(conf("repos-url")), 256*1024, ic);
514 val sigfile = tmpdir/"tripe-keys.sig";
515 val seq = conf("master-sequence");
516 downloadToFile(sigfile,
517 new URL(conf("sig-url").replaceAllLiterally("<SEQ>",
521 /* Unpack the tarball. Carefully. */
522 val unpkdir = tmpdir/"unpk";
523 ic.operation("unpacking archive") { or =>
525 withCleaner { clean =>
526 val tar = new TarFile(new GZIPInputStream(tarfile.open()));
527 clean { tar.close(); }
530 /* Check the filename to make sure it's not evil. */
531 if (e.name(0) == '/' || e.name.split('/').exists { _ == ".." }) {
532 throw new KeyConfigException(
533 s"invalid path `${e.name}' in tarball");
536 /* Report on progress. */
537 or.step(s"entry `${e.name}'");
539 /* Find out where this file points. */
540 val f = unpkdir/e.name;
545 /* A directory. Create it if it doesn't exist already. */
550 /* A regular file. Write stuff to it. */
553 f.withOutput { out =>
554 for ((b, n) <- blocks(in)) out.write(b, 0, n);
559 /* Something else. Be paranoid and reject it. */
561 throw new KeyConfigException(
562 s"entry `${e.name}' has unexpected object type $ty");
568 /* There ought to be a file in here called `repos/master.pub'. */
569 val reposdir = unpkdir/"repos";
570 val masterfile = reposdir/"master.pub";
572 if (!reposdir.isdir_!)
573 throw new KeyConfigException("missing `repos/' directory");
574 if (!masterfile.isreg_!)
575 throw new KeyConfigException("missing `repos/master.pub' file");
576 val mastertag = s"master-$seq";
578 /* Fetch the master key's fingerprint. */
579 ic.operation("checking master key fingerprint") { _ =>
580 val foundfp = keyFingerprint(masterfile, mastertag,
581 conf("fingerprint-hash"));
582 val wantfp = conf("hk-master");
583 if (!fingerprintsEqual(wantfp, foundfp)) {
584 throw new KeyConfigException(
585 s"master key #$seq has wrong fingerprint: " +
586 s"expected $wantfp but found $foundfp");
590 /* Check the archive signature. */
591 ic.operation("verifying archive signature") { or =>
592 runCommand("catsign", "-k", masterfile.getPath, "verify", "-aqC",
593 "-k", mastertag, "-t", conf("sig-fresh"),
594 sigfile.getPath, tarfile.getPath);
597 /* Confirm that the configuration in the new archive is sane. */
598 checkConfigSanity(unpkdir/"tripe-keys.conf", ic);
600 /* Build the public keyring. */
601 ic.job(new SimpleModel("counting public keys", -1)) { jr =>
603 /* Delete the accumulated keyring. */
604 val pubkeys = unpkdir/"keyring.pub";
607 /* Figure out which files we need to hack. */
608 var kv = ArrayBuffer[File]();
609 reposdir.foreachFile { file => file.getName match {
610 case RX_PUBKEY(peer) if file.isreg_! => kv += file;
614 val m = new DetailedModel("collecting public keys", kv.length);
617 /* Work through the key files. */
619 m.detail = k.getName;
620 if (!i) jr.change(m, i);
622 runCommand("key", "-k", pubkeys.getPath, "merge", k.getPath);
626 /* Clean up finally. */
627 (unpkdir/"keyring.pub.old").remove_!();
630 /* Now we just have to juggle the files about. */
631 ic.operation("committing new configuration") { _ =>
632 unpkdir.rename_!(newdir);
633 livedir.rename_!(olddir);
634 newdir.rename_!(livedir);
638 invalidate(); // should move to `Live'
642 def generateKey(tag: String, label: String, ic: Eyecandy) {
644 if (label.exists { _ == '/' })
645 throw new IllegalArgumentException(s"invalid label string `$label'");
646 if ((keysdir/label).isdir_!)
647 throw new IllegalArgumentException(s"key `$label' already exists");
654 val now = datefmt synchronized { datefmt.format(new Date) };
655 val kr = tmpdir/"keyring";
656 val pub = tmpdir/s"peer-$tag.pub";
657 val param = livereposdir/"param";
659 keysdir.mkdirNew_!();
661 ic.operation("fetching key-generation parameters") { _ =>
662 runCommand("key", "-k", kr.getPath, "merge", param.getPath);
664 ic.operation("generating new key") { _ =>
665 runCommand("key", "-k", kr.getPath, "add",
666 "-a", conf("kx-genalg"), "-p", "param",
667 "-e", conf("kx-expire"), "-t", tag, "tripe");
669 ic.operation("extracting public key") { _ =>
670 runCommand("key", "-k", kr.getPath, "extract",
671 "-f", "-secret", pub.getPath, tag);
673 ic.operation("writing metadata") { _ =>
674 tmpdir/"meta" withWriter { w =>
675 w.write(s"tag = $tag\n");
676 w.write(s"time = $now\n");
679 ic.operation("installing new key") { _ =>
680 tmpdir.rename_!(keysdir/label);
684 def key(label: String): PrivateKey = new PrivateKey(this, keysdir/label);
685 def keyLabels: Seq[String] = (keysdir.files_! map { _.getName }).toStream;
686 def keys: Seq[PrivateKey] = keyLabels map { k => key(k) };
689 /*----- That's all, folks -------------------------------------------------*/