X-Git-Url: https://git.distorted.org.uk/~mdw/tripe-android/blobdiff_plain/7894831e9078211df0b460c4d3dd1bc51ca46804..8eabb4ff13562f3550499ee599297f7e97fa8754:/keys.scala?ds=sidebyside diff --git a/keys.scala b/keys.scala new file mode 100644 index 0000000..f075159 --- /dev/null +++ b/keys.scala @@ -0,0 +1,223 @@ +/* -*-scala-*- + * + * Key distribution + * + * (c) 2018 Straylight/Edgeware + */ + +/*----- Licensing notice --------------------------------------------------* + * + * This file is part of the Trivial IP Encryption (TrIPE) Android app. + * + * TrIPE is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 3 of the License, or (at your + * option) any later version. + * + * TrIPE is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License + * along with TrIPE. If not, see . + */ + +package uk.org.distorted.tripe; package object keys { + +/*----- Imports -----------------------------------------------------------*/ + +import java.io.{Closeable, File, FileOutputStream, FileReader, IOException}; + +import scala.collection.mutable.HashMap; + +/*----- Useful regular expressions ----------------------------------------*/ + +val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r; +val RX_KEYVAL = """(?x) ^ \s* + ([-\w]+) + (?:\s+(?!=)|\s*=\s*) + (|\S|\S.*\S) + \s* $""".r; +val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r; + +/*----- Things that go wrong ----------------------------------------------*/ + +class ConfigSyntaxError(val file: String, val lno: Int, val msg: String) + extends Exception { + override def getMessage(): String = s"$file:$lno: $msg"; +} + +class ConfigDefaultFailed(val file: String, val dfltkey: String, + val badkey: String, val badval: String) + extends Exception { + override def getMessage(): String = + s"$file: can't default `$dfltkey' because " + + s"`$badval' is not a recognized value for `$badkey'"; +} + +class DefaultFailed(val key: String) extends Exception; + +/*----- Parsing a configuration -------------------------------------------*/ + +type Config = scala.collection.Map[String, String]; + +val DEFAULTS: Seq[(String, Config => String)] = + Seq("repos-base" -> { _ => "tripe-keys.tar.gz" }, + "sig-base" -> { _ => "tripe-keys.sig-" }, + "repos-url" -> { conf => conf("base-url") + conf("repos-base") }, + "sig-url" -> { conf => conf("base-url") + conf("sig-base") }, + "kx" -> { _ => "dh" }, + "kx-genalg" -> { conf => conf("kx") match { + case alg@("dh" | "ec" | "x25519" | "x448") => alg + case _ => throw new DefaultFailed("kx") + } }, + "kx-expire" -> { _ => "now + 1 year" }, + "kx-warn-days" -> { _ => "28" }, + "bulk" -> { _ => "iiv" }, + "cipher" -> { conf => conf("bulk") match { + case "naclbox" => "salsa20" + case _ => "rijndael-cbc" + } }, + "hash" -> { _ => "sha256" }, + "mgf" -> { conf => conf("hash") + "-mgf" }, + "mac" -> { conf => conf("bulk") match { + case "naclbox" => "poly1305/128" + case _ => + val h = conf("hash"); + JNI.hashsz(h) match { + case -1 => throw new DefaultFailed("hash") + case hsz => s"${h}-hmac/${4*hsz}" + } + } }, + "sig" -> { conf => conf("kx") match { + case "dh" => "dsa" + case "ec" => "ecdsa" + case "x25519" => "ed25519" + case "x448" => "ed448" + case _ => throw new DefaultFailed("kx") + } }, + "sig-fresh" -> { _ => "always" }, + "fingerprint-hash" -> { _("hash") }); + +def readConfig(path: String): Config = { + var m = HashMap[String, String](); + withCleaner { clean => + var in = new FileReader(path); clean { in.close(); } + var lno = 1; + for (line <- lines(in)) { + line match { + case RX_COMMENT() => (); + case RX_KEYVAL(key, value) => m += key -> value; + case _ => + throw new ConfigSyntaxError(path, lno, "failed to parse line"); + } + lno += 1; + } + } + + for ((key, dflt) <- DEFAULTS) { + if (!(m contains key)) { + try { m += key -> dflt(m); } + catch { + case e: DefaultFailed => + throw new ConfigDefaultFailed(path, key, e.key, m(e.key)); + } + } + } + m +} + +/*----- Managing a key repository -----------------------------------------*/ + +/* Lifecycle notes + * + * -> empty + * + * insert config file via URL or something + * + * -> pending (pending/tripe-keys.conf) + * + * verify master key fingerprint (against barcode?) + * + * -> confirmed (live/tripe-keys.conf; no live/repos) + * -> live (live/...) + * + * download package + * extract contents + * verify signature + * build keyrings + * build peer config + * rename tmp -> new + * + * -> updating (live/...; new/...) + * + * rename old repository aside + * + * -> committing (old/...; new/...) + * + * rename verified repository + * + * -> live (live/...) + * + * (delete old/) + */ + +object Repository { + object State extends Enumeration { + val Empty, Pending, Confirmed, Updating, Committing, Live = Value; + } + +} + +class Repository(val root: File) extends Closeable { + import Repository.State.{Value => State, _}; + + val livedir = new File(root, "live"); + val livereposdir = new File(livedir, "repos"); + val newdir = new File(root, "new"); + val olddir = new File(root, "old"); + val pendingdir = new File(root, "pending"); + val tmpdir = new File(root, "tmp"); + + val lock = { + if (!root.isDirectory && !root.mkdir()) ???; + val chan = new FileOutputStream(new File(root, "lk")).getChannel; + chan.tryLock() match { + case null => + throw new IOException(s"repository `${root.getPath}' locked") + case lk => lk + } + } + + def close() { + lock.release(); + lock.channel.close(); + } + + def state: State = + if (livedir.isDirectory) { + if (!livereposdir.isDirectory) Confirmed + else if (newdir.isDirectory && olddir.isDirectory) Committing + else Live + } else { + if (newdir.isDirectory) Updating + else if (pendingdir.isDirectory) Pending + else Empty + } + + def commitState(): State = state match { + case Updating => rmTree(newdir); state + case Committing => + if (!newdir.renameTo(livedir) && !olddir.renameTo(livedir)) + throw new IOException("failed to commit update"); + state + case st => st; + + def clean() { + +} + +/*----- That's all, folks -------------------------------------------------*/ + +}