chiark / gitweb /
keys.scala, etc.: Make merging public keys have a progress bar.
[tripe-android] / keys.scala
1 /* -*-scala-*-
2  *
3  * Key distribution
4  *
5  * (c) 2018 Straylight/Edgeware
6  */
7
8 /*----- Licensing notice --------------------------------------------------*
9  *
10  * This file is part of the Trivial IP Encryption (TrIPE) Android app.
11  *
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.
16  *
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
20  * for more details.
21  *
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/>.
24  */
25
26 package uk.org.distorted.tripe; package object keys {
27
28 /*----- Imports -----------------------------------------------------------*/
29
30 import scala.collection.mutable.{ArrayBuffer, HashMap};
31
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;
38
39 import sys.{SystemError, hashsz, runCommand};
40 import sys.Errno.EEXIST;
41 import sys.FileImplicits._;
42 import sys.FileInfo.{DIR, REG};
43
44 import progress.{Eyecandy, SimpleModel, DataModel, DetailedModel};
45 import Implicits.truish;
46
47 /*----- Useful regular expressions ----------------------------------------*/
48
49 private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
50 private final val RX_KEYVAL = """(?x) ^ \s*
51       ([-\w]+)
52       (?:\s+(?!=)|\s*=\s*)
53       (|\S|\S.*\S)
54       \s* $""".r;
55 private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
56
57 private final val RX_PUBKEY = """(?x) ^ peer- (.*) \.pub $""".r;
58
59 private final val RX_KEYINFO = """(?x) ^ ([^:]*) : \s* (\S.*) $""".r
60 private final val RX_KEYATTR = """(?x) ^ \s*
61         ([^\s=] | [^\s=][^=]*[^\s=])
62         \s* = \s*
63         (\S.*) $""".r;
64
65 /*----- Things that go wrong ----------------------------------------------*/
66
67 class ConfigSyntaxError(val file: String, val lno: Int, val msg: String)
68         extends Exception {
69   override def getMessage(): String = s"$file:$lno: $msg";
70 }
71
72 class ConfigDefaultFailed(val file: String, val dfltkey: String,
73                           val badkey: String, val badval: String)
74         extends Exception {
75   override def getMessage(): String =
76     s"$file: can't default `$dfltkey' because " +
77           s"`$badval' is not a recognized value for `$badkey'";
78 }
79
80 class DefaultFailed(val key: String) extends Exception;
81
82 /*----- Parsing a configuration -------------------------------------------*/
83
84 type Config = scala.collection.Map[String, String];
85
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")
95       } },
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"
102       } },
103       "hash" -> { _ => "sha256" },
104       "mgf" -> { conf => conf("hash") + "-mgf" },
105       "mac" -> { conf => conf("bulk") match {
106         case "naclbox" => "poly1305/128"
107         case _ =>
108           val h = conf("hash");
109           hashsz(h) match {
110             case -1 => throw new DefaultFailed("hash")
111             case hsz => s"${h}-hmac/${4*hsz}"
112           }
113       } },
114       "sig" -> { conf => conf("kx") match {
115         case "dh" => "dsa"
116         case "ec" => "ecdsa"
117         case "x25519" => "ed25519"
118         case "x448" => "ed448"
119         case _ => throw new DefaultFailed("kx")
120       } },
121       "sig-fresh" -> { _ => "always" },
122       "fingerprint-hash" -> { _("hash") });
123
124 private def parseConfig(file: File): HashMap[String, String] = {
125
126   /* Build the new configuration in a temporary place. */
127   val m = HashMap[String, String]();
128
129   /* Read the config file into our map. */
130   file.withReader { in =>
131     var lno = 1;
132     for (line <- lines(in)) {
133       line match {
134         case RX_COMMENT() => ok;
135         case RX_KEYVAL(key, value) => m(key) = value;
136         case _ =>
137           throw new ConfigSyntaxError(file.getPath, lno,
138                                       "failed to parse line");
139       }
140       lno += 1;
141     }
142   }
143
144   /* Done. */
145   m
146 }
147
148 private def readConfig(file: File): Config = {
149   var m = parseConfig(file);
150
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); }
155       catch {
156         case e: DefaultFailed =>
157           throw new ConfigDefaultFailed(file.getPath, key,
158                                         e.key, m(e.key));
159       }
160     }
161   }
162
163   /* And we're done. */
164   m
165 }
166
167 /*----- Managing a key repository -----------------------------------------*/
168
169 def downloadToFile(file: File, url: URL,
170                    maxlen: Long = Long.MaxValue,
171                    ic: Eyecandy) {
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");
178       }
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)
184                     with DataModel,
185                   0);
186       }
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);
191         jr.step(len + n);
192       }
193     })
194   }
195 }
196
197 /* Lifecycle notes
198  *
199  *   -> empty
200  *
201  * insert config file via URL or something
202  *
203  *   -> pending (pending/tripe-keys.conf)
204  *
205  * verify master key fingerprint (against barcode?)
206  *
207  *   -> confirmed (live/tripe-keys.conf; no live/repos)
208  *   -> live (live/...)
209  *
210  * download package
211  * extract contents
212  * verify signature
213  * build keyrings
214  * build peer config
215  * rename tmp -> new
216  *
217  *   -> updating (live/...; new/...)
218  *
219  * rename old repository aside
220  *
221  *   -> committing (old/...; new/...)
222  *
223  * rename verified repository
224  *
225  *   -> live (live/...)
226  *
227  * (delete old/)
228  */
229
230 class RepositoryStateException(val state: Repository.State.Value,
231                                msg: String)
232         extends Exception(msg);
233
234 class KeyConfigException(msg: String) extends Exception(msg);
235
236 private def launderFingerprint(fp: String): String =
237   fp filter { _.isLetterOrDigit };
238
239 private def fingerprintsEqual(a: String, b: String) =
240   launderFingerprint(a) == launderFingerprint(b);
241
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
247     case _ =>
248       throw new IOException("unexpected output from `key fingerprint'");
249   }
250 }
251
252 private def checkIdent(id: String) {
253   if (id exists { ch => ch == ':' || ch == '.' || ch.isWhitespace })
254     throw new IllegalArgumentException(s"bad key tag `$id'");
255 }
256
257 object Repository {
258   object State extends Enumeration {
259     val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
260   }
261 }
262
263 def checkConfigSanity(file: File, ic: Eyecandy) {
264   ic.operation("checking new configuration") { _ =>
265
266     /* Make sure we can read and understand the file. */
267     val conf = readConfig(file);
268
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.
271      */
272     conf("repos-url"); conf("sig-url");
273     conf("fingerprint-hash"); conf("sig-fresh");
274     conf("master-sequence"); conf("hk-master");
275   }
276 }
277
278 private val keydatefmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
279
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"));
287
288   def remove() { dir.rmTree(); }
289
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,
294                               "list", "-vv", tag);
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");
302         }
303       case RX_KEYINFO(k, v) =>
304         m += k -> v;
305       case line => throw new IOException(
306         s"unexpected output from `key list': $line");
307     }
308     (m.result, a.result)
309   }
310
311   lazy val expires = info("expiry") match {
312     case "forever" => None
313     case d => Some(keydatefmt synchronized { keydatefmt.parse(d) })
314   }
315   lazy val ty = info("type");
316   lazy val comment = info("comment");
317   lazy val keyid = {
318
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.
324      */
325     (JLong.parseLong(info("keyid"), 16)&0xffffffff).toInt;
326   }
327   lazy val attr = _attr;
328 }
329
330 class Repository(val root: File) extends Closeable {
331   import Repository.State.{Value => State, _};
332
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";
342
343   /* Take out a lock in case of other instances. */
344   private[this] var open = false;
345   private[this] val lock = {
346      root.mkdirNew_!();
347     open = true;
348     (root/"lk").lock_!()
349   }
350   def close() { lock.close(); open = false; }
351   private[this] def checkLocked()
352     { if (!open) throw new IllegalStateException("repository is unlocked"); }
353
354   /* Maintain a cache of some repository state. */
355   private var _state: State = null;
356   private var _config: Config = null;
357   private def invalidate() {
358     _state = null;
359     _config = null;
360   }
361
362   def state: State = {
363     /* Determine the current repository state. */
364
365     if (_state == null)
366       _state = if (livedir.isdir_!) {
367         if (!livereposdir.isdir_!) Confirmed
368         else if (newdir.isdir_!) Updating
369         else Live
370       } else {
371         if (newdir.isdir_!) Committing
372         else if (pendingdir.isdir_!) Pending
373         else Empty
374       }
375
376     _state
377   }
378
379   def checkState(wanted: State*) {
380     /* Ensure we're in a particular state. */
381     checkLocked();
382     val st = state;
383     if (wanted.forall(_ != st)) {
384       throw new RepositoryStateException(st, s"Repository is $st, not " +
385                                          oxford("or",
386                                                 wanted.map(_.toString)));
387     }
388   }
389
390   def cleanup(ic: Eyecandy) {
391
392     /* If we're part-way through an update then back out or press forward. */
393     state match {
394
395       case Updating =>
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
398          * either way.
399          */
400
401         ic.operation("rolling back failed update")
402           { _ => newdir.rmTree(); }
403         invalidate();            // should move back to `Live' or `Confirmed'
404
405       case Committing =>
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.
408          */
409
410         ic.operation("committing interrupted update")
411           { _ => newdir.rename_!(livedir); }
412         invalidate();                   // should move on to `Live'
413
414       case _ =>
415         /* Other states are stable. */
416         ok;
417     }
418
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
421      * `tmpdir'.
422      */
423     ic.operation("cleaning up configuration area") { or =>
424       val st = state;
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();
433         } }
434         case _ => or.step(s"delete `$f'"); f.rmTree();
435       } }
436     }
437   }
438
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(); } }
443   }
444
445   def clearTmp() {
446     /* Arrange to have an empty `tmpdir'. */
447     tmpdir.rmTree();
448     tmpdir.mkdir_!();
449   }
450
451   def config: Config = {
452     /* Return the repository configuration. */
453
454     checkLocked();
455     if (_config == null) {
456
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
462         case _ => ???
463       }
464
465       /* And then read the configuration. */
466       _config = readConfig(dir/"tripe-keys.conf");
467     }
468
469     _config
470   }
471
472   def fetchConfig(url: URL, ic: Eyecandy) {
473     /* Fetch an initial configuration file from a given URL. */
474
475     checkState(Empty);
476     clearTmp();
477
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'
485     cleanup(ic);
486   }
487
488   def confirm(ic: Eyecandy) {
489     /* The user has approved the master key fingerprint in the `Pending'
490      * configuration.  Advance to `Confirmed'.
491      */
492
493     checkState(Pending);
494     ic.operation("confirming configuration")
495       { _ => pendingdir.rename_!(livedir); }
496     invalidate();                       // should move to `Confirmed'
497   }
498
499   def update(ic: Eyecandy) {
500     /* Update the repository from the master.
501      *
502      * Fetch a (possibly new) archive; unpack it; verify the master key
503      * against the known fingerprint; and check the signature on the bundle.
504      */
505
506     cleanup(ic);
507     checkState(Confirmed, Live);
508     val conf = config;
509     clearTmp();
510
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>",
518                                                                seq)),
519                    4*1024, ic);
520
521     /* Unpack the tarball.  Carefully. */
522     val unpkdir = tmpdir/"unpk";
523     ic.operation("unpacking archive") { or =>
524       unpkdir.mkdir_!();
525       withCleaner { clean =>
526         val tar = new TarFile(new GZIPInputStream(tarfile.open()));
527         clean { tar.close(); }
528         for (e <- tar) {
529
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");
534           }
535
536           /* Report on progress. */
537           or.step(s"entry `${e.name}'");
538
539           /* Find out where this file points. */
540           val f = unpkdir/e.name;
541
542           /* Unpack it. */
543           e.typ match {
544             case DIR =>
545               /* A directory.  Create it if it doesn't exist already. */
546
547               f.mkdirNew_!();
548
549             case REG =>
550               /* A regular file.  Write stuff to it. */
551
552               e.withStream { in =>
553                 f.withOutput { out =>
554                   for ((b, n) <- blocks(in)) out.write(b, 0, n);
555                 }
556               }
557
558             case ty =>
559               /* Something else.  Be paranoid and reject it. */
560
561               throw new KeyConfigException(
562                 s"entry `${e.name}' has unexpected object type $ty");
563           }
564         }
565       }
566     }
567
568     /* There ought to be a file in here called `repos/master.pub'. */
569     val reposdir = unpkdir/"repos";
570     val masterfile = reposdir/"master.pub";
571
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";
577
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");
587       }
588     }
589
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);
595     }
596
597     /* Confirm that the configuration in the new archive is sane. */
598     checkConfigSanity(unpkdir/"tripe-keys.conf", ic);
599
600     /* Build the public keyring. */
601     ic.job(new SimpleModel("counting public keys", -1)) { jr =>
602
603       /* Delete the accumulated keyring. */
604       val pubkeys = unpkdir/"keyring.pub";
605       pubkeys.remove_!();
606
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;
611         case _ => ok;
612       } }
613       kv = kv.sorted;
614       val m = new DetailedModel("collecting public keys", kv.length);
615       var i: Long = 0;
616
617       /* Work through the key files. */
618       for (k <- kv) {
619         m.detail = k.getName;
620         if (!i) jr.change(m, i);
621         else jr.step(i);
622         runCommand("key", "-k", pubkeys.getPath, "merge", k.getPath);
623         i += 1;
624       }
625
626       /* Clean up finally. */
627       (unpkdir/"keyring.pub.old").remove_!();
628     }
629
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);
635     }
636
637     /* All done. */
638     invalidate();                       // should move to `Live'
639     cleanup(ic);
640   }
641
642   def generateKey(tag: String, label: String, ic: Eyecandy) {
643     checkIdent(tag);
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");
648
649     cleanup(ic);
650     checkState(Live);
651     val conf = config;
652     clearTmp();
653
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";
658
659     keysdir.mkdirNew_!();
660
661     ic.operation("fetching key-generation parameters") { _ =>
662       runCommand("key", "-k", kr.getPath, "merge", param.getPath);
663     }
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");
668     }
669     ic.operation("extracting public key") { _ =>
670       runCommand("key", "-k", kr.getPath, "extract",
671                  "-f", "-secret", pub.getPath, tag);
672                                          }
673     ic.operation("writing metadata") { _ =>
674       tmpdir/"meta" withWriter { w =>
675         w.write(s"tag = $tag\n");
676         w.write(s"time = $now\n");
677       }
678     }
679     ic.operation("installing new key") { _ =>
680       tmpdir.rename_!(keysdir/label);
681     }
682   }
683
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) };
687 }
688
689 /*----- That's all, folks -------------------------------------------------*/
690
691 }