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