wip
[tripe-android] / keys.scala
diff --git a/keys.scala b/keys.scala
new file mode 100644 (file)
index 0000000..f075159
--- /dev/null
@@ -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 <https://www.gnu.org/licenses/>.
+ */
+
+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-<SEQ>" },
+      "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 -------------------------------------------------*/
+
+}