| 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 java.io.{Closeable, File, FileOutputStream, FileReader, IOException}; |
| 31 | |
| 32 | import scala.collection.mutable.HashMap; |
| 33 | |
| 34 | /*----- Useful regular expressions ----------------------------------------*/ |
| 35 | |
| 36 | val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r; |
| 37 | val RX_KEYVAL = """(?x) ^ \s* |
| 38 | ([-\w]+) |
| 39 | (?:\s+(?!=)|\s*=\s*) |
| 40 | (|\S|\S.*\S) |
| 41 | \s* $""".r; |
| 42 | val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r; |
| 43 | |
| 44 | /*----- Things that go wrong ----------------------------------------------*/ |
| 45 | |
| 46 | class ConfigSyntaxError(val file: String, val lno: Int, val msg: String) |
| 47 | extends Exception { |
| 48 | override def getMessage(): String = s"$file:$lno: $msg"; |
| 49 | } |
| 50 | |
| 51 | class ConfigDefaultFailed(val file: String, val dfltkey: String, |
| 52 | val badkey: String, val badval: String) |
| 53 | extends Exception { |
| 54 | override def getMessage(): String = |
| 55 | s"$file: can't default `$dfltkey' because " + |
| 56 | s"`$badval' is not a recognized value for `$badkey'"; |
| 57 | } |
| 58 | |
| 59 | class DefaultFailed(val key: String) extends Exception; |
| 60 | |
| 61 | /*----- Parsing a configuration -------------------------------------------*/ |
| 62 | |
| 63 | type Config = scala.collection.Map[String, String]; |
| 64 | |
| 65 | val DEFAULTS: Seq[(String, Config => String)] = |
| 66 | Seq("repos-base" -> { _ => "tripe-keys.tar.gz" }, |
| 67 | "sig-base" -> { _ => "tripe-keys.sig-<SEQ>" }, |
| 68 | "repos-url" -> { conf => conf("base-url") + conf("repos-base") }, |
| 69 | "sig-url" -> { conf => conf("base-url") + conf("sig-base") }, |
| 70 | "kx" -> { _ => "dh" }, |
| 71 | "kx-genalg" -> { conf => conf("kx") match { |
| 72 | case alg@("dh" | "ec" | "x25519" | "x448") => alg |
| 73 | case _ => throw new DefaultFailed("kx") |
| 74 | } }, |
| 75 | "kx-expire" -> { _ => "now + 1 year" }, |
| 76 | "kx-warn-days" -> { _ => "28" }, |
| 77 | "bulk" -> { _ => "iiv" }, |
| 78 | "cipher" -> { conf => conf("bulk") match { |
| 79 | case "naclbox" => "salsa20" |
| 80 | case _ => "rijndael-cbc" |
| 81 | } }, |
| 82 | "hash" -> { _ => "sha256" }, |
| 83 | "mgf" -> { conf => conf("hash") + "-mgf" }, |
| 84 | "mac" -> { conf => conf("bulk") match { |
| 85 | case "naclbox" => "poly1305/128" |
| 86 | case _ => |
| 87 | val h = conf("hash"); |
| 88 | JNI.hashsz(h) match { |
| 89 | case -1 => throw new DefaultFailed("hash") |
| 90 | case hsz => s"${h}-hmac/${4*hsz}" |
| 91 | } |
| 92 | } }, |
| 93 | "sig" -> { conf => conf("kx") match { |
| 94 | case "dh" => "dsa" |
| 95 | case "ec" => "ecdsa" |
| 96 | case "x25519" => "ed25519" |
| 97 | case "x448" => "ed448" |
| 98 | case _ => throw new DefaultFailed("kx") |
| 99 | } }, |
| 100 | "sig-fresh" -> { _ => "always" }, |
| 101 | "fingerprint-hash" -> { _("hash") }); |
| 102 | |
| 103 | def readConfig(path: String): Config = { |
| 104 | var m = HashMap[String, String](); |
| 105 | withCleaner { clean => |
| 106 | var in = new FileReader(path); clean { in.close(); } |
| 107 | var lno = 1; |
| 108 | for (line <- lines(in)) { |
| 109 | line match { |
| 110 | case RX_COMMENT() => (); |
| 111 | case RX_KEYVAL(key, value) => m += key -> value; |
| 112 | case _ => |
| 113 | throw new ConfigSyntaxError(path, lno, "failed to parse line"); |
| 114 | } |
| 115 | lno += 1; |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | for ((key, dflt) <- DEFAULTS) { |
| 120 | if (!(m contains key)) { |
| 121 | try { m += key -> dflt(m); } |
| 122 | catch { |
| 123 | case e: DefaultFailed => |
| 124 | throw new ConfigDefaultFailed(path, key, e.key, m(e.key)); |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | m |
| 129 | } |
| 130 | |
| 131 | /*----- Managing a key repository -----------------------------------------*/ |
| 132 | |
| 133 | /* Lifecycle notes |
| 134 | * |
| 135 | * -> empty |
| 136 | * |
| 137 | * insert config file via URL or something |
| 138 | * |
| 139 | * -> pending (pending/tripe-keys.conf) |
| 140 | * |
| 141 | * verify master key fingerprint (against barcode?) |
| 142 | * |
| 143 | * -> confirmed (live/tripe-keys.conf; no live/repos) |
| 144 | * -> live (live/...) |
| 145 | * |
| 146 | * download package |
| 147 | * extract contents |
| 148 | * verify signature |
| 149 | * build keyrings |
| 150 | * build peer config |
| 151 | * rename tmp -> new |
| 152 | * |
| 153 | * -> updating (live/...; new/...) |
| 154 | * |
| 155 | * rename old repository aside |
| 156 | * |
| 157 | * -> committing (old/...; new/...) |
| 158 | * |
| 159 | * rename verified repository |
| 160 | * |
| 161 | * -> live (live/...) |
| 162 | * |
| 163 | * (delete old/) |
| 164 | */ |
| 165 | |
| 166 | object Repository { |
| 167 | object State extends Enumeration { |
| 168 | val Empty, Pending, Confirmed, Updating, Committing, Live = Value; |
| 169 | } |
| 170 | |
| 171 | } |
| 172 | |
| 173 | class Repository(val root: File) extends Closeable { |
| 174 | import Repository.State.{Value => State, _}; |
| 175 | |
| 176 | val livedir = new File(root, "live"); |
| 177 | val livereposdir = new File(livedir, "repos"); |
| 178 | val newdir = new File(root, "new"); |
| 179 | val olddir = new File(root, "old"); |
| 180 | val pendingdir = new File(root, "pending"); |
| 181 | val tmpdir = new File(root, "tmp"); |
| 182 | |
| 183 | val lock = { |
| 184 | if (!root.isDirectory && !root.mkdir()) ???; |
| 185 | val chan = new FileOutputStream(new File(root, "lk")).getChannel; |
| 186 | chan.tryLock() match { |
| 187 | case null => |
| 188 | throw new IOException(s"repository `${root.getPath}' locked") |
| 189 | case lk => lk |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | def close() { |
| 194 | lock.release(); |
| 195 | lock.channel.close(); |
| 196 | } |
| 197 | |
| 198 | def state: State = |
| 199 | if (livedir.isDirectory) { |
| 200 | if (!livereposdir.isDirectory) Confirmed |
| 201 | else if (newdir.isDirectory && olddir.isDirectory) Committing |
| 202 | else Live |
| 203 | } else { |
| 204 | if (newdir.isDirectory) Updating |
| 205 | else if (pendingdir.isDirectory) Pending |
| 206 | else Empty |
| 207 | } |
| 208 | |
| 209 | def commitState(): State = state match { |
| 210 | case Updating => rmTree(newdir); state |
| 211 | case Committing => |
| 212 | if (!newdir.renameTo(livedir) && !olddir.renameTo(livedir)) |
| 213 | throw new IOException("failed to commit update"); |
| 214 | state |
| 215 | case st => st; |
| 216 | |
| 217 | def clean() { |
| 218 | |
| 219 | } |
| 220 | |
| 221 | /*----- That's all, folks -------------------------------------------------*/ |
| 222 | |
| 223 | } |