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/authorkey/authorkey.go

237 lines
7.5 KiB

// Package authorkey handles the keys of the author signature of alg 1 (spec
// v0.11, §29.12): an Ed25519 seed of 32 bytes, written in Bech32 as
// DKAUTHOR-SECRET-KEY-1…, 79 characters in upper case, whose public key A is
// written dkauthor1…, 67 characters in lower case. A file of a secret key
// holds that line and, by default, is encrypted with age and a passphrase,
// scrypt with a work factor of WorkFactor.
//
// A signature of alg 1 proves that someone with the secret key signed, not
// who holds it: whoever opens a capsule knows a public key only through
// another channel (§29.12).
package authorkey
import (
"bufio"
"bytes"
"crypto/ed25519"
"errors"
"fmt"
"io"
"strings"
"filippo.io/age"
"g.activething.com/go/DateKeys/codec/bech32"
"g.activething.com/go/DateKeys/internal/ed25519strict"
)
const (
// PublicPrefix is the Bech32 prefix of a public key, in lower case.
PublicPrefix = "dkauthor"
// SecretPrefix is the Bech32 prefix of a secret key, in upper case.
SecretPrefix = "DKAUTHOR-SECRET-KEY-"
// PublicLength and SecretLength are the lengths of their Bech32 strings.
PublicLength = 67
SecretLength = 79
// WorkFactor is the scrypt work factor, logN, of an encrypted key file:
// 64 MiB, which a phone can afford, where age's 18 would take 256 MiB.
WorkFactor = 16
// maxFile bounds a key file.
maxFile = 64 << 10
)
// Key is a secret key of an author.
type Key struct {
priv ed25519.PrivateKey
}
// Generate returns a new key from the CSPRNG.
func Generate() (*Key, error) {
_, priv, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, err
}
return &Key{priv}, nil
}
// NewFromSeed returns the key of a seed of 32 bytes.
func NewFromSeed(seed []byte) (*Key, error) {
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("authorkey: a seed has %d bytes, not %d", len(seed), ed25519.SeedSize)
}
return &Key{ed25519.NewKeyFromSeed(seed)}, nil
}
// Public returns the public key A, 32 bytes.
func (k *Key) Public() []byte { return bytes.Clone(k.priv[32:]) }
// Sign returns the Ed25519 signature of msg, 64 bytes.
func (k *Key) Sign(msg []byte) []byte { return ed25519.Sign(k.priv, msg) }
// Clear wipes the key; it cannot sign afterwards.
func (k *Key) Clear() { clear(k.priv) }
Review fixes: author keys, the writer, the CLI, extensions and the locator Fixes of the review of the session of 1 and 2 October that the text of spec v0.11 already asks for: - authorkey: String and GoString hide the secret key, which only Secret returns; ParsePublic refuses a key that is not a point of the curve (ed25519strict.OnCurve, checked against the square root of testkit). - capsule: a typed nil in AuthorKey, CMSSigner or Sealer is an error, never a capsule without the signature or the seal that was asked for. A panic while evaluating the signature or the seal fails only that part, F1 or S2, not both. OpenOptions.Accept sees the verdicts before step 18 and can refuse to publish the files. - extension.CheckWrite, the rule of encoders of spec 72: the writers of capsules and .dkk files refuse datekeys.note and datekeys.capsule outside the arrays where they are registered, or with invalid data. - CLI: encrypt -sign shows the author key and the code of AUTHOR_MESSAGE before it signs (rule 20); decrypt -expect-author compares the key of an F4 and writes nothing unless it matches; decrypt notifies a public note that it does not show; the lines of the verdicts break at the last space that fits, each row after the first behind a mark, so that the terminal never breaks them; L is the payload, not the content. - locator: a reader rejects an address that breaks 44.1 and keeps the others; addresses refuse the special-purpose blocks of IANA, IPv6 outside 2000::/3, localhost and local names, characters outside RFC 3986, dot segments, and a CID that does not decode to version 1 and a multihash; ParseInfo checks that the locator is an age file with one tlock stanza for the round of its DateKey; Info.Extension reads what it writes; its errors carry no normative code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 days ago
// Secret returns the secret key, DKAUTHOR-SECRET-KEY-1…. Only a key file
// should ever hold it.
func (k *Key) Secret() string {
s, err := bech32.Encode(SecretPrefix, k.priv.Seed())
if err != nil {
panic(err) // the prefix and the length are fixed
}
return s
}
Review fixes: author keys, the writer, the CLI, extensions and the locator Fixes of the review of the session of 1 and 2 October that the text of spec v0.11 already asks for: - authorkey: String and GoString hide the secret key, which only Secret returns; ParsePublic refuses a key that is not a point of the curve (ed25519strict.OnCurve, checked against the square root of testkit). - capsule: a typed nil in AuthorKey, CMSSigner or Sealer is an error, never a capsule without the signature or the seal that was asked for. A panic while evaluating the signature or the seal fails only that part, F1 or S2, not both. OpenOptions.Accept sees the verdicts before step 18 and can refuse to publish the files. - extension.CheckWrite, the rule of encoders of spec 72: the writers of capsules and .dkk files refuse datekeys.note and datekeys.capsule outside the arrays where they are registered, or with invalid data. - CLI: encrypt -sign shows the author key and the code of AUTHOR_MESSAGE before it signs (rule 20); decrypt -expect-author compares the key of an F4 and writes nothing unless it matches; decrypt notifies a public note that it does not show; the lines of the verdicts break at the last space that fits, each row after the first behind a mark, so that the terminal never breaks them; L is the payload, not the content. - locator: a reader rejects an address that breaks 44.1 and keeps the others; addresses refuse the special-purpose blocks of IANA, IPv6 outside 2000::/3, localhost and local names, characters outside RFC 3986, dot segments, and a CID that does not decode to version 1 and a multihash; ParseInfo checks that the locator is an age file with one tlock stanza for the round of its DateKey; Info.Extension reads what it writes; its errors carry no normative code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 days ago
// String hides the secret key, so that a %v in a log or in an error never
// prints it; Secret returns it.
func (k *Key) String() string { return SecretPrefix + "1… (hidden)" }
// GoString hides the secret key for %#v, as String does for %v.
func (k *Key) GoString() string { return k.String() }
// PublicString returns the public key pub as dkauthor1….
func PublicString(pub []byte) (string, error) {
if len(pub) != ed25519.PublicKeySize {
return "", fmt.Errorf("authorkey: a public key has %d bytes, not %d", ed25519.PublicKeySize, len(pub))
}
return bech32.Encode(PublicPrefix, pub)
}
// ParsePublic returns the public key of a string dkauthor1…: lower case, of
// PublicLength characters, with the right prefix and padding, and a key that
Review fixes: author keys, the writer, the CLI, extensions and the locator Fixes of the review of the session of 1 and 2 October that the text of spec v0.11 already asks for: - authorkey: String and GoString hide the secret key, which only Secret returns; ParsePublic refuses a key that is not a point of the curve (ed25519strict.OnCurve, checked against the square root of testkit). - capsule: a typed nil in AuthorKey, CMSSigner or Sealer is an error, never a capsule without the signature or the seal that was asked for. A panic while evaluating the signature or the seal fails only that part, F1 or S2, not both. OpenOptions.Accept sees the verdicts before step 18 and can refuse to publish the files. - extension.CheckWrite, the rule of encoders of spec 72: the writers of capsules and .dkk files refuse datekeys.note and datekeys.capsule outside the arrays where they are registered, or with invalid data. - CLI: encrypt -sign shows the author key and the code of AUTHOR_MESSAGE before it signs (rule 20); decrypt -expect-author compares the key of an F4 and writes nothing unless it matches; decrypt notifies a public note that it does not show; the lines of the verdicts break at the last space that fits, each row after the first behind a mark, so that the terminal never breaks them; L is the payload, not the content. - locator: a reader rejects an address that breaks 44.1 and keeps the others; addresses refuse the special-purpose blocks of IANA, IPv6 outside 2000::/3, localhost and local names, characters outside RFC 3986, dot segments, and a CID that does not decode to version 1 and a multihash; ParseInfo checks that the locator is an age file with one tlock stanza for the round of its DateKey; Info.Extension reads what it writes; its errors carry no normative code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 days ago
// the strict profile could accept: canonical, a point of the curve and not of
// small order.
func ParsePublic(s string) ([]byte, error) {
if len(s) != PublicLength {
return nil, fmt.Errorf("authorkey: a public key has %d characters, not %d", PublicLength, len(s))
}
if strings.ToLower(s) != s {
return nil, errors.New("authorkey: a public key is written in lower case")
}
hrp, data, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("authorkey: %w", err)
}
if hrp != PublicPrefix || len(data) != ed25519.PublicKeySize {
return nil, fmt.Errorf("authorkey: %q is not a public key %s1…", s, PublicPrefix)
}
Review fixes: author keys, the writer, the CLI, extensions and the locator Fixes of the review of the session of 1 and 2 October that the text of spec v0.11 already asks for: - authorkey: String and GoString hide the secret key, which only Secret returns; ParsePublic refuses a key that is not a point of the curve (ed25519strict.OnCurve, checked against the square root of testkit). - capsule: a typed nil in AuthorKey, CMSSigner or Sealer is an error, never a capsule without the signature or the seal that was asked for. A panic while evaluating the signature or the seal fails only that part, F1 or S2, not both. OpenOptions.Accept sees the verdicts before step 18 and can refuse to publish the files. - extension.CheckWrite, the rule of encoders of spec 72: the writers of capsules and .dkk files refuse datekeys.note and datekeys.capsule outside the arrays where they are registered, or with invalid data. - CLI: encrypt -sign shows the author key and the code of AUTHOR_MESSAGE before it signs (rule 20); decrypt -expect-author compares the key of an F4 and writes nothing unless it matches; decrypt notifies a public note that it does not show; the lines of the verdicts break at the last space that fits, each row after the first behind a mark, so that the terminal never breaks them; L is the payload, not the content. - locator: a reader rejects an address that breaks 44.1 and keeps the others; addresses refuse the special-purpose blocks of IANA, IPv6 outside 2000::/3, localhost and local names, characters outside RFC 3986, dot segments, and a CID that does not decode to version 1 and a multihash; ParseInfo checks that the locator is an age file with one tlock stanza for the round of its DateKey; Info.Extension reads what it writes; its errors carry no normative code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 days ago
if !ed25519strict.Canonical(data) || !ed25519strict.OnCurve(data) || ed25519strict.SmallOrder(data) {
return nil, errors.New("authorkey: the public key is not canonical, not a point of the curve or of small order: no signature would verify")
}
return data, nil
}
// ParseSecret returns the key of a string DKAUTHOR-SECRET-KEY-1…: upper case,
// of SecretLength characters, with the right prefix and padding.
func ParseSecret(s string) (*Key, error) {
if len(s) != SecretLength {
return nil, fmt.Errorf("authorkey: a secret key has %d characters, not %d", SecretLength, len(s))
}
if strings.ToUpper(s) != s {
return nil, errors.New("authorkey: a secret key is written in upper case")
}
hrp, data, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("authorkey: %w", err)
}
defer clear(data)
if hrp != SecretPrefix || len(data) != ed25519.SeedSize {
return nil, fmt.Errorf("authorkey: not a secret key %s1…", SecretPrefix)
}
return NewFromSeed(data)
}
// Marshal returns the file of a key without encryption: a comment with the
// public key and the line of the secret key.
func Marshal(k *Key) []byte {
pub, _ := PublicString(k.Public())
Review fixes: author keys, the writer, the CLI, extensions and the locator Fixes of the review of the session of 1 and 2 October that the text of spec v0.11 already asks for: - authorkey: String and GoString hide the secret key, which only Secret returns; ParsePublic refuses a key that is not a point of the curve (ed25519strict.OnCurve, checked against the square root of testkit). - capsule: a typed nil in AuthorKey, CMSSigner or Sealer is an error, never a capsule without the signature or the seal that was asked for. A panic while evaluating the signature or the seal fails only that part, F1 or S2, not both. OpenOptions.Accept sees the verdicts before step 18 and can refuse to publish the files. - extension.CheckWrite, the rule of encoders of spec 72: the writers of capsules and .dkk files refuse datekeys.note and datekeys.capsule outside the arrays where they are registered, or with invalid data. - CLI: encrypt -sign shows the author key and the code of AUTHOR_MESSAGE before it signs (rule 20); decrypt -expect-author compares the key of an F4 and writes nothing unless it matches; decrypt notifies a public note that it does not show; the lines of the verdicts break at the last space that fits, each row after the first behind a mark, so that the terminal never breaks them; L is the payload, not the content. - locator: a reader rejects an address that breaks 44.1 and keeps the others; addresses refuse the special-purpose blocks of IANA, IPv6 outside 2000::/3, localhost and local names, characters outside RFC 3986, dot segments, and a CID that does not decode to version 1 and a multihash; ParseInfo checks that the locator is an age file with one tlock stanza for the round of its DateKey; Info.Extension reads what it writes; its errors carry no normative code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 days ago
return []byte("# public key: " + pub + "\n" + k.Secret() + "\n")
}
// Encrypt writes the file of a key encrypted with age and passphrase,
// scrypt with a work factor of WorkFactor.
func Encrypt(w io.Writer, k *Key, passphrase string) error {
if passphrase == "" {
return errors.New("authorkey: an empty passphrase")
}
r, err := age.NewScryptRecipient(passphrase)
if err != nil {
return err
}
r.SetWorkFactor(WorkFactor)
plain := Marshal(k)
defer clear(plain)
aw, err := age.Encrypt(w, r)
if err != nil {
return err
}
if _, err := aw.Write(plain); err != nil {
return err
}
return aw.Close()
}
// Read returns the key of a file: encrypted with age and a passphrase, as
// Encrypt writes it, or plain, as Marshal does, with one line of a secret key
// and, besides it, only empty lines and comments that start with '#'. The
// passphrase is needed only for an encrypted file.
func Read(r io.Reader, passphrase string) (*Key, error) {
b, err := io.ReadAll(io.LimitReader(r, maxFile+1))
if err != nil {
return nil, err
}
defer clear(b)
if len(b) > maxFile {
return nil, fmt.Errorf("authorkey: a key file of more than %d bytes", maxFile)
}
if bytes.HasPrefix(b, []byte("age-encryption.org/v1\n")) {
if passphrase == "" {
return nil, errors.New("authorkey: the key file is encrypted: it needs its passphrase")
}
id, err := age.NewScryptIdentity(passphrase)
if err != nil {
return nil, err
}
Review fixes: author keys, the writer, the CLI, extensions and the locator Fixes of the review of the session of 1 and 2 October that the text of spec v0.11 already asks for: - authorkey: String and GoString hide the secret key, which only Secret returns; ParsePublic refuses a key that is not a point of the curve (ed25519strict.OnCurve, checked against the square root of testkit). - capsule: a typed nil in AuthorKey, CMSSigner or Sealer is an error, never a capsule without the signature or the seal that was asked for. A panic while evaluating the signature or the seal fails only that part, F1 or S2, not both. OpenOptions.Accept sees the verdicts before step 18 and can refuse to publish the files. - extension.CheckWrite, the rule of encoders of spec 72: the writers of capsules and .dkk files refuse datekeys.note and datekeys.capsule outside the arrays where they are registered, or with invalid data. - CLI: encrypt -sign shows the author key and the code of AUTHOR_MESSAGE before it signs (rule 20); decrypt -expect-author compares the key of an F4 and writes nothing unless it matches; decrypt notifies a public note that it does not show; the lines of the verdicts break at the last space that fits, each row after the first behind a mark, so that the terminal never breaks them; L is the payload, not the content. - locator: a reader rejects an address that breaks 44.1 and keeps the others; addresses refuse the special-purpose blocks of IANA, IPv6 outside 2000::/3, localhost and local names, characters outside RFC 3986, dot segments, and a CID that does not decode to version 1 and a multihash; ParseInfo checks that the locator is an age file with one tlock stanza for the round of its DateKey; Info.Extension reads what it writes; its errors carry no normative code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 days ago
// A work factor above ours would let a hostile file ask for gigabytes
// of memory, so it is refused. A lower one is a weaker file that the
// person made with another tool, and it opens (§29.12 fixes only the
// default).
id.SetMaxWorkFactor(WorkFactor)
ar, err := age.Decrypt(bytes.NewReader(b), id)
if err != nil {
return nil, fmt.Errorf("authorkey: %w", err)
}
plain, err := io.ReadAll(io.LimitReader(ar, maxFile+1))
if err != nil {
return nil, fmt.Errorf("authorkey: %w", err)
}
defer clear(plain)
return parseFile(plain)
}
return parseFile(b)
}
func parseFile(b []byte) (*Key, error) {
var key *Key
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if key != nil {
return nil, errors.New("authorkey: a key file holds one secret key")
}
k, err := ParseSecret(line)
if err != nil {
return nil, err
}
key = k
}
if err := sc.Err(); err != nil {
return nil, err
}
if key == nil {
return nil, errors.New("authorkey: no secret key in the file")
}
return key, nil
}

Powered by TurnKey Linux.