chiark / gitweb /
More work in progress.
[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};
33 import java.net.{URL, URLConnection};
34 import java.util.zip.GZIPInputStream;
35
36 import sys.{SystemError, hashsz, runCommand};
37 import sys.Errno.EEXIST;
38 import sys.FileImplicits._;
39
40 import progress.{Eyecandy, SimpleModel, DataModel};
41
42 /*----- Useful regular expressions ----------------------------------------*/
43
44 private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
45 private final val RX_KEYVAL = """(?x) ^ \s*
46       ([-\w]+)
47       (?:\s+(?!=)|\s*=\s*)
48       (|\S|\S.*\S)
49       \s* $""".r;
50 private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
51
52 /*----- Things that go wrong ----------------------------------------------*/
53
54 class ConfigSyntaxError(val file: String, val lno: Int, val msg: String)
55         extends Exception {
56   override def getMessage(): String = s"$file:$lno: $msg";
57 }
58
59 class ConfigDefaultFailed(val file: String, val dfltkey: String,
60                           val badkey: String, val badval: String)
61         extends Exception {
62   override def getMessage(): String =
63     s"$file: can't default `$dfltkey' because " +
64           s"`$badval' is not a recognized value for `$badkey'";
65 }
66
67 class DefaultFailed(val key: String) extends Exception;
68
69 /*----- Parsing a configuration -------------------------------------------*/
70
71 type Config = scala.collection.Map[String, String];
72
73 private val DEFAULTS: Seq[(String, Config => String)] =
74   Seq("repos-base" -> { _ => "tripe-keys.tar.gz" },
75       "sig-base" -> { _ => "tripe-keys.sig-<SEQ>" },
76       "repos-url" -> { conf => conf("base-url") + conf("repos-base") },
77       "sig-url" -> { conf => conf("base-url") + conf("sig-base") },
78       "kx" -> { _ => "dh" },
79       "kx-genalg" -> { conf => conf("kx") match {
80         case alg@("dh" | "ec" | "x25519" | "x448") => alg
81         case _ => throw new DefaultFailed("kx")
82       } },
83       "kx-expire" -> { _ => "now + 1 year" },
84       "kx-warn-days" -> { _ => "28" },
85       "bulk" -> { _ => "iiv" },
86       "cipher" -> { conf => conf("bulk") match {
87         case "naclbox" => "salsa20"
88         case _ => "rijndael-cbc"
89       } },
90       "hash" -> { _ => "sha256" },
91       "mgf" -> { conf => conf("hash") + "-mgf" },
92       "mac" -> { conf => conf("bulk") match {
93         case "naclbox" => "poly1305/128"
94         case _ =>
95           val h = conf("hash");
96           hashsz(h) match {
97             case -1 => throw new DefaultFailed("hash")
98             case hsz => s"${h}-hmac/${4*hsz}"
99           }
100       } },
101       "sig" -> { conf => conf("kx") match {
102         case "dh" => "dsa"
103         case "ec" => "ecdsa"
104         case "x25519" => "ed25519"
105         case "x448" => "ed448"
106         case _ => throw new DefaultFailed("kx")
107       } },
108       "sig-fresh" -> { _ => "always" },
109       "fingerprint-hash" -> { _("hash") });
110
111 private def readConfig(file: File): Config = {
112
113   /* Build the new configuration in a temporary place. */
114   var m = HashMap[String, String]();
115
116   /* Read the config file into our map. */
117   file.withReader { in =>
118     var lno = 1;
119     for (line <- lines(in)) {
120       line match {
121         case RX_COMMENT() => ok;
122         case RX_KEYVAL(key, value) => m += key -> value;
123         case _ =>
124           throw new ConfigSyntaxError(file.getPath, lno,
125                                       "failed to parse line");
126       }
127       lno += 1;
128     }
129   }
130
131   /* Fill in defaults where things have been missed out. */
132   for ((key, dflt) <- DEFAULTS) {
133     if (!(m contains key)) {
134       try { m += key -> dflt(m); }
135       catch {
136         case e: DefaultFailed =>
137           throw new ConfigDefaultFailed(file.getPath, key,
138                                         e.key, m(e.key));
139       }
140     }
141   }
142
143   /* And we're done. */
144   m
145 }
146
147 /*----- Managing a key repository -----------------------------------------*/
148
149 def downloadToFile(file: File, url: URL,
150                    maxlen: Long = Long.MaxValue,
151                    ic: Eyecandy) {
152   ic.job(new SimpleModel(s"connecting to `$url'", -1)) { jr =>
153     fetchURL(url, new URLFetchCallbacks {
154       val out = file.openForOutput();
155       private def toobig() {
156         throw new KeyConfigException(
157           s"remote file `$url' is suspiciously large");
158       }
159       var totlen: Long = 0;
160       override def preflight(conn: URLConnection) {
161         totlen = conn.getContentLength;
162         if (totlen > maxlen) toobig();
163         jr.change(new SimpleModel(s"downloading `$url'", totlen)
164                     with DataModel,
165                   0);
166       }
167       override def done(win: Boolean) { out.close(); }
168       def write(buf: Array[Byte], n: Int, len: Long) {
169         if (len + n > maxlen) toobig();
170         out.write(buf, 0, n);
171         jr.step(len + n);
172       }
173     })
174   }
175 }
176
177 /* Lifecycle notes
178  *
179  *   -> empty
180  *
181  * insert config file via URL or something
182  *
183  *   -> pending (pending/tripe-keys.conf)
184  *
185  * verify master key fingerprint (against barcode?)
186  *
187  *   -> confirmed (live/tripe-keys.conf; no live/repos)
188  *   -> live (live/...)
189  *
190  * download package
191  * extract contents
192  * verify signature
193  * build keyrings
194  * build peer config
195  * rename tmp -> new
196  *
197  *   -> updating (live/...; new/...)
198  *
199  * rename old repository aside
200  *
201  *   -> committing (old/...; new/...)
202  *
203  * rename verified repository
204  *
205  *   -> live (live/...)
206  *
207  * (delete old/)
208  */
209
210 class RepositoryStateException(val state: Repository.State.Value,
211                                msg: String)
212         extends Exception(msg);
213
214 class KeyConfigException(msg: String) extends Exception(msg);
215
216 private def launderFingerprint(fp: String): String =
217   fp filter { _.isLetterOrDigit };
218
219 private def fingerprintsEqual(a: String, b: String) =
220   launderFingerprint(a) == launderFingerprint(b);
221
222 private def keyFingerprint(kr: File, tag: String, hash: String): String = {
223   val (out, _) = runCommand("key", "-k", kr.getPath, "fingerprint",
224                             "-a", hash, "-f", "-secret", tag);
225   nextToken(out) match {
226     case Some((fp, _)) => fp
227     case _ =>
228       throw new java.io.IOException("unexpected output from `key fingerprint");
229   }
230 }
231
232 object Repository {
233   object State extends Enumeration {
234     val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
235   }
236 }
237
238 def checkConfigSanity(file: File, ic: Eyecandy) {
239   ic.operation("checking new configuration") { _ =>
240
241     /* Make sure we can read and understand the file. */
242     val conf = readConfig(file);
243
244     /* Make sure there are entries which we can use to update.  This won't
245      * guarantee that we can reliably update, but it will help.
246      */
247     conf("repos-url"); conf("sig-url");
248     conf("fingerprint-hash"); conf("sig-fresh");
249     conf("master-sequence"); conf("hk-master");
250   }
251 }
252
253 class Repository(val root: File) extends Closeable {
254   import Repository.State.{Value => State, _};
255
256   /* Important directories and files. */
257   private[this] val livedir = root/"live";
258   private[this] val livereposdir = livedir/"repos";
259   private[this] val newdir = root/"new";
260   private[this] val olddir = root/"old";
261   private[this] val pendingdir = root/"pending";
262   private[this] val tmpdir = root/"tmp";
263
264   /* Take out a lock in case of other instances. */
265   private[this] val lock = {
266     try { root.mkdir_!(); }
267     catch { case SystemError(EEXIST, _) => ok; }
268     (root/"lk").lock_!()
269   }
270   def close() { lock.close(); }
271
272   /* Maintain a cache of some repository state. */
273   private var _state: State = null;
274   private var _config: Config = null;
275   private def invalidate() {
276     _state = null;
277     _config = null;
278   }
279
280   def state: State = {
281     /* Determine the current repository state. */
282
283     if (_state == null)
284       _state = if (livedir.isdir_!) {
285         if (!livereposdir.isdir_!) Confirmed
286         else if (newdir.isdir_!) Updating
287         else Live
288       } else {
289         if (newdir.isdir_!) Committing
290         else if (pendingdir.isdir_!) Pending
291         else Empty
292       }
293
294     _state
295   }
296
297   def checkState(wanted: State*) {
298     /* Ensure we're in a particular state. */
299     val st = state;
300     if (wanted.forall(_ != st)) {
301       throw new RepositoryStateException(st, s"Repository is $st, not " +
302                                          oxford("or",
303                                                 wanted.map(_.toString)));
304     }
305   }
306
307   def cleanup() {
308
309     /* If we're part-way through an update then back out or press forward. */
310     state match {
311
312       case Updating =>
313         /* We have a new tree allegedly ready, but the current one is still
314          * in place.  It seems safer to zap the new one here, but we could go
315          * either way.
316          */
317
318         newdir.rmTree();
319         invalidate();            // should move back to `Live' or `Confirmed'
320
321       case Committing =>
322         /* We have a new tree ready, and an old one moved aside.  We're going
323          * to have to move one of them.  Let's try committing the new tree.
324          */
325
326         newdir.rename_!(livedir);       // should move on to `Live'
327         invalidate();
328
329       case _ =>
330         /* Other states are stable. */
331         ok;
332     }
333
334     /* Now work through the things in our area of the filesystem and zap the
335      * ones which don't belong.  In particular, this will always erase
336      * `tmpdir'.
337      */
338     val st = state;
339     root.foreachFile { f => (f.getName, st) match {
340       case ("lk", _) => ok;
341       case ("live", Live | Confirmed) => ok;
342       case ("pending", Pending) => ok;
343       case (_, Updating | Committing) =>
344         unreachable(s"unexpectedly still in `$st' state");
345       case _ => f.rmTree();
346     }
347   } }
348
349   def destroy(ic: Eyecandy) {
350     /* Clear out the entire repository.  Everything.  It's all gone. */
351     ic.operation("clearing configuration")
352       { _ => root.foreachFile { f => if (f.getName != "lk") f.rmTree(); } }
353   }
354
355   def clearTmp() {
356     /* Arrange to have an empty `tmpdir'. */
357     tmpdir.rmTree();
358     tmpdir.mkdir_!();
359   }
360
361   def config: Config = {
362     /* Return the repository configuration. */
363
364     if (_config == null) {
365
366       /* Firstly, decide where to find the configuration file. */
367       cleanup();
368       val dir = state match {
369         case Live | Confirmed => livedir
370         case Pending => pendingdir
371         case Empty =>
372           throw new RepositoryStateException(Empty, "repository is Empty");
373       }
374
375       /* And then read the configuration. */
376       _config = readConfig(dir/"tripe-keys.conf");
377     }
378
379     _config
380   }
381
382   def fetchConfig(url: URL, ic: Eyecandy) {
383     /* Fetch an initial configuration file from a given URL. */
384
385     checkState(Empty);
386     clearTmp();
387
388     val conffile = tmpdir/"tripe-keys.conf";
389     downloadToFile(conffile, url, 16*1024, ic);
390     checkConfigSanity(conffile, ic);
391     ic.operation("committing configuration")
392       { _ => tmpdir.rename_!(pendingdir); }
393     invalidate();                       // should move to `Pending'
394   }
395
396   def confirm(ic: Eyecandy) {
397     /* The user has approved the master key fingerprint in the `Pending'
398      * configuration.  Advance to `Confirmed'.
399      */
400
401     checkState(Pending);
402     ic.operation("confirming configuration")
403       { _ => pendingdir.rename_!(livedir); }
404     invalidate();                       // should move to `Confirmed'
405   }
406
407   def update(ic: Eyecandy) {
408     /* Update the repository from the master.
409      *
410      * Fetch a (possibly new) archive; unpack it; verify the master key
411      * against the known fingerprint; and check the signature on the bundle.
412      */
413
414     checkState(Confirmed, Live);
415     val conf = config;
416     clearTmp();
417
418     /* First thing is to download the tarball and signature. */
419     val tarfile = tmpdir/"tripe-keys.tar.gz";
420     downloadToFile(tarfile, new URL(conf("repos-url")), 256*1024, ic);
421     val sigfile = tmpdir/"tripe-keys.sig";
422     val seq = conf("master-sequence");
423     downloadToFile(sigfile,
424                    new URL(conf("sig-url").replaceAllLiterally("<SEQ>",
425                                                                seq)),
426                    4*1024, ic);
427
428     /* Unpack the tarball.  Carefully. */
429     val unpkdir = tmpdir/"unpk";
430     ic.operation("unpacking archive") { or =>
431       unpkdir.mkdir_!();
432       withCleaner { clean =>
433         val tar = new TarFile(new GZIPInputStream(tarfile.open()));
434         clean { tar.close(); }
435         for (e <- tar) {
436
437           /* Check the filename to make sure it's not evil. */
438           if (e.name(0) == '/' || e.name.split('/').exists { _ == ".." })
439             throw new KeyConfigException("invalid path in tarball");
440
441           /* Report on progress. */
442           or.step(s"entry `${e.name}'");
443
444           /* Find out where this file points. */
445           val f = unpkdir/e.name;
446
447           /* Unpack it. */
448           if (e.isdir) {
449             /* A directory.  Create it if it doesn't exist already. */
450
451             try { f.mkdir_!(); }
452             catch { case SystemError(EEXIST, _) => ok; }
453           } else if (e.isreg) {
454             /* A regular file.  Write stuff to it. */
455
456             e.withStream { in =>
457               f.withOutput { out =>
458                 for ((b, n) <- blocks(in)) out.write(b, 0, n);
459               }
460             }
461           } else {
462             /* Something else.  Be paranoid and reject it. */
463
464             throw new KeyConfigException(
465               s"entry `${e.name}' has unexpected object type");
466           }
467         }
468       }
469     }
470
471     /* There ought to be a file in here called `repos/master.pub'. */
472     val reposdir = unpkdir/"repos";
473     val masterfile = reposdir/"master.pub";
474
475     if (!reposdir.isdir_!)
476       throw new KeyConfigException("missing `repos/' directory");
477     if (!masterfile.isreg_!)
478       throw new KeyConfigException("missing `repos/master.pub' file");
479     val mastertag = s"master-$seq";
480
481     /* Fetch the master key's fingerprint. */
482     ic.operation("checking master key fingerprint") { _ =>
483       val foundfp = keyFingerprint(masterfile, mastertag,
484                                    conf("fingerprint-hash"));
485       val wantfp = conf("hk-master");
486       if (!fingerprintsEqual(wantfp, foundfp)) {
487         throw new KeyConfigException(
488           s"master key #$seq has wrong fingerprint: " +
489           s"expected $wantfp but found $foundfp");
490       }
491     }
492
493     /* Check the archive signature. */
494     ic.operation("verifying archive signature") { or =>
495       runCommand("catsign", "-k", masterfile.getPath, "verify", "-aqC",
496                  "-k", mastertag, "-t", conf("sig-fresh"),
497                  sigfile.getPath, tarfile.getPath);
498     }
499
500     /* Confirm that the configuration in the new archive is sane. */
501     checkConfigSanity(unpkdir/"tripe-keys.conf", ic);
502
503     /* Now we just have to juggle the files about. */
504     ic.operation("committing new configuration") { _ =>
505       unpkdir.rename_!(newdir);
506       livedir.rename_!(olddir);
507       newdir.rename_!(livedir);
508     }
509
510     invalidate();                       // should move to `Live'
511   }
512 }
513
514 /*----- That's all, folks -------------------------------------------------*/
515
516 }