// 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 decrypt -in regalo.dkc -out regalo [-dkk key.dkk] [-identity key.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/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" ) 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] [-padding reforzado|bloque256] datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-relay URL]... 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.` // errUsage reports a malformed command line; main prints the usage text. 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 "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)") 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) } if *dkk != "" { if err := checkNew(*dkk); err != nil { return err } } 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") if err := parse(fs, args); err != nil { return err } if *in == "" || *out == "" { return errors.New("decrypt: -in and -out are required") } reg, err := profile.Default() if err != nil { return err } opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now} 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() 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 if p, perr := capsule.ParsePrelude(pre[:n]); 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)) return nil } fmt.Fprintf(stderr, " format %d, %d bytes of content\n", opened.Format, opened.PayloadLength) 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 } 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) } 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, }) }