Signature plan, step 5: author keygen and public, encrypt -sign, decrypt -expect-author

The passphrase of a key comes from a file, or from the standard input with
"-", never from the command line or the environment, so the CLI needs no
terminal library. decrypt -expect-author shows the signature as always and
then fails, with the files already written, unless it is F3 with that key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
v0.11
dev 6 days ago
parent c3175a150a
commit d1441deb83

@ -0,0 +1,152 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"strings"
"g.activething.com/go/DateKeys/authorkey"
)
// maxPassFile bounds the file of a passphrase.
const maxPassFile = 4 << 10
// readPass returns the passphrase in file, one line without its line ending,
// or in the standard input when file is "-". The CLI takes no passphrase on
// the command line, where the shell history keeps it, nor from the
// environment, where other processes can read it.
func readPass(cmd, file string, stdin io.Reader) (string, error) {
var r io.Reader = stdin
if file != "-" {
f, err := os.Open(file)
if err != nil {
return "", err
}
defer f.Close()
r = f
}
b, err := io.ReadAll(io.LimitReader(r, maxPassFile+1))
defer clear(b)
if err != nil {
return "", err
}
if len(b) > maxPassFile {
return "", fmt.Errorf("%s: the passphrase file is longer than %d bytes", cmd, maxPassFile)
}
s := strings.TrimSuffix(strings.TrimSuffix(string(b), "\n"), "\r")
if s == "" {
return "", fmt.Errorf("%s: the passphrase is empty", cmd)
}
return s, nil
}
// loadAuthorKey reads the key file path. An encrypted one needs passFile.
func loadAuthorKey(cmd, path, passFile string, stdin io.Reader) (*authorkey.Key, error) {
var pass string
if passFile != "" {
var err error
if pass, err = readPass(cmd, passFile, stdin); err != nil {
return nil, err
}
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
k, err := authorkey.Read(f, pass)
if err != nil {
if pass == "" && strings.Contains(err.Error(), "passphrase") {
return nil, fmt.Errorf("%s: %s is encrypted: give its passphrase with -pass-file FILE, or - for the standard input", cmd, path)
}
return nil, fmt.Errorf("%s: %s: %w", cmd, path, err)
}
return k, nil
}
// author runs "datekeys author keygen|public" (spec v0.11, §29.12).
func author(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "keygen":
return authorKeygen(args[1:], stdout, stderr, stdin)
case "public":
return authorPublic(args[1:], stdout, stdin)
}
return errUsage
}
func authorKeygen(args []string, stdout, stderr io.Writer, stdin io.Reader) error {
fs := newFlags("author keygen")
out := fs.String("out", "", "new file for the secret key; never overwritten")
passFile := fs.String("pass-file", "", "file with the passphrase that encrypts the key, or - for the standard input")
plain := fs.Bool("plain", false, "write the key without encryption, as text anyone who reads the file can use")
if err := parse(fs, args); err != nil {
return err
}
switch {
case *out == "":
return errors.New("author keygen: -out is required")
case *plain == (*passFile != ""):
return errors.New("author keygen: give -pass-file, to encrypt the key, or -plain, to write it as text, and not both")
}
if err := checkNew(*out); err != nil {
return err
}
var pass string
if !*plain {
var err error
if pass, err = readPass("author keygen", *passFile, stdin); err != nil {
return err
}
}
k, err := authorkey.Generate()
if err != nil {
return err
}
defer k.Clear()
err = writeAtomic(*out, func(w io.Writer) error {
if *plain {
_, err := w.Write(authorkey.Marshal(k))
return err
}
return authorkey.Encrypt(w, k, pass)
})
if err != nil {
return err
}
pub, err := authorkey.PublicString(k.Public())
if err != nil {
return err
}
fmt.Fprintln(stdout, pub)
fmt.Fprintf(stderr, "Secret key written to %s: keep it, and its passphrase, secret. The line above is the public key: give it to whoever must know your signature.\n", *out)
return nil
}
func authorPublic(args []string, stdout io.Writer, stdin io.Reader) error {
fs := newFlags("author public")
key := fs.String("key", "", "file with the secret key")
passFile := fs.String("pass-file", "", "file with the passphrase of an encrypted key, or - for the standard input")
if err := parse(fs, args); err != nil {
return err
}
if *key == "" {
return errors.New("author public: -key is required")
}
k, err := loadAuthorKey("author public", *key, *passFile, stdin)
if err != nil {
return err
}
defer k.Clear()
pub, err := authorkey.PublicString(k.Public())
if err != nil {
return err
}
fmt.Fprintln(stdout, pub)
return nil
}

@ -0,0 +1,97 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"g.activething.com/go/DateKeys/profile"
)
// Spec v0.11, §29.9, §29.12: author keygen, encrypt -sign and decrypt
// -expect-author, with the passphrase in a file and in the standard input.
func TestAuthorSignRoundTrip(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("firmada"), 0o600)
pass := filepath.Join(dir, "pass.txt")
os.WriteFile(pass, []byte("una contraseña larga\r\n"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
genesis := time.Unix(p.GenesisTime, 0)
keyFile := filepath.Join(dir, "autor.key")
pub, stderr, err := cli(t, genesis, "author", "keygen", "-out", keyFile, "-pass-file", pass)
if err != nil || !strings.HasPrefix(pub, "dkauthor1") || !strings.Contains(stderr, "keep it") {
t.Fatalf("keygen: %q %v %s", pub, err, stderr)
}
pub = strings.TrimSpace(pub)
if b, _ := os.ReadFile(keyFile); !strings.HasPrefix(string(b), "age-encryption.org/v1") {
t.Error("the key file is not encrypted")
}
if _, _, err := cli(t, genesis, "author", "keygen", "-out", keyFile, "-pass-file", pass); err == nil {
t.Error("overwrote a key")
}
if _, _, err := cli(t, genesis, "author", "keygen", "-out", filepath.Join(dir, "x.key")); err == nil {
t.Error("wrote a key without a passphrase and without -plain")
}
if got, _, err := cli(t, genesis, "author", "public", "-key", keyFile, "-pass-file", pass); err != nil || strings.TrimSpace(got) != pub {
t.Errorf("public: %q %v", got, err)
}
if _, _, err := cli(t, genesis, "author", "public", "-key", keyFile); err == nil || !strings.Contains(err.Error(), "-pass-file") {
t.Errorf("public of an encrypted key without its passphrase: %v", err)
}
other := filepath.Join(dir, "otra.key")
otherPub, _, err := cli(t, genesis, "author", "keygen", "-out", other, "-plain")
if err != nil {
t.Fatal(err)
}
otherPub = strings.TrimSpace(otherPub)
dkc := filepath.Join(dir, "c.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc, "-sign", keyFile, "-sign-pass-file", pass); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
// The passphrase from the standard input.
stdin = strings.NewReader("una contraseña larga\n")
t.Cleanup(func() { stdin = os.Stdin })
dkc2 := filepath.Join(dir, "c2.dkc")
if _, stderr, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", dkc2, "-sign", keyFile, "-sign-pass-file", "-", "-large-area"); err != nil {
t.Fatalf("%v\n%s", err, stderr)
}
for i, tc := range []struct {
file, expect, want string
fails bool
}{
{dkc, "", "Firmado con la clave " + pub, false},
{dkc, pub, "Firmado con la clave que guardaste como -expect-author.", false},
{dkc2, pub, "Firmado con la clave que guardaste como -expect-author.", false},
{dkc, otherPub, "Firmado con la clave " + pub, true},
} {
args := []string{"decrypt", "-in", tc.file, "-out", filepath.Join(dir, "out"+string(rune('a'+i))), "-relay", relay(t)}
if tc.expect != "" {
args = append(args, "-expect-author", tc.expect)
}
stdout, _, err := cli(t, later, args...)
if (err != nil) != tc.fails || !strings.Contains(stdout, tc.want) {
t.Errorf("case %d: %v\n%s", i, err, stdout)
}
if tc.fails && (err == nil || !strings.Contains(err.Error(), "not signed with the expected key")) {
t.Errorf("case %d: %v", i, err)
}
}
// An unsigned capsule does not meet -expect-author.
plain := filepath.Join(dir, "plain.dkc")
if _, _, err := cli(t, genesis, "encrypt", "-at", unlock.Format(time.RFC3339), "-in", in, "-out", plain); err != nil {
t.Fatal(err)
}
if _, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outz"), "-relay", relay(t), "-expect-author", pub); err == nil {
t.Error("an unsigned capsule met -expect-author")
}
if _, _, err := cli(t, later, "decrypt", "-in", plain, "-out", filepath.Join(dir, "outy"), "-relay", relay(t), "-expect-author", "dkauthor1x"); err == nil {
t.Error("a malformed -expect-author was accepted")
}
}

@ -4,6 +4,8 @@
// 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
@ -35,6 +37,7 @@ import (
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"
@ -44,8 +47,10 @@ import (
)
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]
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-relay URL]...
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] [-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]
@ -63,9 +68,20 @@ new folder PATH, and the content of formats 1 and 2 to the new file PATH.
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.`
in the shell history; -words-file reads them from a file.
-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 widens the security area from 32 KiB to 64 KiB. 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,
@ -102,6 +118,8 @@ func run(args []string, stdout, stderr io.Writer, now func() time.Time) error {
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":
@ -170,6 +188,9 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
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")
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, "widen the security area to 64 KiB, for signatures that do not fit in 32 KiB")
if err := parse(fs, args); err != nil {
return err
}
@ -223,6 +244,18 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
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
sources, skipped, err := collect(ins, !*noMTime)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
@ -267,17 +300,26 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
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 {
@ -356,9 +398,15 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
fmt.Fprintf(stderr, " no files: %s was not created\n", *out)
}
present(stdout, opened, *out, outputWidth(stdout))
if *expect != "" && opened.Verdicts.Signature != capsule.VerdictSignedSaved {
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.

Loading…
Cancel
Save

Powered by TurnKey Linux.