Commit | Line | Data |
---|---|---|
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 | ||
26 | package uk.org.distorted.tripe; package object keys { | |
27 | ||
28 | /*----- Imports -----------------------------------------------------------*/ | |
29 | ||
8eabb4ff MW |
30 | import scala.collection.mutable.HashMap; |
31 | ||
c8292b34 MW |
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 | ||
04a5abae MW |
40 | import progress.{Eyecandy, SimpleModel, DataModel}; |
41 | ||
8eabb4ff MW |
42 | /*----- Useful regular expressions ----------------------------------------*/ |
43 | ||
04a5abae MW |
44 | private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r; |
45 | private final val RX_KEYVAL = """(?x) ^ \s* | |
8eabb4ff MW |
46 | ([-\w]+) |
47 | (?:\s+(?!=)|\s*=\s*) | |
48 | (|\S|\S.*\S) | |
49 | \s* $""".r; | |
04a5abae | 50 | private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r; |
8eabb4ff MW |
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 | ||
c8292b34 | 73 | private 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 | 111 | private 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 | ||
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 | } | |
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 |
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 | ||
8eabb4ff MW |
232 | object Repository { |
233 | object State extends Enumeration { | |
234 | val Empty, Pending, Confirmed, Updating, Committing, Live = Value; | |
235 | } | |
8eabb4ff MW |
236 | } |
237 | ||
04a5abae MW |
238 | def 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 |
253 | class 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 | } |