chiark / gitweb /
The work! The progress!
[tripe-android] / keys.scala
CommitLineData
8eabb4ff
MW
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
26package uk.org.distorted.tripe; package object keys {
27
28/*----- Imports -----------------------------------------------------------*/
29
8eabb4ff
MW
30import scala.collection.mutable.HashMap;
31
a5ec891a
MW
32import java.io.{Closeable, File, IOException};
33import java.lang.{Long => JLong};
c8292b34 34import java.net.{URL, URLConnection};
a5ec891a
MW
35import java.text.SimpleDateFormat;
36import java.util.Date;
c8292b34
MW
37import java.util.zip.GZIPInputStream;
38
39import sys.{SystemError, hashsz, runCommand};
40import sys.Errno.EEXIST;
41import sys.FileImplicits._;
a5ec891a 42import sys.FileInfo.{DIR, REG};
c8292b34 43
04a5abae
MW
44import progress.{Eyecandy, SimpleModel, DataModel};
45
8eabb4ff
MW
46/*----- Useful regular expressions ----------------------------------------*/
47
04a5abae
MW
48private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
49private final val RX_KEYVAL = """(?x) ^ \s*
8eabb4ff
MW
50 ([-\w]+)
51 (?:\s+(?!=)|\s*=\s*)
52 (|\S|\S.*\S)
53 \s* $""".r;
04a5abae 54private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
8eabb4ff 55
a5ec891a
MW
56private final val RX_PUBKEY = """(?x) ^ peer- (.*) \.pub $""".r;
57
58private final val RX_KEYINFO = """(?x) ^ ([^:]*) : \s* (\S.*) $""".r
59private final val RX_KEYATTR = """(?x) ^ \s*
60 ([^\s=] | [^\s=][^=]*[^\s=])
61 \s* = \s*
62 (\S.*) $""".r;
63
8eabb4ff
MW
64/*----- Things that go wrong ----------------------------------------------*/
65
66class ConfigSyntaxError(val file: String, val lno: Int, val msg: String)
67 extends Exception {
68 override def getMessage(): String = s"$file:$lno: $msg";
69}
70
71class ConfigDefaultFailed(val file: String, val dfltkey: String,
72 val badkey: String, val badval: String)
73 extends Exception {
74 override def getMessage(): String =
75 s"$file: can't default `$dfltkey' because " +
76 s"`$badval' is not a recognized value for `$badkey'";
77}
78
79class DefaultFailed(val key: String) extends Exception;
80
81/*----- Parsing a configuration -------------------------------------------*/
82
83type Config = scala.collection.Map[String, String];
84
c8292b34 85private val DEFAULTS: Seq[(String, Config => String)] =
8eabb4ff
MW
86 Seq("repos-base" -> { _ => "tripe-keys.tar.gz" },
87 "sig-base" -> { _ => "tripe-keys.sig-<SEQ>" },
88 "repos-url" -> { conf => conf("base-url") + conf("repos-base") },
89 "sig-url" -> { conf => conf("base-url") + conf("sig-base") },
90 "kx" -> { _ => "dh" },
91 "kx-genalg" -> { conf => conf("kx") match {
92 case alg@("dh" | "ec" | "x25519" | "x448") => alg
93 case _ => throw new DefaultFailed("kx")
94 } },
95 "kx-expire" -> { _ => "now + 1 year" },
96 "kx-warn-days" -> { _ => "28" },
97 "bulk" -> { _ => "iiv" },
98 "cipher" -> { conf => conf("bulk") match {
99 case "naclbox" => "salsa20"
100 case _ => "rijndael-cbc"
101 } },
102 "hash" -> { _ => "sha256" },
103 "mgf" -> { conf => conf("hash") + "-mgf" },
104 "mac" -> { conf => conf("bulk") match {
105 case "naclbox" => "poly1305/128"
106 case _ =>
107 val h = conf("hash");
c8292b34 108 hashsz(h) match {
8eabb4ff
MW
109 case -1 => throw new DefaultFailed("hash")
110 case hsz => s"${h}-hmac/${4*hsz}"
111 }
112 } },
113 "sig" -> { conf => conf("kx") match {
114 case "dh" => "dsa"
115 case "ec" => "ecdsa"
116 case "x25519" => "ed25519"
117 case "x448" => "ed448"
118 case _ => throw new DefaultFailed("kx")
119 } },
120 "sig-fresh" -> { _ => "always" },
121 "fingerprint-hash" -> { _("hash") });
122
3bb2303d 123private def parseConfig(file: File): HashMap[String, String] = {
8eabb4ff 124
04a5abae 125 /* Build the new configuration in a temporary place. */
ad64fbfa 126 val m = HashMap[String, String]();
04a5abae
MW
127
128 /* Read the config file into our map. */
129 file.withReader { in =>
130 var lno = 1;
131 for (line <- lines(in)) {
132 line match {
133 case RX_COMMENT() => ok;
3bb2303d 134 case RX_KEYVAL(key, value) => m(key) = value;
04a5abae
MW
135 case _ =>
136 throw new ConfigSyntaxError(file.getPath, lno,
137 "failed to parse line");
138 }
139 lno += 1;
c8292b34 140 }
04a5abae
MW
141 }
142
a5ec891a
MW
143 /* Done. */
144 m
145}
146
147private def readConfig(file: File): Config = {
148 var m = parseConfig(file);
149
04a5abae
MW
150 /* Fill in defaults where things have been missed out. */
151 for ((key, dflt) <- DEFAULTS) {
152 if (!(m contains key)) {
3bb2303d 153 try { m(key) = dflt(m); }
04a5abae
MW
154 catch {
155 case e: DefaultFailed =>
156 throw new ConfigDefaultFailed(file.getPath, key,
157 e.key, m(e.key));
158 }
c8292b34 159 }
04a5abae
MW
160 }
161
162 /* And we're done. */
163 m
164}
165
166/*----- Managing a key repository -----------------------------------------*/
167
168def downloadToFile(file: File, url: URL,
169 maxlen: Long = Long.MaxValue,
170 ic: Eyecandy) {
171 ic.job(new SimpleModel(s"connecting to `$url'", -1)) { jr =>
172 fetchURL(url, new URLFetchCallbacks {
173 val out = file.openForOutput();
174 private def toobig() {
175 throw new KeyConfigException(
176 s"remote file `$url' is suspiciously large");
177 }
178 var totlen: Long = 0;
179 override def preflight(conn: URLConnection) {
180 totlen = conn.getContentLength;
181 if (totlen > maxlen) toobig();
182 jr.change(new SimpleModel(s"downloading `$url'", totlen)
183 with DataModel,
184 0);
185 }
186 override def done(win: Boolean) { out.close(); }
187 def write(buf: Array[Byte], n: Int, len: Long) {
188 if (len + n > maxlen) toobig();
189 out.write(buf, 0, n);
190 jr.step(len + n);
191 }
192 })
193 }
8eabb4ff
MW
194}
195
8eabb4ff
MW
196/* Lifecycle notes
197 *
198 * -> empty
199 *
200 * insert config file via URL or something
201 *
202 * -> pending (pending/tripe-keys.conf)
203 *
204 * verify master key fingerprint (against barcode?)
205 *
206 * -> confirmed (live/tripe-keys.conf; no live/repos)
207 * -> live (live/...)
208 *
209 * download package
210 * extract contents
211 * verify signature
212 * build keyrings
213 * build peer config
214 * rename tmp -> new
215 *
216 * -> updating (live/...; new/...)
217 *
218 * rename old repository aside
219 *
220 * -> committing (old/...; new/...)
221 *
222 * rename verified repository
223 *
224 * -> live (live/...)
225 *
226 * (delete old/)
227 */
228
04a5abae
MW
229class RepositoryStateException(val state: Repository.State.Value,
230 msg: String)
231 extends Exception(msg);
232
233class KeyConfigException(msg: String) extends Exception(msg);
234
235private def launderFingerprint(fp: String): String =
236 fp filter { _.isLetterOrDigit };
237
238private def fingerprintsEqual(a: String, b: String) =
239 launderFingerprint(a) == launderFingerprint(b);
240
241private def keyFingerprint(kr: File, tag: String, hash: String): String = {
242 val (out, _) = runCommand("key", "-k", kr.getPath, "fingerprint",
243 "-a", hash, "-f", "-secret", tag);
244 nextToken(out) match {
245 case Some((fp, _)) => fp
246 case _ =>
a5ec891a 247 throw new IOException("unexpected output from `key fingerprint'");
04a5abae
MW
248 }
249}
250
a5ec891a
MW
251private def checkIdent(id: String) {
252 if (id exists { ch => ch == ':' || ch == '.' || ch.isWhitespace })
253 throw new IllegalArgumentException(s"bad key tag `$id'");
254}
255
8eabb4ff
MW
256object Repository {
257 object State extends Enumeration {
258 val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
259 }
8eabb4ff
MW
260}
261
04a5abae
MW
262def checkConfigSanity(file: File, ic: Eyecandy) {
263 ic.operation("checking new configuration") { _ =>
c8292b34 264
04a5abae
MW
265 /* Make sure we can read and understand the file. */
266 val conf = readConfig(file);
267
268 /* Make sure there are entries which we can use to update. This won't
269 * guarantee that we can reliably update, but it will help.
270 */
271 conf("repos-url"); conf("sig-url");
272 conf("fingerprint-hash"); conf("sig-fresh");
273 conf("master-sequence"); conf("hk-master");
274 }
275}
c8292b34 276
a5ec891a
MW
277private val keydatefmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
278class PrivateKey private[keys](repo: Repository, dir: File) {
279 private[this] lazy val keyring = dir/"keyring";
280 private[this] lazy val meta = parseConfig(dir/"meta");
281 lazy val tag = meta("tag");
282 lazy val time = datefmt synchronized { datefmt.parse(meta("time")); };
283 lazy val fingerprint = keyFingerprint(keyring, tag,
284 repo.config("fingerprint-hash"));
285
286 def remove() { dir.rmTree(); }
287
288 private[this] lazy val (info, _attr) = {
289 val m = Map.newBuilder[String, String];
290 val a = Map.newBuilder[String, String];
291 val (out, _) = runCommand("key", "-k", keyring.getPath,
292 "list", "-vv", tag);
293 val lines = out.lines;
294 while (lines.hasNext) lines.next match {
295 case "attributes:" =>
296 while (lines.hasNext) lines.next match {
297 case RX_KEYATTR(k, v) => a += k -> v;
298 case line => throw new IOException(
299 s"unexpected output from `key list': $line");
300 }
301 case RX_KEYINFO(k, v) =>
302 m += k -> v;
303 case line => throw new IOException(
304 s"unexpected output from `key list': $line");
305 }
306 (m.result, a.result)
307 }
308
309 lazy val expires = info("expiry") match {
310 case "forever" => None
311 case d => Some(keydatefmt synchronized { keydatefmt.parse(d) })
312 }
313 lazy val ty = info("type");
314 lazy val comment = info("comment");
315 lazy val keyid = {
316
317 /* Ugh. Using `Int' throws an exception on words whose top bit is set
318 * because Java doesn't have proper unsigned integers. There's
319 * `parseUnsignedInt' in Java 1.8, but that limits our Android targets.
320 * And Scala has put its own `Long' object in the way of Java's so we
321 * need this circumolution.
322 */
323 (JLong.parseLong(info("keyid"), 16)&0xffffffff).toInt;
324 }
325 lazy val attr = _attr;
326}
327
8eabb4ff
MW
328class Repository(val root: File) extends Closeable {
329 import Repository.State.{Value => State, _};
330
c8292b34 331 /* Important directories and files. */
a5ec891a
MW
332 private[this] val configdir = root/"config";
333 private[this] val livedir = configdir/"live";
04a5abae 334 private[this] val livereposdir = livedir/"repos";
a5ec891a
MW
335 private[this] val newdir = configdir/"new";
336 private[this] val olddir = configdir/"old";
337 private[this] val pendingdir = configdir/"pending";
04a5abae 338 private[this] val tmpdir = root/"tmp";
a5ec891a 339 private[this] val keysdir = root/"keys";
c8292b34
MW
340
341 /* Take out a lock in case of other instances. */
a5ec891a 342 private[this] var open = false;
c8292b34 343 private[this] val lock = {
a5ec891a
MW
344 root.mkdirNew_!();
345 open = true;
04a5abae 346 (root/"lk").lock_!()
c8292b34 347 }
a5ec891a
MW
348 def close() { lock.close(); open = false; }
349 private[this] def checkLocked()
350 { if (!open) throw new IllegalStateException("repository is unlocked"); }
c8292b34
MW
351
352 /* Maintain a cache of some repository state. */
353 private var _state: State = null;
354 private var _config: Config = null;
355 private def invalidate() {
356 _state = null;
357 _config = null;
358 }
359
360 def state: State = {
361 /* Determine the current repository state. */
362
363 if (_state == null)
364 _state = if (livedir.isdir_!) {
365 if (!livereposdir.isdir_!) Confirmed
366 else if (newdir.isdir_!) Updating
367 else Live
368 } else {
369 if (newdir.isdir_!) Committing
370 else if (pendingdir.isdir_!) Pending
371 else Empty
372 }
373
374 _state
375 }
376
377 def checkState(wanted: State*) {
378 /* Ensure we're in a particular state. */
a5ec891a 379 checkLocked();
c8292b34
MW
380 val st = state;
381 if (wanted.forall(_ != st)) {
382 throw new RepositoryStateException(st, s"Repository is $st, not " +
383 oxford("or",
384 wanted.map(_.toString)));
385 }
386 }
387
a5ec891a 388 def cleanup(ic: Eyecandy) {
c8292b34
MW
389
390 /* If we're part-way through an update then back out or press forward. */
391 state match {
392
393 case Updating =>
394 /* We have a new tree allegedly ready, but the current one is still
395 * in place. It seems safer to zap the new one here, but we could go
396 * either way.
397 */
398
a5ec891a
MW
399 ic.operation("rolling back failed update")
400 { _ => newdir.rmTree(); }
c8292b34
MW
401 invalidate(); // should move back to `Live' or `Confirmed'
402
403 case Committing =>
404 /* We have a new tree ready, and an old one moved aside. We're going
405 * to have to move one of them. Let's try committing the new tree.
406 */
407
a5ec891a
MW
408 ic.operation("committing interrupted update")
409 { _ => newdir.rename_!(livedir); }
410 invalidate(); // should move on to `Live'
c8292b34
MW
411
412 case _ =>
413 /* Other states are stable. */
414 ok;
8eabb4ff 415 }
c8292b34
MW
416
417 /* Now work through the things in our area of the filesystem and zap the
418 * ones which don't belong. In particular, this will always erase
419 * `tmpdir'.
420 */
a5ec891a
MW
421 ic.operation("cleaning up configuration area") { or =>
422 val st = state;
423 root foreachFile { f => f.getName match {
424 case "lk" | "keys" => ok;
425 case "config" => configdir foreachFile { f => (f.getName, st) match {
426 case ("live", Live | Confirmed) => ok;
427 case ("pending", Pending) => ok;
428 case (_, Updating | Committing) =>
429 unreachable(s"unexpectedly still in `$st' state");
430 case _ => or.step(s"delete `$f'"); f.rmTree();
431 } }
432 case _ => or.step(s"delete `$f'"); f.rmTree();
433 } }
c8292b34 434 }
a5ec891a 435 }
c8292b34 436
04a5abae 437 def destroy(ic: Eyecandy) {
c8292b34 438 /* Clear out the entire repository. Everything. It's all gone. */
04a5abae 439 ic.operation("clearing configuration")
a5ec891a 440 { _ => root foreachFile { f => if (f.getName != "lk") f.rmTree(); } }
c8292b34
MW
441 }
442
443 def clearTmp() {
444 /* Arrange to have an empty `tmpdir'. */
445 tmpdir.rmTree();
446 tmpdir.mkdir_!();
447 }
448
449 def config: Config = {
450 /* Return the repository configuration. */
451
a5ec891a 452 checkLocked();
c8292b34
MW
453 if (_config == null) {
454
455 /* Firstly, decide where to find the configuration file. */
a5ec891a 456 checkState(Pending, Confirmed, Live);
c8292b34
MW
457 val dir = state match {
458 case Live | Confirmed => livedir
459 case Pending => pendingdir
a5ec891a 460 case _ => ???
c8292b34 461 }
c8292b34 462
04a5abae
MW
463 /* And then read the configuration. */
464 _config = readConfig(dir/"tripe-keys.conf");
c8292b34
MW
465 }
466
467 _config
8eabb4ff
MW
468 }
469
04a5abae 470 def fetchConfig(url: URL, ic: Eyecandy) {
c8292b34
MW
471 /* Fetch an initial configuration file from a given URL. */
472
473 checkState(Empty);
474 clearTmp();
04a5abae
MW
475
476 val conffile = tmpdir/"tripe-keys.conf";
477 downloadToFile(conffile, url, 16*1024, ic);
478 checkConfigSanity(conffile, ic);
a5ec891a 479 configdir.mkdirNew_!();
04a5abae
MW
480 ic.operation("committing configuration")
481 { _ => tmpdir.rename_!(pendingdir); }
c8292b34 482 invalidate(); // should move to `Pending'
a5ec891a 483 cleanup(ic);
8eabb4ff
MW
484 }
485
04a5abae 486 def confirm(ic: Eyecandy) {
c8292b34
MW
487 /* The user has approved the master key fingerprint in the `Pending'
488 * configuration. Advance to `Confirmed'.
489 */
490
491 checkState(Pending);
04a5abae
MW
492 ic.operation("confirming configuration")
493 { _ => pendingdir.rename_!(livedir); }
c8292b34
MW
494 invalidate(); // should move to `Confirmed'
495 }
496
04a5abae 497 def update(ic: Eyecandy) {
c8292b34
MW
498 /* Update the repository from the master.
499 *
500 * Fetch a (possibly new) archive; unpack it; verify the master key
501 * against the known fingerprint; and check the signature on the bundle.
502 */
503
a5ec891a 504 cleanup(ic);
c8292b34
MW
505 checkState(Confirmed, Live);
506 val conf = config;
507 clearTmp();
508
509 /* First thing is to download the tarball and signature. */
04a5abae
MW
510 val tarfile = tmpdir/"tripe-keys.tar.gz";
511 downloadToFile(tarfile, new URL(conf("repos-url")), 256*1024, ic);
512 val sigfile = tmpdir/"tripe-keys.sig";
c8292b34
MW
513 val seq = conf("master-sequence");
514 downloadToFile(sigfile,
515 new URL(conf("sig-url").replaceAllLiterally("<SEQ>",
04a5abae
MW
516 seq)),
517 4*1024, ic);
c8292b34
MW
518
519 /* Unpack the tarball. Carefully. */
04a5abae
MW
520 val unpkdir = tmpdir/"unpk";
521 ic.operation("unpacking archive") { or =>
522 unpkdir.mkdir_!();
523 withCleaner { clean =>
524 val tar = new TarFile(new GZIPInputStream(tarfile.open()));
525 clean { tar.close(); }
526 for (e <- tar) {
527
528 /* Check the filename to make sure it's not evil. */
a5ec891a
MW
529 if (e.name(0) == '/' || e.name.split('/').exists { _ == ".." }) {
530 throw new KeyConfigException(
531 s"invalid path `${e.name}' in tarball");
532 }
04a5abae
MW
533
534 /* Report on progress. */
535 or.step(s"entry `${e.name}'");
536
537 /* Find out where this file points. */
538 val f = unpkdir/e.name;
539
540 /* Unpack it. */
a5ec891a
MW
541 e.typ match {
542 case DIR =>
543 /* A directory. Create it if it doesn't exist already. */
544
545 f.mkdirNew_!();
04a5abae 546
a5ec891a
MW
547 case REG =>
548 /* A regular file. Write stuff to it. */
04a5abae 549
a5ec891a
MW
550 e.withStream { in =>
551 f.withOutput { out =>
552 for ((b, n) <- blocks(in)) out.write(b, 0, n);
553 }
04a5abae 554 }
c8292b34 555
a5ec891a
MW
556 case ty =>
557 /* Something else. Be paranoid and reject it. */
558
559 throw new KeyConfigException(
560 s"entry `${e.name}' has unexpected object type $ty");
04a5abae 561 }
c8292b34
MW
562 }
563 }
8eabb4ff
MW
564 }
565
c8292b34 566 /* There ought to be a file in here called `repos/master.pub'. */
04a5abae
MW
567 val reposdir = unpkdir/"repos";
568 val masterfile = reposdir/"master.pub";
569
c8292b34
MW
570 if (!reposdir.isdir_!)
571 throw new KeyConfigException("missing `repos/' directory");
c8292b34
MW
572 if (!masterfile.isreg_!)
573 throw new KeyConfigException("missing `repos/master.pub' file");
04a5abae 574 val mastertag = s"master-$seq";
8eabb4ff 575
c8292b34 576 /* Fetch the master key's fingerprint. */
04a5abae
MW
577 ic.operation("checking master key fingerprint") { _ =>
578 val foundfp = keyFingerprint(masterfile, mastertag,
579 conf("fingerprint-hash"));
580 val wantfp = conf("hk-master");
581 if (!fingerprintsEqual(wantfp, foundfp)) {
582 throw new KeyConfigException(
583 s"master key #$seq has wrong fingerprint: " +
584 s"expected $wantfp but found $foundfp");
585 }
586 }
587
588 /* Check the archive signature. */
589 ic.operation("verifying archive signature") { or =>
590 runCommand("catsign", "-k", masterfile.getPath, "verify", "-aqC",
591 "-k", mastertag, "-t", conf("sig-fresh"),
592 sigfile.getPath, tarfile.getPath);
593 }
594
595 /* Confirm that the configuration in the new archive is sane. */
596 checkConfigSanity(unpkdir/"tripe-keys.conf", ic);
597
a5ec891a
MW
598 /* Build the public keyring. (Observe the quadratic performance.) */
599 ic.operation("collecting public keys") { or =>
600 val pubkeys = unpkdir/"keyring.pub";
601 pubkeys.remove_!();
602 reposdir foreachFile { file => file.getName match {
603 case RX_PUBKEY(peer) if file.isreg_! =>
604 or.step(peer);
605 runCommand("key", "-k", pubkeys.getPath, "merge", file.getPath);
606 case _ => ok;
607 } }
608 (unpkdir/"keyring.pub.old").remove_!();
609 }
610
04a5abae
MW
611 /* Now we just have to juggle the files about. */
612 ic.operation("committing new configuration") { _ =>
613 unpkdir.rename_!(newdir);
614 livedir.rename_!(olddir);
615 newdir.rename_!(livedir);
616 }
617
a5ec891a 618 /* All done. */
04a5abae 619 invalidate(); // should move to `Live'
a5ec891a 620 cleanup(ic);
c8292b34 621 }
a5ec891a
MW
622
623 def generateKey(tag: String, label: String, ic: Eyecandy) {
624 checkIdent(tag);
625 if (label.exists { _ == '/' })
626 throw new IllegalArgumentException(s"invalid label string `$label'");
627 if ((keysdir/label).isdir_!)
628 throw new IllegalArgumentException(s"key `$label' already exists");
629
630 cleanup(ic);
631 checkState(Live);
632 val conf = config;
633 clearTmp();
634
635 val now = datefmt synchronized { datefmt.format(new Date) };
636 val kr = tmpdir/"keyring";
637 val pub = tmpdir/s"peer-$tag.pub";
638 val param = livereposdir/"param";
639
640 keysdir.mkdirNew_!();
641
642 ic.operation("fetching key-generation parameters") { _ =>
643 runCommand("key", "-k", kr.getPath, "merge", param.getPath);
644 }
645 ic.operation("generating new key") { _ =>
646 runCommand("key", "-k", kr.getPath, "add",
647 "-a", conf("kx-genalg"), "-p", "param",
648 "-e", conf("kx-expire"), "-t", tag, "tripe");
649 }
650 ic.operation("extracting public key") { _ =>
651 runCommand("key", "-k", kr.getPath, "extract",
652 "-f", "-secret", pub.getPath, tag);
653 }
654 ic.operation("writing metadata") { _ =>
655 tmpdir/"meta" withWriter { w =>
656 w.write(s"tag = $tag\n");
657 w.write(s"time = $now\n");
658 }
659 }
660 ic.operation("installing new key") { _ =>
661 tmpdir.rename_!(keysdir/label);
662 }
663 }
664
665 def key(label: String): PrivateKey = new PrivateKey(this, keysdir/label);
666 def keyLabels: Seq[String] = (keysdir.files_! map { _.getName }).toStream;
667 def keys: Seq[PrivateKey] = keyLabels map { k => key(k) };
8eabb4ff
MW
668}
669
670/*----- That's all, folks -------------------------------------------------*/
671
672}