chiark / gitweb /
Wow, is that a proper Android build system?
[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.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};
45
46 /*----- Useful regular expressions ----------------------------------------*/
47
48 private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
49 private final val RX_KEYVAL = """(?x) ^ \s*
50       ([-\w]+)
51       (?:\s+(?!=)|\s*=\s*)
52       (|\S|\S.*\S)
53       \s* $""".r;
54 private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
55
56 private final val RX_PUBKEY = """(?x) ^ peer- (.*) \.pub $""".r;
57
58 private final val RX_KEYINFO = """(?x) ^ ([^:]*) : \s* (\S.*) $""".r
59 private final val RX_KEYATTR = """(?x) ^ \s*
60         ([^\s=] | [^\s=][^=]*[^\s=])
61         \s* = \s*
62         (\S.*) $""".r;
63
64 /*----- Things that go wrong ----------------------------------------------*/
65
66 class ConfigSyntaxError(val file: String, val lno: Int, val msg: String)
67         extends Exception {
68   override def getMessage(): String = s"$file:$lno: $msg";
69 }
70
71 class ConfigDefaultFailed(val file: String, val dfltkey: String,
72                           val badkey: String, val badval: String)
73         extends Exception {
74   override def getMessage(): String =
75     s"$file: can't default `$dfltkey' because " +
76           s"`$badval' is not a recognized value for `$badkey'";
77 }
78
79 class DefaultFailed(val key: String) extends Exception;
80
81 /*----- Parsing a configuration -------------------------------------------*/
82
83 type Config = scala.collection.Map[String, String];
84
85 private val DEFAULTS: Seq[(String, Config => String)] =
86   Seq("repos-base" -> { _ => "tripe-keys.tar.gz" },
87       "sig-base" -> { _ => "tripe-keys.sig-<SEQ>" },
88       "repos-url" -> { conf => conf("base-url") + conf("repos-base") },
89       "sig-url" -> { conf => conf("base-url") + conf("sig-base") },
90       "kx" -> { _ => "dh" },
91       "kx-genalg" -> { conf => conf("kx") match {
92         case alg@("dh" | "ec" | "x25519" | "x448") => alg
93         case _ => throw new DefaultFailed("kx")
94       } },
95       "kx-expire" -> { _ => "now + 1 year" },
96       "kx-warn-days" -> { _ => "28" },
97       "bulk" -> { _ => "iiv" },
98       "cipher" -> { conf => conf("bulk") match {
99         case "naclbox" => "salsa20"
100         case _ => "rijndael-cbc"
101       } },
102       "hash" -> { _ => "sha256" },
103       "mgf" -> { conf => conf("hash") + "-mgf" },
104       "mac" -> { conf => conf("bulk") match {
105         case "naclbox" => "poly1305/128"
106         case _ =>
107           val h = conf("hash");
108           hashsz(h) match {
109             case -1 => throw new DefaultFailed("hash")
110             case hsz => s"${h}-hmac/${4*hsz}"
111           }
112       } },
113       "sig" -> { conf => conf("kx") match {
114         case "dh" => "dsa"
115         case "ec" => "ecdsa"
116         case "x25519" => "ed25519"
117         case "x448" => "ed448"
118         case _ => throw new DefaultFailed("kx")
119       } },
120       "sig-fresh" -> { _ => "always" },
121       "fingerprint-hash" -> { _("hash") });
122
123 private def parseConfig(file: File): HashMap[String, String] = {
124
125   /* Build the new configuration in a temporary place. */
126   var m = HashMap[String, String]();
127
128   /* Read the config file into our map. */
129   file.withReader { in =>
130     var lno = 1;
131     for (line <- lines(in)) {
132       line match {
133         case RX_COMMENT() => ok;
134         case RX_KEYVAL(key, value) => m(key) = value;
135         case _ =>
136           throw new ConfigSyntaxError(file.getPath, lno,
137                                       "failed to parse line");
138       }
139       lno += 1;
140     }
141   }
142
143   /* Done. */
144   m
145 }
146
147 private def readConfig(file: File): Config = {
148   var m = parseConfig(file);
149
150   /* Fill in defaults where things have been missed out. */
151   for ((key, dflt) <- DEFAULTS) {
152     if (!(m contains key)) {
153       try { m(key) = dflt(m); }
154       catch {
155         case e: DefaultFailed =>
156           throw new ConfigDefaultFailed(file.getPath, key,
157                                         e.key, m(e.key));
158       }
159     }
160   }
161
162   /* And we're done. */
163   m
164 }
165
166 /*----- Managing a key repository -----------------------------------------*/
167
168 def downloadToFile(file: File, url: URL,
169                    maxlen: Long = Long.MaxValue,
170                    ic: Eyecandy) {
171   ic.job(new SimpleModel(s"connecting to `$url'", -1)) { jr =>
172     fetchURL(url, new URLFetchCallbacks {
173       val out = file.openForOutput();
174       private def toobig() {
175         throw new KeyConfigException(
176           s"remote file `$url' is suspiciously large");
177       }
178       var totlen: Long = 0;
179       override def preflight(conn: URLConnection) {
180         totlen = conn.getContentLength;
181         if (totlen > maxlen) toobig();
182         jr.change(new SimpleModel(s"downloading `$url'", totlen)
183                     with DataModel,
184                   0);
185       }
186       override def done(win: Boolean) { out.close(); }
187       def write(buf: Array[Byte], n: Int, len: Long) {
188         if (len + n > maxlen) toobig();
189         out.write(buf, 0, n);
190         jr.step(len + n);
191       }
192     })
193   }
194 }
195
196 /* Lifecycle notes
197  *
198  *   -> empty
199  *
200  * insert config file via URL or something
201  *
202  *   -> pending (pending/tripe-keys.conf)
203  *
204  * verify master key fingerprint (against barcode?)
205  *
206  *   -> confirmed (live/tripe-keys.conf; no live/repos)
207  *   -> live (live/...)
208  *
209  * download package
210  * extract contents
211  * verify signature
212  * build keyrings
213  * build peer config
214  * rename tmp -> new
215  *
216  *   -> updating (live/...; new/...)
217  *
218  * rename old repository aside
219  *
220  *   -> committing (old/...; new/...)
221  *
222  * rename verified repository
223  *
224  *   -> live (live/...)
225  *
226  * (delete old/)
227  */
228
229 class RepositoryStateException(val state: Repository.State.Value,
230                                msg: String)
231         extends Exception(msg);
232
233 class KeyConfigException(msg: String) extends Exception(msg);
234
235 private def launderFingerprint(fp: String): String =
236   fp filter { _.isLetterOrDigit };
237
238 private def fingerprintsEqual(a: String, b: String) =
239   launderFingerprint(a) == launderFingerprint(b);
240
241 private def keyFingerprint(kr: File, tag: String, hash: String): String = {
242   val (out, _) = runCommand("key", "-k", kr.getPath, "fingerprint",
243                             "-a", hash, "-f", "-secret", tag);
244   nextToken(out) match {
245     case Some((fp, _)) => fp
246     case _ =>
247       throw new IOException("unexpected output from `key fingerprint'");
248   }
249 }
250
251 private def checkIdent(id: String) {
252   if (id exists { ch => ch == ':' || ch == '.' || ch.isWhitespace })
253     throw new IllegalArgumentException(s"bad key tag `$id'");
254 }
255
256 object Repository {
257   object State extends Enumeration {
258     val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
259   }
260 }
261
262 def checkConfigSanity(file: File, ic: Eyecandy) {
263   ic.operation("checking new configuration") { _ =>
264
265     /* Make sure we can read and understand the file. */
266     val conf = readConfig(file);
267
268     /* Make sure there are entries which we can use to update.  This won't
269      * guarantee that we can reliably update, but it will help.
270      */
271     conf("repos-url"); conf("sig-url");
272     conf("fingerprint-hash"); conf("sig-fresh");
273     conf("master-sequence"); conf("hk-master");
274   }
275 }
276
277 private val keydatefmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
278 class PrivateKey private[keys](repo: Repository, dir: File) {
279   private[this] lazy val keyring = dir/"keyring";
280   private[this] lazy val meta = parseConfig(dir/"meta");
281   lazy val tag = meta("tag");
282   lazy val time = datefmt synchronized { datefmt.parse(meta("time")); };
283   lazy val fingerprint = keyFingerprint(keyring, tag,
284                                         repo.config("fingerprint-hash"));
285
286   def remove() { dir.rmTree(); }
287
288   private[this] lazy val (info, _attr) = {
289     val m = Map.newBuilder[String, String];
290     val a = Map.newBuilder[String, String];
291     val (out, _) = runCommand("key", "-k", keyring.getPath,
292                               "list", "-vv", tag);
293     val lines = out.lines;
294     while (lines.hasNext) lines.next match {
295       case "attributes:" =>
296         while (lines.hasNext) lines.next match {
297           case RX_KEYATTR(k, v) => a += k -> v;
298           case line => throw new IOException(
299             s"unexpected output from `key list': $line");
300         }
301       case RX_KEYINFO(k, v) =>
302         m += k -> v;
303       case line => throw new IOException(
304         s"unexpected output from `key list': $line");
305     }
306     (m.result, a.result)
307   }
308
309   lazy val expires = info("expiry") match {
310     case "forever" => None
311     case d => Some(keydatefmt synchronized { keydatefmt.parse(d) })
312   }
313   lazy val ty = info("type");
314   lazy val comment = info("comment");
315   lazy val keyid = {
316
317     /* Ugh.  Using `Int' throws an exception on words whose top bit is set
318      * because Java doesn't have proper unsigned integers.  There's
319      * `parseUnsignedInt' in Java 1.8, but that limits our Android targets.
320      * And Scala has put its own `Long' object in the way of Java's so we
321      * need this circumolution.
322      */
323     (JLong.parseLong(info("keyid"), 16)&0xffffffff).toInt;
324   }
325   lazy val attr = _attr;
326 }
327
328 class Repository(val root: File) extends Closeable {
329   import Repository.State.{Value => State, _};
330
331   /* Important directories and files. */
332   private[this] val configdir = root/"config";
333   private[this] val livedir = configdir/"live";
334   private[this] val livereposdir = livedir/"repos";
335   private[this] val newdir = configdir/"new";
336   private[this] val olddir = configdir/"old";
337   private[this] val pendingdir = configdir/"pending";
338   private[this] val tmpdir = root/"tmp";
339   private[this] val keysdir = root/"keys";
340
341   /* Take out a lock in case of other instances. */
342   private[this] var open = false;
343   private[this] val lock = {
344      root.mkdirNew_!();
345     open = true;
346     (root/"lk").lock_!()
347   }
348   def close() { lock.close(); open = false; }
349   private[this] def checkLocked()
350     { if (!open) throw new IllegalStateException("repository is unlocked"); }
351
352   /* Maintain a cache of some repository state. */
353   private var _state: State = null;
354   private var _config: Config = null;
355   private def invalidate() {
356     _state = null;
357     _config = null;
358   }
359
360   def state: State = {
361     /* Determine the current repository state. */
362
363     if (_state == null)
364       _state = if (livedir.isdir_!) {
365         if (!livereposdir.isdir_!) Confirmed
366         else if (newdir.isdir_!) Updating
367         else Live
368       } else {
369         if (newdir.isdir_!) Committing
370         else if (pendingdir.isdir_!) Pending
371         else Empty
372       }
373
374     _state
375   }
376
377   def checkState(wanted: State*) {
378     /* Ensure we're in a particular state. */
379     checkLocked();
380     val st = state;
381     if (wanted.forall(_ != st)) {
382       throw new RepositoryStateException(st, s"Repository is $st, not " +
383                                          oxford("or",
384                                                 wanted.map(_.toString)));
385     }
386   }
387
388   def cleanup(ic: Eyecandy) {
389
390     /* If we're part-way through an update then back out or press forward. */
391     state match {
392
393       case Updating =>
394         /* We have a new tree allegedly ready, but the current one is still
395          * in place.  It seems safer to zap the new one here, but we could go
396          * either way.
397          */
398
399         ic.operation("rolling back failed update")
400           { _ => newdir.rmTree(); }
401         invalidate();            // should move back to `Live' or `Confirmed'
402
403       case Committing =>
404         /* We have a new tree ready, and an old one moved aside.  We're going
405          * to have to move one of them.  Let's try committing the new tree.
406          */
407
408         ic.operation("committing interrupted update")
409           { _ => newdir.rename_!(livedir); }
410         invalidate();                   // should move on to `Live'
411
412       case _ =>
413         /* Other states are stable. */
414         ok;
415     }
416
417     /* Now work through the things in our area of the filesystem and zap the
418      * ones which don't belong.  In particular, this will always erase
419      * `tmpdir'.
420      */
421     ic.operation("cleaning up configuration area") { or =>
422       val st = state;
423       root foreachFile { f => f.getName match {
424         case "lk" | "keys" => ok;
425         case "config" => configdir foreachFile { f => (f.getName, st) match {
426           case ("live", Live | Confirmed) => ok;
427           case ("pending", Pending) => ok;
428           case (_, Updating | Committing) =>
429             unreachable(s"unexpectedly still in `$st' state");
430           case _ => or.step(s"delete `$f'"); f.rmTree();
431         } }
432         case _ => or.step(s"delete `$f'"); f.rmTree();
433       } }
434     }
435   }
436
437   def destroy(ic: Eyecandy) {
438     /* Clear out the entire repository.  Everything.  It's all gone. */
439     ic.operation("clearing configuration")
440       { _ => root foreachFile { f => if (f.getName != "lk") f.rmTree(); } }
441   }
442
443   def clearTmp() {
444     /* Arrange to have an empty `tmpdir'. */
445     tmpdir.rmTree();
446     tmpdir.mkdir_!();
447   }
448
449   def config: Config = {
450     /* Return the repository configuration. */
451
452     checkLocked();
453     if (_config == null) {
454
455       /* Firstly, decide where to find the configuration file. */
456       checkState(Pending, Confirmed, Live);
457       val dir = state match {
458         case Live | Confirmed => livedir
459         case Pending => pendingdir
460         case _ => ???
461       }
462
463       /* And then read the configuration. */
464       _config = readConfig(dir/"tripe-keys.conf");
465     }
466
467     _config
468   }
469
470   def fetchConfig(url: URL, ic: Eyecandy) {
471     /* Fetch an initial configuration file from a given URL. */
472
473     checkState(Empty);
474     clearTmp();
475
476     val conffile = tmpdir/"tripe-keys.conf";
477     downloadToFile(conffile, url, 16*1024, ic);
478     checkConfigSanity(conffile, ic);
479     configdir.mkdirNew_!();
480     ic.operation("committing configuration")
481       { _ => tmpdir.rename_!(pendingdir); }
482     invalidate();                       // should move to `Pending'
483     cleanup(ic);
484   }
485
486   def confirm(ic: Eyecandy) {
487     /* The user has approved the master key fingerprint in the `Pending'
488      * configuration.  Advance to `Confirmed'.
489      */
490
491     checkState(Pending);
492     ic.operation("confirming configuration")
493       { _ => pendingdir.rename_!(livedir); }
494     invalidate();                       // should move to `Confirmed'
495   }
496
497   def update(ic: Eyecandy) {
498     /* Update the repository from the master.
499      *
500      * Fetch a (possibly new) archive; unpack it; verify the master key
501      * against the known fingerprint; and check the signature on the bundle.
502      */
503
504     cleanup(ic);
505     checkState(Confirmed, Live);
506     val conf = config;
507     clearTmp();
508
509     /* First thing is to download the tarball and signature. */
510     val tarfile = tmpdir/"tripe-keys.tar.gz";
511     downloadToFile(tarfile, new URL(conf("repos-url")), 256*1024, ic);
512     val sigfile = tmpdir/"tripe-keys.sig";
513     val seq = conf("master-sequence");
514     downloadToFile(sigfile,
515                    new URL(conf("sig-url").replaceAllLiterally("<SEQ>",
516                                                                seq)),
517                    4*1024, ic);
518
519     /* Unpack the tarball.  Carefully. */
520     val unpkdir = tmpdir/"unpk";
521     ic.operation("unpacking archive") { or =>
522       unpkdir.mkdir_!();
523       withCleaner { clean =>
524         val tar = new TarFile(new GZIPInputStream(tarfile.open()));
525         clean { tar.close(); }
526         for (e <- tar) {
527
528           /* Check the filename to make sure it's not evil. */
529           if (e.name(0) == '/' || e.name.split('/').exists { _ == ".." }) {
530             throw new KeyConfigException(
531               s"invalid path `${e.name}' in tarball");
532           }
533
534           /* Report on progress. */
535           or.step(s"entry `${e.name}'");
536
537           /* Find out where this file points. */
538           val f = unpkdir/e.name;
539
540           /* Unpack it. */
541           e.typ match {
542             case DIR =>
543               /* A directory.  Create it if it doesn't exist already. */
544
545               f.mkdirNew_!();
546
547             case REG =>
548               /* A regular file.  Write stuff to it. */
549
550               e.withStream { in =>
551                 f.withOutput { out =>
552                   for ((b, n) <- blocks(in)) out.write(b, 0, n);
553                 }
554               }
555
556             case ty =>
557               /* Something else.  Be paranoid and reject it. */
558
559               throw new KeyConfigException(
560                 s"entry `${e.name}' has unexpected object type $ty");
561           }
562         }
563       }
564     }
565
566     /* There ought to be a file in here called `repos/master.pub'. */
567     val reposdir = unpkdir/"repos";
568     val masterfile = reposdir/"master.pub";
569
570     if (!reposdir.isdir_!)
571       throw new KeyConfigException("missing `repos/' directory");
572     if (!masterfile.isreg_!)
573       throw new KeyConfigException("missing `repos/master.pub' file");
574     val mastertag = s"master-$seq";
575
576     /* Fetch the master key's fingerprint. */
577     ic.operation("checking master key fingerprint") { _ =>
578       val foundfp = keyFingerprint(masterfile, mastertag,
579                                    conf("fingerprint-hash"));
580       val wantfp = conf("hk-master");
581       if (!fingerprintsEqual(wantfp, foundfp)) {
582         throw new KeyConfigException(
583           s"master key #$seq has wrong fingerprint: " +
584           s"expected $wantfp but found $foundfp");
585       }
586     }
587
588     /* Check the archive signature. */
589     ic.operation("verifying archive signature") { or =>
590       runCommand("catsign", "-k", masterfile.getPath, "verify", "-aqC",
591                  "-k", mastertag, "-t", conf("sig-fresh"),
592                  sigfile.getPath, tarfile.getPath);
593     }
594
595     /* Confirm that the configuration in the new archive is sane. */
596     checkConfigSanity(unpkdir/"tripe-keys.conf", ic);
597
598     /* Build the public keyring.  (Observe the quadratic performance.) */
599     ic.operation("collecting public keys") { or =>
600       val pubkeys = unpkdir/"keyring.pub";
601       pubkeys.remove_!();
602       reposdir foreachFile { file => file.getName match {
603         case RX_PUBKEY(peer) if file.isreg_! =>
604           or.step(peer);
605           runCommand("key", "-k", pubkeys.getPath, "merge", file.getPath);
606         case _ => ok;
607       } }
608       (unpkdir/"keyring.pub.old").remove_!();
609     }
610
611     /* Now we just have to juggle the files about. */
612     ic.operation("committing new configuration") { _ =>
613       unpkdir.rename_!(newdir);
614       livedir.rename_!(olddir);
615       newdir.rename_!(livedir);
616     }
617
618     /* All done. */
619     invalidate();                       // should move to `Live'
620     cleanup(ic);
621   }
622
623   def generateKey(tag: String, label: String, ic: Eyecandy) {
624     checkIdent(tag);
625     if (label.exists { _ == '/' })
626       throw new IllegalArgumentException(s"invalid label string `$label'");
627     if ((keysdir/label).isdir_!)
628       throw new IllegalArgumentException(s"key `$label' already exists");
629
630     cleanup(ic);
631     checkState(Live);
632     val conf = config;
633     clearTmp();
634
635     val now = datefmt synchronized { datefmt.format(new Date) };
636     val kr = tmpdir/"keyring";
637     val pub = tmpdir/s"peer-$tag.pub";
638     val param = livereposdir/"param";
639
640     keysdir.mkdirNew_!();
641
642     ic.operation("fetching key-generation parameters") { _ =>
643       runCommand("key", "-k", kr.getPath, "merge", param.getPath);
644     }
645     ic.operation("generating new key") { _ =>
646       runCommand("key", "-k", kr.getPath, "add",
647                  "-a", conf("kx-genalg"), "-p", "param",
648                  "-e", conf("kx-expire"), "-t", tag, "tripe");
649     }
650     ic.operation("extracting public key") { _ =>
651       runCommand("key", "-k", kr.getPath, "extract",
652                  "-f", "-secret", pub.getPath, tag);
653                                          }
654     ic.operation("writing metadata") { _ =>
655       tmpdir/"meta" withWriter { w =>
656         w.write(s"tag = $tag\n");
657         w.write(s"time = $now\n");
658       }
659     }
660     ic.operation("installing new key") { _ =>
661       tmpdir.rename_!(keysdir/label);
662     }
663   }
664
665   def key(label: String): PrivateKey = new PrivateKey(this, keysdir/label);
666   def keyLabels: Seq[String] = (keysdir.files_! map { _.getName }).toStream;
667   def keys: Seq[PrivateKey] = keyLabels map { k => key(k) };
668 }
669
670 /*----- That's all, folks -------------------------------------------------*/
671
672 }