check / check (push) Failing after 4s
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
162 lines
4.9 KiB
Go
162 lines
4.9 KiB
Go
// 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)
|
||
}
|