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 | ||
8eabb4ff MW |
40 | /*----- Useful regular expressions ----------------------------------------*/ |
41 | ||
c8292b34 MW |
42 | private val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r; |
43 | private val RX_KEYVAL = """(?x) ^ \s* | |
8eabb4ff MW |
44 | ([-\w]+) |
45 | (?:\s+(?!=)|\s*=\s*) | |
46 | (|\S|\S.*\S) | |
47 | \s* $""".r; | |
c8292b34 | 48 | private val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r; |
8eabb4ff MW |
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 | ||
c8292b34 | 71 | private 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 |
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"); | |
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 | ||
164 | object Repository { | |
165 | object State extends Enumeration { | |
166 | val Empty, Pending, Confirmed, Updating, Committing, Live = Value; | |
167 | } | |
8eabb4ff MW |
168 | } |
169 | ||
c8292b34 MW |
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 | ||
8eabb4ff MW |
176 | class 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 | } |