| 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 | } |