More work in progress.
[tripe-android] / keys.scala
CommitLineData
8eabb4ff
MW
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
26package uk.org.distorted.tripe; package object keys {
27
28/*----- Imports -----------------------------------------------------------*/
29
8eabb4ff
MW
30import scala.collection.mutable.HashMap;
31
c8292b34
MW
32import java.io.{Closeable, File};
33import java.net.{URL, URLConnection};
34import java.util.zip.GZIPInputStream;
35
36import sys.{SystemError, hashsz, runCommand};
37import sys.Errno.EEXIST;
38import sys.FileImplicits._;
39
04a5abae
MW
40import progress.{Eyecandy, SimpleModel, DataModel};
41
8eabb4ff
MW
42/*----- Useful regular expressions ----------------------------------------*/
43
04a5abae
MW
44private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
45private final val RX_KEYVAL = """(?x) ^ \s*
8eabb4ff
MW
46 ([-\w]+)
47 (?:\s+(?!=)|\s*=\s*)
48 (|\S|\S.*\S)
49 \s* $""".r;
04a5abae 50private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
8eabb4ff
MW
51
52/*----- Things that go wrong ----------------------------------------------*/
53
54class ConfigSyntaxError(val file: String, val lno: Int, val msg: String)
55 extends Exception {
56 override def getMessage(): String = s"$file:$lno: $msg";
57}
58
59class 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
67class DefaultFailed(val key: String) extends Exception;
68
69/*----- Parsing a configuration -------------------------------------------*/
70
71type Config = scala.collection.Map[String, String];
72
c8292b34 73private val DEFAULTS: Seq[(String, Config => String)] =
8eabb4ff
MW
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");
c8292b34 96 hashsz(h) match {
8eabb4ff
MW
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
04a5abae 111private def readConfig(file: File): Config = {
8eabb4ff 112
04a5abae
MW
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;
c8292b34 128 }
04a5abae
MW
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 }
c8292b34 140 }
04a5abae
MW
141 }
142
143 /* And we're done. */
144 m
145}
146
147/*----- Managing a key repository -----------------------------------------*/
148
149def 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 }
8eabb4ff
MW
175}
176
8eabb4ff
MW
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
04a5abae
MW
210class RepositoryStateException(val state: Repository.State.Value,
211 msg: String)
212 extends Exception(msg);
213
214class KeyConfigException(msg: String) extends Exception(msg);
215
216private def launderFingerprint(fp: String): String =
217 fp filter { _.isLetterOrDigit };
218
219private def fingerprintsEqual(a: String, b: String) =
220 launderFingerprint(a) == launderFingerprint(b);
221
222private 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
8eabb4ff
MW
232object Repository {
233 object State extends Enumeration {
234 val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
235 }
8eabb4ff
MW
236}
237
04a5abae
MW
238def checkConfigSanity(file: File, ic: Eyecandy) {
239 ic.operation("checking new configuration") { _ =>
c8292b34 240
04a5abae
MW
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}
c8292b34 252
8eabb4ff
MW
253class Repository(val root: File) extends Closeable {
254 import Repository.State.{Value => State, _};
255
c8292b34 256 /* Important directories and files. */
04a5abae
MW
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";
c8292b34
MW
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; }
04a5abae 268 (root/"lk").lock_!()
c8292b34
MW
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;
8eabb4ff 332 }
c8292b34
MW
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
04a5abae 349 def destroy(ic: Eyecandy) {
c8292b34 350 /* Clear out the entire repository. Everything. It's all gone. */
04a5abae
MW
351 ic.operation("clearing configuration")
352 { _ => root.foreachFile { f => if (f.getName != "lk") f.rmTree(); } }
c8292b34
MW
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 }
c8292b34 374
04a5abae
MW
375 /* And then read the configuration. */
376 _config = readConfig(dir/"tripe-keys.conf");
c8292b34
MW
377 }
378
379 _config
8eabb4ff
MW
380 }
381
04a5abae 382 def fetchConfig(url: URL, ic: Eyecandy) {
c8292b34
MW
383 /* Fetch an initial configuration file from a given URL. */
384
385 checkState(Empty);
386 clearTmp();
04a5abae
MW
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); }
c8292b34 393 invalidate(); // should move to `Pending'
8eabb4ff
MW
394 }
395
04a5abae 396 def confirm(ic: Eyecandy) {
c8292b34
MW
397 /* The user has approved the master key fingerprint in the `Pending'
398 * configuration. Advance to `Confirmed'.
399 */
400
401 checkState(Pending);
04a5abae
MW
402 ic.operation("confirming configuration")
403 { _ => pendingdir.rename_!(livedir); }
c8292b34
MW
404 invalidate(); // should move to `Confirmed'
405 }
406
04a5abae 407 def update(ic: Eyecandy) {
c8292b34
MW
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. */
04a5abae
MW
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";
c8292b34
MW
422 val seq = conf("master-sequence");
423 downloadToFile(sigfile,
424 new URL(conf("sig-url").replaceAllLiterally("<SEQ>",
04a5abae
MW
425 seq)),
426 4*1024, ic);
c8292b34
MW
427
428 /* Unpack the tarball. Carefully. */
04a5abae
MW
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 }
c8292b34 460 }
04a5abae
MW
461 } else {
462 /* Something else. Be paranoid and reject it. */
c8292b34 463
04a5abae
MW
464 throw new KeyConfigException(
465 s"entry `${e.name}' has unexpected object type");
466 }
c8292b34
MW
467 }
468 }
8eabb4ff
MW
469 }
470
c8292b34 471 /* There ought to be a file in here called `repos/master.pub'. */
04a5abae
MW
472 val reposdir = unpkdir/"repos";
473 val masterfile = reposdir/"master.pub";
474
c8292b34
MW
475 if (!reposdir.isdir_!)
476 throw new KeyConfigException("missing `repos/' directory");
c8292b34
MW
477 if (!masterfile.isreg_!)
478 throw new KeyConfigException("missing `repos/master.pub' file");
04a5abae 479 val mastertag = s"master-$seq";
8eabb4ff 480
c8292b34 481 /* Fetch the master key's fingerprint. */
04a5abae
MW
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'
c8292b34 511 }
8eabb4ff
MW
512}
513
514/*----- That's all, folks -------------------------------------------------*/
515
516}