// Package agehd derives deterministic X25519 age identities from a // BIP-39 seed using a vendor/application-scoped BIP-85 path: // // m / 83696968′ / ′ / ′ / 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 ( "fmt" "strings" "filippo.io/age" "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcutil/bech32" "github.com/tyler-smith/go-bip39" ) 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 ) // 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) != 32 { return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent)) } // Make a copy to avoid modifying the original key := make([]byte, 32) copy(key, ent) clamp(key) data, err := bech32.ConvertBits(key, 8, 5, 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) { // Convert mnemonic to seed seed := bip39.NewSeed(mnemonic, "") // 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, 32) _, 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, 32) _, 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) }