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.
DateKeys/cmd/datekeys/main.go

568 lines
20 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 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 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 (
"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/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]...
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.
-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 also fails, once the files are
written, 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 "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 = k
}
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, %d bytes of content, 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...")
if err := parse(fs, args); err != nil {
return err
}
if *in == "" || *out == "" {
return errors.New("decrypt: -in and -out are required")
}
if *expect != "" {
if _, err := authorkey.ParsePublic(*expect); err != nil {
return fmt.Errorf("decrypt: -expect-author: %w", err)
}
}
reg, err := profile.Default()
if err != nil {
return err
}
opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now}
if *expect != "" {
opts.AuthorKeys = map[string]string{*expect: "-expect-author"}
}
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.
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.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))
if *expect != "" && opened.Verdicts.Signature != capsule.VerdictSignedSaved {
if len(opened.Head.Files) == 0 {
return fmt.Errorf("decrypt: the capsule is not signed with the expected key %s: do not trust it as that author's", *expect)
}
return fmt.Errorf("decrypt: the capsule is not signed with the expected key %s: its files were written to %s, but do not trust them as that author's", *expect, *out)
}
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
}
// 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,
})
}

Powered by TurnKey Linux.