You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
726 lines
26 KiB
726 lines
26 KiB
// Command datekeys encrypts, inspects and opens DateKeyCap (.dkc) files.
|
|
//
|
|
// datekeys encrypt -at 2030-01-01T00:00:00Z -in fotos -in carta.txt -comment "Para Ana" -out regalo.dkc
|
|
// datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk key.dkk -in secret.txt -out secret.dkc
|
|
// datekeys inspect -in regalo.dkc
|
|
// datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -words-file palabras.txt -in carta.txt -out carta.dkc
|
|
// datekeys author keygen -out autor.key -pass-file clave.txt
|
|
// datekeys encrypt -at 2030-01-01T00:00:00Z -in carta.txt -sign autor.key -sign-pass-file clave.txt -out carta.dkc
|
|
// datekeys decrypt -in regalo.dkc -out regalo [-dkk key.dkk] [-identity key.txt] [-words-file palabras.txt]
|
|
// datekeys decrypt -in regalo.dkc -out regalo -release regalo.dkr
|
|
// datekeys release -in regalo.dkc -out regalo.dkr
|
|
// datekeys datekey resolve -at 2030-01-01T00:00:00Z
|
|
// datekeys profile hash
|
|
// datekeys version
|
|
//
|
|
// Encryption never touches the network. Decryption fetches the release from
|
|
// public drand relays, or reads the one the person has in hand with -release,
|
|
// and verifies it locally. Outputs are written to a
|
|
// temporary file in the destination directory and published only when
|
|
// complete; existing files are never overwritten. The files of a format 3
|
|
// capsule go to a new folder, staged inside it and moved into place only
|
|
// when every check has passed.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
|
|
datekeys "g.activething.com/go/DateKeys"
|
|
"g.activething.com/go/DateKeys/accesskey"
|
|
"g.activething.com/go/DateKeys/authorkey"
|
|
"g.activething.com/go/DateKeys/capsule"
|
|
"g.activething.com/go/DateKeys/datekey"
|
|
"g.activething.com/go/DateKeys/internal/inspectview"
|
|
"g.activething.com/go/DateKeys/profile"
|
|
"g.activething.com/go/DateKeys/provider"
|
|
"g.activething.com/go/DateKeys/provider/drand"
|
|
"g.activething.com/go/DateKeys/wordkey"
|
|
)
|
|
|
|
const usage = `usage:
|
|
datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area]
|
|
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]... [-release FILE] [-save-release FILE.dkr]
|
|
datekeys release -in FILE.dkc -out FILE.dkr [-relay URL]...
|
|
datekeys author keygen -out FILE (-pass-file FILE|-plain)
|
|
datekeys author public -key FILE [-pass-file FILE]
|
|
datekeys inspect -in FILE.dkc [-json]
|
|
datekeys datekey resolve -at TIME
|
|
datekeys profile hash [-in PROFILE.cbor]
|
|
datekeys version
|
|
|
|
TIME is RFC 3339 with a time zone, for example 2030-01-01T00:00:00Z.
|
|
encrypt writes capsule format 3: the files of each -in, a folder by its name
|
|
and the files below it, with an optional comment and declared author. The
|
|
content is padded, by default with the rule reforzado, and a time_and_key
|
|
capsule holds 16 slots, from 1 to 16 credentials and a dummy in each slot
|
|
left (spec §29, §39). decrypt writes the files of a format 3 capsule to the
|
|
new folder PATH, and the content of formats 1 and 2 to the new file PATH.
|
|
|
|
decrypt fetches the release of the round from drand relays, never before the
|
|
round time. -release FILE gives it instead, without any network request: a
|
|
.dkr file, drand's JSON or a local release archive. It is verified like one
|
|
from a relay, and the clock does not stop it: a valid release proves that the
|
|
round was published (spec v0.15, §63 step 9.c). -save-release keeps the
|
|
release that opened the capsule in a new .dkr file, to keep next to the .dkc:
|
|
in decades the relays may no longer serve the round. release fetches,
|
|
verifies and saves that .dkr without opening the capsule; the request tells
|
|
the relay the round, as opening does.
|
|
|
|
-words and -words-file give a key of words to a time_and_key capsule: at
|
|
least 6 different words of 3 or more letters that open it with decrypt,
|
|
instead of a .dkk
|
|
(wordkey). Case, accents and extra spaces do not matter. -words leaves them
|
|
in the shell history; -words-file reads them from a file.
|
|
|
|
-note puts a public note in the capsule, in clear: anyone who has the .dkc
|
|
reads it before the date, nobody can check who wrote it, and with the date it
|
|
can identify someone. decrypt shows it as text of the creator.
|
|
|
|
-sign signs the capsule with the author key in the file KEY, made by author
|
|
keygen (spec v0.11, §29.9). A key encrypted with a passphrase needs
|
|
-sign-pass-file: a file with the passphrase, or - for the standard input. The
|
|
passphrase is never taken from the command line or the environment. A
|
|
signature proves that whoever has the secret key signed, not who that is.
|
|
-large-area lets the security area grow from 32 KiB to 64 KiB if a signature does not fit. decrypt always
|
|
shows the signature; with -expect-author it fails, and writes no file, unless
|
|
the capsule is signed with that public key.`
|
|
|
|
// errUsage reports a malformed command line; main prints the usage text.
|
|
var stdin io.Reader = os.Stdin
|
|
|
|
var errUsage = errors.New("invalid command line; run 'datekeys help'")
|
|
|
|
// longHorizon is the product policy threshold for the harvest-now,
|
|
// decrypt-later warning (spec §53).
|
|
const longHorizon = 365 * 24 * time.Hour
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:], os.Stdout, os.Stderr, time.Now); err != nil {
|
|
if errors.Is(err, errUsage) {
|
|
fmt.Fprintln(os.Stderr, usage)
|
|
os.Exit(2)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "datekeys:", err)
|
|
if code := datekeys.Code(err); code != "" {
|
|
fmt.Fprintln(os.Stderr, "datekeys: error code", code)
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
type multi []string
|
|
|
|
func (m *multi) String() string { return strings.Join(*m, ",") }
|
|
func (m *multi) Set(v string) error { *m = append(*m, v); return nil }
|
|
|
|
// run is the CLI; the clock is injected for tests (only the CLI reads the
|
|
// wall clock).
|
|
func run(args []string, stdout, stderr io.Writer, now func() time.Time) error {
|
|
if len(args) == 0 {
|
|
return errUsage
|
|
}
|
|
switch args[0] {
|
|
case "encrypt":
|
|
return encrypt(args[1:], stderr, now)
|
|
case "decrypt":
|
|
return decrypt(args[1:], stdout, stderr, now)
|
|
case "release":
|
|
return saveRelease(args[1:], stderr, now)
|
|
case "author":
|
|
return author(args[1:], stdout, stderr, stdin)
|
|
case "inspect":
|
|
return inspect(args[1:], stdout)
|
|
case "datekey":
|
|
if len(args) < 2 || args[1] != "resolve" {
|
|
return errUsage
|
|
}
|
|
return resolve(args[2:], stdout)
|
|
case "profile":
|
|
if len(args) < 2 || args[1] != "hash" {
|
|
return errUsage
|
|
}
|
|
return profileHash(args[2:], stdout)
|
|
case "version", "-version", "--version":
|
|
if len(args) != 1 {
|
|
return errUsage
|
|
}
|
|
// The module version (a tag, or the pseudo-version of the commit a
|
|
// checkout was built from), the specification it implements and the
|
|
// toolchain.
|
|
fmt.Fprintf(stdout, "datekeys %s\nspecification %s\n%s %s/%s\n", datekeys.Version(), datekeys.SpecVersion, runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
|
return nil
|
|
case "-h", "-help", "--help", "help":
|
|
fmt.Fprintln(stdout, usage)
|
|
return nil
|
|
}
|
|
return errUsage
|
|
}
|
|
|
|
func newFlags(name string) *flag.FlagSet {
|
|
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
return fs
|
|
}
|
|
|
|
func parse(fs *flag.FlagSet, args []string) error {
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("%s: %w", fs.Name(), err)
|
|
}
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("%s: unexpected arguments %q", fs.Name(), fs.Args())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseTime(s string) (time.Time, error) {
|
|
t, err := time.Parse(time.RFC3339Nano, s)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid -at %q: RFC 3339 with a time zone is required", s)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
|
|
fs := newFlags("encrypt")
|
|
at := fs.String("at", "", "unlock time, RFC 3339")
|
|
var ins multi
|
|
fs.Var(&ins, "in", "file or folder to encrypt (repeatable)")
|
|
out := fs.String("out", "", "new .dkc file; never overwritten")
|
|
comment := fs.String("comment", "", "comment for whoever opens the capsule, shown as text of the creator")
|
|
author := fs.String("author", "", "declared author, shown as text of the creator that proves nothing")
|
|
noMTime := fs.Bool("no-mtime", false, "leave out the modification times of the files")
|
|
policy := fs.String("policy", "time_only", "time_only or time_and_key")
|
|
dkk := fs.String("dkk", "", "time_and_key: new .dkk file for a portable access key")
|
|
padding := fs.String("padding", "reforzado", "padding rule of the content: reforzado or bloque256")
|
|
var recipients multi
|
|
fs.Var(&recipients, "recipient", "time_and_key: X25519 recipient age1... (repeatable)")
|
|
words := fs.String("words", "", "time_and_key: at least 6 words that open the capsule; they stay in the shell history")
|
|
wordsFile := fs.String("words-file", "", "time_and_key: file with the words that open the capsule")
|
|
note := fs.String("note", "", "public note of the capsule: one line that anyone with the .dkc reads before the date, and that can identify someone with it")
|
|
sign := fs.String("sign", "", "file with the author key that signs the capsule")
|
|
signPass := fs.String("sign-pass-file", "", "file with the passphrase of the author key, or - for the standard input")
|
|
largeArea := fs.Bool("large-area", false, "let the security area grow to 64 KiB if a signature does not fit in 32 KiB (an author key always fits)")
|
|
if err := parse(fs, args); err != nil {
|
|
return err
|
|
}
|
|
unlock, err := parseTime(*at)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pol, err := capsule.ParsePolicy(*policy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var code capsule.Padding
|
|
switch *padding {
|
|
case capsule.Reforzado.String():
|
|
code = capsule.Reforzado
|
|
case capsule.Bloque256.String():
|
|
code = capsule.Bloque256
|
|
default:
|
|
return fmt.Errorf("encrypt: unknown padding rule %q: reforzado or bloque256", *padding)
|
|
}
|
|
if *out == "" || len(ins) == 0 && *comment == "" {
|
|
return errors.New("encrypt: -out, and -in or -comment, are required")
|
|
}
|
|
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Padding: code,
|
|
Comment: *comment, Author: *author, Now: now}
|
|
for _, r := range recipients {
|
|
x, err := age.ParseX25519Recipient(r)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
opts.Recipients = append(opts.Recipients, x)
|
|
}
|
|
// The key of words is one more credential: the writer derives it once it
|
|
// has drawn capsule_id, which salts it (spec §38.1).
|
|
text, err := wordsText("encrypt", *words, *wordsFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if text != "" {
|
|
if pol != capsule.TimeAndKey {
|
|
return errors.New("encrypt: -words and -words-file need -policy time_and_key")
|
|
}
|
|
w := wordkey.Normalize(text)
|
|
if err := wordkey.Check(w); err != nil {
|
|
return fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
opts.Words = w
|
|
}
|
|
if *dkk != "" {
|
|
if err := checkNew(*dkk); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if *signPass != "" && *sign == "" {
|
|
return errors.New("encrypt: -sign-pass-file needs -sign")
|
|
}
|
|
if *sign != "" {
|
|
k, err := loadAuthorKey("encrypt", *sign, *signPass, stdin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer k.Clear()
|
|
opts.AuthorKey = announced{k, stderr}
|
|
}
|
|
opts.LargeArea = *largeArea
|
|
opts.PublicNote = *note
|
|
if *note != "" {
|
|
fmt.Fprintln(stderr, "warning: the public note is in clear: anyone who has the .dkc reads it before the date, nobody can delete it from the copies that circulate, and with the date it can identify someone (spec §24.1).")
|
|
}
|
|
sources, skipped, err := collect(ins, !*noMTime)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt: %w", err)
|
|
}
|
|
var res *capsule.Result
|
|
err = writeAtomic(*out, func(w io.Writer) error {
|
|
res, err = capsule.EncryptFiles(w, sources, opts)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.PortableKey != nil {
|
|
defer res.PortableKey.Wipe()
|
|
if err := writeAtomic(*dkk, func(w io.Writer) error { return accesskey.Encode(w, res.PortableKey) }); err != nil {
|
|
return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err)
|
|
}
|
|
}
|
|
fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n format %d: %d files, a payload of %d bytes, padded to %d (%s)\n",
|
|
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID, res.Format, len(res.Head.Files), res.Length, res.PaddedLength, res.Padding)
|
|
for _, p := range skipped {
|
|
fmt.Fprintf(stderr, " left out %s, which the system creates on its own\n", p)
|
|
}
|
|
if res.PortableKey != nil {
|
|
fmt.Fprintf(stderr, " access key %s: keep it secret; it is valid for this capsule only\n", *dkk)
|
|
}
|
|
if res.UnlockAt.Sub(now()) > longHorizon {
|
|
fmt.Fprintln(stderr, "warning: Quicknet V1 timelock is not post-quantum. The ciphertext may stay available for years,\n"+
|
|
" and its future confidentiality depends on the provider and on the underlying cryptography (spec §53).")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) error {
|
|
fs := newFlags("decrypt")
|
|
in := fs.String("in", "", ".dkc file")
|
|
out := fs.String("out", "", "new folder (format 3) or file (formats 1 and 2); never overwritten")
|
|
dkk := fs.String("dkk", "", "portable access key (.dkk)")
|
|
timeout := fs.Duration("timeout", 30*time.Second, "release request timeout")
|
|
var identities, relays multi
|
|
fs.Var(&identities, "identity", "age identity file with X25519 keys (repeatable)")
|
|
fs.Var(&relays, "relay", "drand relay base URL (repeatable); default: public relays")
|
|
words := fs.String("words", "", "the words of a key of words; they stay in the shell history")
|
|
wordsFile := fs.String("words-file", "", "file with the words of a key of words")
|
|
expect := fs.String("expect-author", "", "fail unless the capsule is signed with this public key, dkauthor1...")
|
|
release := fs.String("release", "", "the release in hand: a .dkr, drand's JSON or a local release archive; no network request")
|
|
keep := fs.String("save-release", "", "new .dkr file for the release that opened the capsule; never overwritten")
|
|
if err := parse(fs, args); err != nil {
|
|
return err
|
|
}
|
|
if *in == "" || *out == "" {
|
|
return errors.New("decrypt: -in and -out are required")
|
|
}
|
|
if *release != "" && len(relays) > 0 {
|
|
return errors.New("decrypt: -release and -relay are exclusive")
|
|
}
|
|
if *keep != "" {
|
|
if err := checkNew(*keep); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var expected []byte
|
|
if *expect != "" {
|
|
k, err := authorkey.ParsePublic(*expect)
|
|
if err != nil {
|
|
return fmt.Errorf("decrypt: -expect-author: %w", err)
|
|
}
|
|
expected = k
|
|
}
|
|
reg, err := profile.Default()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts := capsule.OpenOptions{Registry: reg, Now: now}
|
|
if *release != "" {
|
|
supplier, closer, err := releaseInHand(*release)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closer.Close()
|
|
opts.Release = supplier
|
|
} else {
|
|
opts.Source = drand.New(relays...)
|
|
}
|
|
if expected != nil {
|
|
// The expected key is not a key that the person saved, with a label
|
|
// she gave it: a signature with it is F4, which shows the whole key. A
|
|
// capsule that is not signed with it is refused before step 18, so
|
|
// that none of its files is ever written.
|
|
opts.Accept = func(v capsule.Verdicts) error {
|
|
if v.Signature == capsule.VerdictSignedOther && bytes.Equal(v.AuthorKey[:], expected) {
|
|
return nil
|
|
}
|
|
writeVerdicts(stdout, v.Lines(), outputWidth(stdout))
|
|
return fmt.Errorf("decrypt: the capsule is not signed with the expected key %s: nothing was written to %s", *expect, *out)
|
|
}
|
|
}
|
|
for _, path := range identities {
|
|
ids, err := readIdentities(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.Identities = append(opts.Identities, ids...)
|
|
}
|
|
if *dkk != "" {
|
|
// Open decodes the .dkk at step 9.a, and only for a time_and_key
|
|
// capsule, so that its errors come in the order of spec §63.
|
|
f, err := os.Open(*dkk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
opts.AccessKeyFile = f
|
|
}
|
|
src, err := os.Open(*in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
text, err := wordsText("decrypt", *words, *wordsFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if text != "" {
|
|
// The words are salted with the chain and the round of the capsule:
|
|
// steps 1 to 8 give them. When they fail, Open reports why.
|
|
if insp, err := capsule.Inspect(src, capsule.InspectOptions{Registry: reg}); err == nil {
|
|
id, err := wordkey.Identity(wordkey.Normalize(text), insp.Profile.ChainHash[:], insp.Header.DateKey.Round, insp.Header.CapsuleID[:])
|
|
if err != nil {
|
|
return fmt.Errorf("decrypt: %w", err)
|
|
}
|
|
opts.Identities = append(opts.Identities, id)
|
|
}
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
|
defer cancel()
|
|
// The format decides the output: a new folder for the files of format 3,
|
|
// a new file for the content of formats 1 and 2. A prelude that does not
|
|
// parse goes the second way, and Open reports it at step 1 or 2.
|
|
var pre [capsule.PreludeSize]byte
|
|
n, _ := src.ReadAt(pre[:], 0)
|
|
var opened *capsule.Opened
|
|
p, perr := capsule.ParsePrelude(pre[:n])
|
|
if *expect != "" && perr == nil && p.Format != capsule.Format3 {
|
|
// Only a capsule of format 3 has an author signature: fail before
|
|
// the release is requested and before anything is written. A prelude
|
|
// that does not parse is left to Open, which reports it at step 1 or
|
|
// 2 with its code.
|
|
return errors.New("decrypt: -expect-author: only a capsule of format 3 has an author signature, and this is not one")
|
|
}
|
|
if perr == nil && p.Format == capsule.Format3 {
|
|
if err := checkNew(*out); err != nil {
|
|
return err
|
|
}
|
|
// The folder is created at step 17, after the release is requested:
|
|
// its parent must be a folder before then.
|
|
if info, err := os.Stat(filepath.Dir(*out)); err != nil || !info.IsDir() {
|
|
return fmt.Errorf("decrypt: %s cannot be created: %s is not a folder", *out, filepath.Dir(*out))
|
|
}
|
|
opts.Sink = &dirSink{dir: *out}
|
|
if opened, err = capsule.Open(ctx, nil, src, opts); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
err = writeAtomic(*out, func(w io.Writer) error {
|
|
opened, err = capsule.Open(ctx, w, src, opts)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Fprintf(stderr, "Decrypted capsule %s (round %d, unlocked at %s); release verified locally\n",
|
|
opened.Inspection.Header.CapsuleIDHex(), opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339))
|
|
if opened.ClockBehind {
|
|
fmt.Fprintf(stderr, "warning: the release proves that round %d was published at %s, and this clock says %s: it may be behind\n",
|
|
opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339), now().UTC().Format(time.RFC3339))
|
|
}
|
|
if *keep != "" {
|
|
if err := writeRelease(*keep, opened.Release); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(stderr, " release saved to %s: keep it next to the capsule\n", *keep)
|
|
}
|
|
if opened.Format == capsule.Format3 {
|
|
fmt.Fprintf(stderr, " format 3, %d files\n", len(opened.Head.Files))
|
|
if len(opened.Head.Files) == 0 {
|
|
fmt.Fprintf(stderr, " no files: %s was not created\n", *out)
|
|
}
|
|
present(stdout, opened, *out, outputWidth(stdout))
|
|
return nil
|
|
}
|
|
fmt.Fprintf(stderr, " format %d, %d bytes of content\n", opened.Format, opened.PayloadLength)
|
|
if *expect != "" {
|
|
return fmt.Errorf("decrypt: -expect-author: a capsule of format %d has no author signature", opened.Format)
|
|
}
|
|
if opened.Format == capsule.Format1 {
|
|
// Spec §55.2, §70: format 1 hides neither the number of credentials
|
|
// nor the exact length of the content.
|
|
fmt.Fprintln(stderr, " format 1 does not hide the number of credentials or the exact length of the content")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// releaseInHand reads the release that the person has in hand, from path: a
|
|
// local release archive, which is read when Open asks for the round, or a
|
|
// .dkr file or drand's JSON, which Open decodes at step 10. A file larger
|
|
// than any of them is cut where it can no longer be valid, so that step 10
|
|
// rejects it with the code of its size.
|
|
func releaseInHand(path string) (provider.Supplier, io.Closer, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, nil, err
|
|
}
|
|
head := make([]byte, min(info.Size(), 64))
|
|
if _, err := io.ReadFull(f, head); err != nil {
|
|
f.Close()
|
|
return nil, nil, err
|
|
}
|
|
if provider.IsArchive(head) {
|
|
return provider.NewArchive(f, info.Size()), f, nil
|
|
}
|
|
defer f.Close()
|
|
rest, err := io.ReadAll(io.LimitReader(f, provider.MaxReleaseJSONSize+1-int64(len(head))))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return provider.Encoded(append(head, rest...)), io.NopCloser(nil), nil
|
|
}
|
|
|
|
// writeRelease writes the release object of r, a verified release, to the
|
|
// new file path (spec v0.15, §47.1).
|
|
func writeRelease(path string, r provider.Release) error {
|
|
b, err := provider.EncodeRelease(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeAtomic(path, func(w io.Writer) error {
|
|
_, err := w.Write(b)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// saveRelease fetches the release of the round of a capsule from drand
|
|
// relays, verifies it and saves it as a new .dkr file, without opening the
|
|
// capsule (spec v0.15, §62.1). Steps 1 to 8 come first, and no request is
|
|
// made before the round time.
|
|
func saveRelease(args []string, stderr io.Writer, now func() time.Time) error {
|
|
fs := newFlags("release")
|
|
in := fs.String("in", "", ".dkc file")
|
|
out := fs.String("out", "", "new .dkr file; never overwritten")
|
|
timeout := fs.Duration("timeout", 30*time.Second, "release request timeout")
|
|
var relays multi
|
|
fs.Var(&relays, "relay", "drand relay base URL (repeatable); default: public relays")
|
|
if err := parse(fs, args); err != nil {
|
|
return err
|
|
}
|
|
if *in == "" || *out == "" {
|
|
return errors.New("release: -in and -out are required")
|
|
}
|
|
if err := checkNew(*out); err != nil {
|
|
return err
|
|
}
|
|
reg, err := profile.Default()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
src, err := os.Open(*in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
insp, err := capsule.Inspect(src, capsule.InspectOptions{Registry: reg})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cond := provider.Condition{Round: insp.Header.DateKey.Round}
|
|
if t := now(); t.Before(insp.UnlockAt) {
|
|
return fmt.Errorf("release: round %d is published at %s, it is %s: %w", cond.Round,
|
|
insp.UnlockAt.Format(time.RFC3339), t.UTC().Format(time.RFC3339), datekeys.ErrReleaseUnavailable)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
|
defer cancel()
|
|
// The relays verify each answer and discard the invalid ones; it is
|
|
// verified again here, as Open does at step 10.
|
|
r, err := drand.New(relays...).Fetch(ctx, insp.Profile, cond)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := provider.Verify(insp.Profile, cond, r); err != nil {
|
|
return err
|
|
}
|
|
r.ChainHash = insp.Profile.ChainHash[:]
|
|
if err := writeRelease(*out, r); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(stderr, "Saved the release of round %d, verified locally, to %s: keep it next to %s\n", cond.Round, *out, *in)
|
|
return nil
|
|
}
|
|
|
|
// wordsText is the text of the words of -words or of -words-file, at most
|
|
// 4 KiB, or "" when neither is given.
|
|
func wordsText(cmd, words, file string) (string, error) {
|
|
if words != "" && file != "" {
|
|
return "", fmt.Errorf("%s: -words and -words-file are exclusive", cmd)
|
|
}
|
|
if file == "" {
|
|
return words, nil
|
|
}
|
|
f, err := os.Open(file)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
b, err := io.ReadAll(io.LimitReader(f, 4<<10+1))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(b) > 4<<10 {
|
|
return "", fmt.Errorf("%s: %s is longer than 4 KiB: it is not a list of words", cmd, file)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func readIdentities(path string) ([]age.Identity, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
ids, err := age.ParseIdentities(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// inspect runs steps 1 to 8 only: it never requests a release and never uses
|
|
// a secret.
|
|
func inspect(args []string, stdout io.Writer) error {
|
|
fs := newFlags("inspect")
|
|
in := fs.String("in", "", ".dkc file")
|
|
asJSON := fs.Bool("json", false, "JSON output")
|
|
if err := parse(fs, args); err != nil {
|
|
return err
|
|
}
|
|
if *in == "" {
|
|
return errors.New("inspect: -in is required")
|
|
}
|
|
reg, err := profile.Default()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.Open(*in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
result, inspectErr := capsule.Inspect(f, capsule.InspectOptions{Registry: reg})
|
|
v := inspectview.New(*in, result, inspectErr)
|
|
if *asJSON {
|
|
if err := v.WriteJSON(stdout); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
v.WriteText(stdout)
|
|
showNote(stdout, result)
|
|
}
|
|
return inspectErr
|
|
}
|
|
|
|
type resolveView struct {
|
|
DateKey string `json:"datekey"`
|
|
Profile string `json:"profile"`
|
|
Round uint64 `json:"round"`
|
|
Requested string `json:"requested"`
|
|
UnlockAt string `json:"unlock_at"`
|
|
}
|
|
|
|
func resolve(args []string, stdout io.Writer) error {
|
|
fs := newFlags("datekey resolve")
|
|
at := fs.String("at", "", "instant, RFC 3339")
|
|
if err := parse(fs, args); err != nil {
|
|
return err
|
|
}
|
|
t, err := parseTime(*at)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p := profile.Quicknet()
|
|
d, err := datekey.Resolve(p, t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.NewEncoder(stdout).Encode(resolveView{
|
|
DateKey: d.Compact(), Profile: d.ProfileID, Round: d.Round,
|
|
Requested: t.Format(time.RFC3339Nano), UnlockAt: d.UnlockAt(p).Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func profileHash(args []string, stdout io.Writer) error {
|
|
fs := newFlags("profile hash")
|
|
in := fs.String("in", "", "Deterministic CBOR profile file; default: the pinned Quicknet profile")
|
|
if err := parse(fs, args); err != nil {
|
|
return err
|
|
}
|
|
p := profile.Quicknet()
|
|
if *in != "" {
|
|
b, err := os.ReadFile(*in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p, err = profile.Decode(b); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
b, err := p.CanonicalCBOR()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h, err := p.Hash()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pinned := *in == "" || hex.EncodeToString(h[:]) == profile.QuicknetProfileHash
|
|
return json.NewEncoder(stdout).Encode(map[string]any{
|
|
"profile_id": p.ID,
|
|
"profile_hash": hex.EncodeToString(h[:]),
|
|
"canonical_cbor": hex.EncodeToString(b),
|
|
"pinned": pinned,
|
|
})
|
|
}
|