chiark / gitweb /
Makefile: Move all artifacts into a subdirectory.
[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
8eabb4ff
MW
40/*----- Useful regular expressions ----------------------------------------*/
41
c8292b34
MW
42private val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
43private val RX_KEYVAL = """(?x) ^ \s*
8eabb4ff
MW
44 ([-\w]+)
45 (?:\s+(?!=)|\s*=\s*)
46 (|\S|\S.*\S)
47 \s* $""".r;
c8292b34 48private val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
8eabb4ff
MW
49
50/*----- Things that go wrong ----------------------------------------------*/
51
52class ConfigSyntaxError(val file: String, val lno: Int, val msg: String)
53 extends Exception {
54 override def getMessage(): String = s"$file:$lno: $msg";
55}
56
57class 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
65class DefaultFailed(val key: String) extends Exception;
66
67/*----- Parsing a configuration -------------------------------------------*/
68
69type Config = scala.collection.Map[String, String];
70
c8292b34 71private val DEFAULTS: Seq[(String, Config => String)] =
8eabb4ff
MW
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");
c8292b34 94 hashsz(h) match {
8eabb4ff
MW
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
c8292b34 109/*----- Managing a key repository -----------------------------------------*/
8eabb4ff 110
c8292b34
MW
111def 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");
8eabb4ff 117 }
c8292b34
MW
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 });
8eabb4ff
MW
129}
130
8eabb4ff
MW
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
164object Repository {
165 object State extends Enumeration {
166 val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
167 }
8eabb4ff
MW
168}
169
c8292b34
MW
170class RepositoryStateException(val state: Repository.State.Value,
171 msg: String)
172 extends Exception(msg);
173
174class KeyConfigException(msg: String) extends Exception(msg);
175
8eabb4ff
MW
176class Repository(val root: File) extends Closeable {
177 import Repository.State.{Value => State, _};
178
c8292b34
MW
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;
8eabb4ff 255 }
c8292b34
MW
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
8eabb4ff
MW
333 }
334
c8292b34
MW
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'
8eabb4ff
MW
343 }
344
c8292b34
MW
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 }
8eabb4ff
MW
410 }
411
c8292b34
MW
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");
8eabb4ff 419
c8292b34
MW
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 }
8eabb4ff
MW
428}
429
430/*----- That's all, folks -------------------------------------------------*/
431
432}