Files
secret/pkg/agehd/agehd.go
T
clawbot 613895afbf
check / check (push) Failing after 4s
Derive the vault key from the mnemonic's words however they are spaced (closes #135)
The long-term key was derived from the mnemonic text exactly as typed,
so a double space, a tab or a trailing newline gave a different key,
and a vault made that way did not open with the same words typed
normally. agehd.DeriveEntropy, which init, vault create, vault import,
unlocker add and every unlock go through, now joins the words with
single spaces and puts them in Unicode NFKD, as BIP39 defines the
seed's input, before hashing them. golang.org/x/text, already an
indirect dependency, becomes a direct one for the NFKD step.

Judgement call: internal/bip39's NewSeed keeps hashing its input as
given, as upstream does; the normalising is in agehd.

Model: opus-5-5
2026-10-12 22:51:00 +00:00

162 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package agehd derives deterministic X25519 age identities from a
// BIP-39 seed using a vendor/application-scoped BIP-85 path:
//
// m / 83696968′ / <vendor id>′ / <application id>′ / n′
//
// • vendor id = 592 366 788 (sha256("berlin.sneak") & 0x7fffffff)
// • app id = 733 482 323 (sha256("secret") & 0x7fffffff)
// • n = sequential index (0,1,…)
package agehd
import (
"errors"
"fmt"
"strings"
"filippo.io/age"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/bech32"
"golang.org/x/text/unicode/norm"
"sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/pkg/bip85"
)
const (
purpose = uint32(83696968) // fixed by BIP-85 ("bip")
vendorID = uint32(592366788) // berlin.sneak
appID = uint32(733482323) // secret
hrp = "age-secret-key-" // Bech32 HRP used by age
x25519KeySize = 32 // 256-bit key size for X25519
)
// errInvalidScalarSize is returned when the entropy is not exactly 32
// bytes long.
var errInvalidScalarSize = errors.New("need 32-byte scalar")
// clamp applies RFC-7748 clamping to a 32-byte scalar.
func clamp(k []byte) {
k[0] &= 248
k[31] &= 127
k[31] |= 64
}
// IdentityFromEntropy converts 32 deterministic bytes into an
// *age.X25519Identity by round-tripping through Bech32.
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
if len(ent) != x25519KeySize {
return nil, fmt.Errorf("%w, got %d", errInvalidScalarSize, len(ent))
}
// Make a copy to avoid modifying the original
key := make([]byte, x25519KeySize)
copy(key, ent)
clamp(key)
const (
bech32BitSize8 = 8 // Standard 8-bit encoding
bech32BitSize5 = 5 // Bech32 5-bit encoding
)
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
if err != nil {
return nil, fmt.Errorf("bech32 convert: %w", err)
}
s, err := bech32.Encode(hrp, data)
if err != nil {
return nil, fmt.Errorf("bech32 encode: %w", err)
}
return age.ParseX25519Identity(strings.ToUpper(s))
}
// DeriveEntropy derives 32 bytes of application-scoped entropy from the
// supplied BIP-39 mnemonic and index n using BIP85.
func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
// BIP-39 makes the seed from the words joined by single spaces, in
// Unicode NFKD. NewSeed hashes its input as given, so the mnemonic is
// brought to that form first: the same words, however they were
// typed, then give the same seed.
sentence := norm.NFKD.String(strings.Join(strings.Fields(mnemonic), " "))
seed := bip39.NewSeed(sentence, "")
// Create master key from seed
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
if err != nil {
return nil, fmt.Errorf("failed to create master key: %w", err)
}
// Build the BIP85 derivation path: m/83696968'/vendor'/app'/n'
path := fmt.Sprintf("m/%d'/%d'/%d'/%d'", purpose, vendorID, appID, n)
// Derive BIP85 entropy (64 bytes)
entropy, err := bip85.DeriveBIP85Entropy(masterKey, path)
if err != nil {
return nil, fmt.Errorf("failed to derive BIP85 entropy: %w", err)
}
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
drng := bip85.NewBIP85DRNG(entropy)
key := make([]byte, x25519KeySize)
_, err = drng.Read(key)
if err != nil {
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
}
return key, nil
}
// DeriveEntropyFromXPRV derives 32 bytes of application-scoped entropy from the
// supplied extended private key (xprv) and index n using BIP85.
func DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error) {
// Parse the extended private key
masterKey, err := bip85.ParseMasterKey(xprv)
if err != nil {
return nil, fmt.Errorf("failed to parse master key: %w", err)
}
// Build the BIP85 derivation path: m/83696968'/vendor'/app'/n'
path := fmt.Sprintf("m/%d'/%d'/%d'/%d'", purpose, vendorID, appID, n)
// Derive BIP85 entropy (64 bytes)
entropy, err := bip85.DeriveBIP85Entropy(masterKey, path)
if err != nil {
return nil, fmt.Errorf("failed to derive BIP85 entropy: %w", err)
}
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
drng := bip85.NewBIP85DRNG(entropy)
key := make([]byte, x25519KeySize)
_, err = drng.Read(key)
if err != nil {
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
}
return key, nil
}
// DeriveIdentity is the primary public helper that derives a deterministic
// age identity from a BIP39 mnemonic and index.
func DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error) {
ent, err := DeriveEntropy(mnemonic, n)
if err != nil {
return nil, err
}
return IdentityFromEntropy(ent)
}
// DeriveIdentityFromXPRV derives a deterministic age identity from an
// extended private key (xprv) and index.
func DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error) {
ent, err := DeriveEntropyFromXPRV(xprv, n)
if err != nil {
return nil, err
}
return IdentityFromEntropy(ent)
}