// 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) } // 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 } // 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 // 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) } 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()) 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 } // 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 }