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

392 lines
12 KiB

// Command datekeys encrypts, inspects and opens DateKeyCap (.dkc) files.
//
// datekeys encrypt -at 2030-01-01T00:00:00Z -in secret.txt -out secret.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 secret.dkc
// datekeys decrypt -in secret.dkc -out secret.txt [-dkk key.dkk] [-identity key.txt]
// datekeys datekey resolve -at 2030-01-01T00:00:00Z
// datekeys profile hash
//
// 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.
package main
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"filippo.io/age"
datekeys "github.com/datekeys/datekeys-go"
"github.com/datekeys/datekeys-go/accesskey"
"github.com/datekeys/datekeys-go/capsule"
"github.com/datekeys/datekeys-go/datekey"
"github.com/datekeys/datekeys-go/profile"
"github.com/datekeys/datekeys-go/provider/drand"
)
const usage = `usage:
datekeys encrypt -at TIME -in FILE -out FILE.dkc [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk]
datekeys decrypt -in FILE.dkc -out FILE [-dkk FILE.dkk] [-identity FILE]... [-relay URL]...
datekeys inspect -in FILE.dkc [-json]
datekeys datekey resolve -at TIME
datekeys profile hash [-in PROFILE.cbor]
TIME is RFC 3339 with a time zone, for example 2030-01-01T00:00:00Z.`
// 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:], 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 "-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")
in := fs.String("in", "", "plaintext file")
out := fs.String("out", "", "new .dkc file; never overwritten")
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")
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
}
if *in == "" || *out == "" {
return errors.New("encrypt: -in and -out are required")
}
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", 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
}
}
src, err := os.Open(*in)
if err != nil {
return err
}
defer src.Close()
var res *capsule.Result
err = writeAtomic(*out, func(w io.Writer) error {
res, err = capsule.Encrypt(w, src, 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",
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID)
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, stderr io.Writer, now func() time.Time) error {
fs := newFlags("decrypt")
in := fs.String("in", "", ".dkc file")
out := fs.String("out", "", "new plaintext file; 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 != "" {
f, err := os.Open(*dkk)
if err != nil {
return err
}
k, err := accesskey.Decode(f)
f.Close()
if err != nil {
return err
}
defer k.Wipe()
opts.AccessKey = k
}
src, err := os.Open(*in)
if err != nil {
return err
}
defer src.Close()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
var opened *capsule.Opened
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))
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
}
type inspectView struct {
File string `json:"file"`
CapsuleID string `json:"capsule_id,omitempty"`
DateKey string `json:"datekey,omitempty"`
Profile string `json:"profile,omitempty"`
Round uint64 `json:"round,omitempty"`
UnlockAt string `json:"unlock_at,omitempty"`
AccessPolicy string `json:"access_policy,omitempty"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Checks []capsule.CheckResult `json:"checks"`
}
// 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{File: *in, Valid: inspectErr == nil, Error: datekeys.Code(inspectErr), Checks: result.Checks}
if h := result.Header; h != nil {
v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String()
}
if !result.UnlockAt.IsZero() {
v.UnlockAt = result.UnlockAt.Format(time.RFC3339)
}
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return err
}
} else {
fmt.Fprintf(stdout, "%s\n", v.File)
for _, c := range v.Checks {
mark := "ok "
if !c.OK {
mark = "FAIL"
}
fmt.Fprintf(stdout, " [%s] step %2d %-26s %s\n", mark, c.Step, c.Name, c.Detail)
}
if v.Valid {
fmt.Fprintf(stdout, " valid before unlock; opens at %s (round %d, %s)\n", v.UnlockAt, v.Round, v.AccessPolicy)
}
}
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.