/* -*-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 scala.collection.mutable.HashMap;
import java.io.{Closeable, File};
import java.net.{URL, URLConnection};
import java.util.zip.GZIPInputStream;
import sys.{SystemError, hashsz, runCommand};
import sys.Errno.EEXIST;
import sys.FileImplicits._;
/*----- Useful regular expressions ----------------------------------------*/
private val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
private val RX_KEYVAL = """(?x) ^ \s*
([-\w]+)
(?:\s+(?!=)|\s*=\s*)
(|\S|\S.*\S)
\s* $""".r;
private 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];
private 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");
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") });
/*----- Managing a key repository -----------------------------------------*/
def downloadToFile(file: File, url: URL, maxlen: Long = Long.MaxValue) {
fetchURL(url, new URLFetchCallbacks {
val out = file.openForOutput();
private def toobig() {
throw new KeyConfigException(s"remote file `$url' is " +
"suspiciously large");
}
var totlen: Long = 0;
override def preflight(conn: URLConnection) {
totlen = conn.getContentLength;
if (totlen > maxlen) toobig();
}
override def done(win: Boolean) { out.close(); }
def write(buf: Array[Byte], n: Int, len: Long) {
if (len + n > maxlen) toobig();
out.write(buf, 0, n);
}
});
}
/* 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 RepositoryStateException(val state: Repository.State.Value,
msg: String)
extends Exception(msg);
class KeyConfigException(msg: String) extends Exception(msg);
class Repository(val root: File) extends Closeable {
import Repository.State.{Value => State, _};
/* Important directories and files. */
private[this] val livedir = root + "live";
private[this] val livereposdir = livedir + "repos";
private[this] val newdir = root + "new";
private[this] val olddir = root + "old";
private[this] val pendingdir = root + "pending";
private[this] val tmpdir = root + "tmp";
/* Take out a lock in case of other instances. */
private[this] val lock = {
try { root.mkdir_!(); }
catch { case SystemError(EEXIST, _) => ok; }
(root + "lk").lock_!()
}
def close() { lock.close(); }
/* Maintain a cache of some repository state. */
private var _state: State = null;
private var _config: Config = null;
private def invalidate() {
_state = null;
_config = null;
}
def state: State = {
/* Determine the current repository state. */
if (_state == null)
_state = if (livedir.isdir_!) {
if (!livereposdir.isdir_!) Confirmed
else if (newdir.isdir_!) Updating
else Live
} else {
if (newdir.isdir_!) Committing
else if (pendingdir.isdir_!) Pending
else Empty
}
_state
}
def checkState(wanted: State*) {
/* Ensure we're in a particular state. */
val st = state;
if (wanted.forall(_ != st)) {
throw new RepositoryStateException(st, s"Repository is $st, not " +
oxford("or",
wanted.map(_.toString)));
}
}
def cleanup() {
/* If we're part-way through an update then back out or press forward. */
state match {
case Updating =>
/* We have a new tree allegedly ready, but the current one is still
* in place. It seems safer to zap the new one here, but we could go
* either way.
*/
newdir.rmTree();
invalidate(); // should move back to `Live' or `Confirmed'
case Committing =>
/* We have a new tree ready, and an old one moved aside. We're going
* to have to move one of them. Let's try committing the new tree.
*/
newdir.rename_!(livedir); // should move on to `Live'
invalidate();
case _ =>
/* Other states are stable. */
ok;
}
/* Now work through the things in our area of the filesystem and zap the
* ones which don't belong. In particular, this will always erase
* `tmpdir'.
*/
val st = state;
root.foreachFile { f => (f.getName, st) match {
case ("lk", _) => ok;
case ("live", Live | Confirmed) => ok;
case ("pending", Pending) => ok;
case (_, Updating | Committing) =>
unreachable(s"unexpectedly still in `$st' state");
case _ => f.rmTree();
}
} }
def destroy() {
/* Clear out the entire repository. Everything. It's all gone. */
root.foreachFile { f => if (f.getName != "lk") f.rmTree(); }
}
def clearTmp() {
/* Arrange to have an empty `tmpdir'. */
tmpdir.rmTree();
tmpdir.mkdir_!();
}
def config: Config = {
/* Return the repository configuration. */
if (_config == null) {
/* Firstly, decide where to find the configuration file. */
cleanup();
val dir = state match {
case Live | Confirmed => livedir
case Pending => pendingdir
case Empty =>
throw new RepositoryStateException(Empty, "repository is Empty");
}
val file = dir + "tripe-keys.conf";
/* Build the new configuration in a temporary place. */
var m = HashMap[String, String]();
/* Read the config file into our map. */
file.withReader { in =>
var lno = 1;
for (line <- lines(in)) {
line match {
case RX_COMMENT() => ok;
case RX_KEYVAL(key, value) => m += key -> value;
case _ =>
throw new ConfigSyntaxError(file.getPath, lno,
"failed to parse line");
}
lno += 1;
}
}
/* Fill in defaults where things have been missed out. */
for ((key, dflt) <- DEFAULTS) {
if (!(m contains key)) {
try { m += key -> dflt(m); }
catch {
case e: DefaultFailed =>
throw new ConfigDefaultFailed(file.getPath, key,
e.key, m(e.key));
}
}
}
/* All done. */
_config = m;
}
_config
}
def fetchConfig(url: URL) {
/* Fetch an initial configuration file from a given URL. */
checkState(Empty);
clearTmp();
downloadToFile(tmpdir + "tripe-keys.conf", url);
tmpdir.rename_!(pendingdir);
invalidate(); // should move to `Pending'
}
def confirm() {
/* The user has approved the master key fingerprint in the `Pending'
* configuration. Advance to `Confirmed'.
*/
checkState(Pending);
pendingdir.rename_!(livedir);
invalidate(); // should move to `Confirmed'
}
def update() {
/* Update the repository from the master.
*
* Fetch a (possibly new) archive; unpack it; verify the master key
* against the known fingerprint; and check the signature on the bundle.
*/
checkState(Confirmed, Live);
val conf = config;
clearTmp();
/* First thing is to download the tarball and signature. */
val tarfile = tmpdir + "tripe-keys.tar.gz";
downloadToFile(tarfile, new URL(conf("repos-url")));
val sigfile = tmpdir + "tripe-keys.sig";
val seq = conf("master-sequence");
downloadToFile(sigfile,
new URL(conf("sig-url").replaceAllLiterally("",
seq)));
/* Unpack the tarball. Carefully. */
val unpkdir = tmpdir + "unpk";
unpkdir.mkdir_!();
withCleaner { clean =>
val tar = new TarFile(new GZIPInputStream(tarfile.open()));
clean { tar.close(); }
for (e <- tar) {
/* Check the filename to make sure it's not evil. */
if (e.name.split('/').exists { _ == ".." })
throw new KeyConfigException("invalid path in tarball");
/* Find out where this file points. */
val f = unpkdir + e.name;
/* Unpack it. */
if (e.isdir) {
/* A directory. Create it if it doesn't exist already. */
try { f.mkdir_!(); }
catch { case SystemError(EEXIST, _) => ok; }
} else if (e.isreg) {
/* A regular file. Write stuff to it. */
e.withStream { in =>
f.withOutput { out =>
for ((b, n) <- blocks(in)) out.write(b, 0, n);
}
}
} else {
/* Something else. Be paranoid and reject it. */
throw new KeyConfigException("unexpected object type in tarball");
}
}
}
/* There ought to be a file in here called `repos/master.pub'. */
val reposdir = unpkdir + "repos";
if (!reposdir.isdir_!)
throw new KeyConfigException("missing `repos/' directory");
val masterfile = reposdir + "master.pub";
if (!masterfile.isreg_!)
throw new KeyConfigException("missing `repos/master.pub' file");
/* Fetch the master key's fingerprint. */
val (out, _) = runCommand("key", "-k", masterfile.getPath,
"fingerprint",
"-f", "-secret",
"-a", conf("fingerprint-hash"),
s"master-$seq");
println(s";; $out");
}
}
/*----- That's all, folks -------------------------------------------------*/
}