This commit is contained in:
2025-05-28 04:02:55 -07:00
commit 7671eaaa57
11 changed files with 2325 additions and 0 deletions

86
internal/agehd/agehd.go Normal file
View File

@@ -0,0 +1,86 @@
// 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 (
"fmt"
"strings"
"filippo.io/age"
bip85 "github.com/bitmask-dev/go-bip85"
"github.com/btcsuite/btcutil/bech32"
"github.com/tyler-smith/go-bip32"
"github.com/tyler-smith/go-bip39"
)
const (
purpose = uint32(83696968) // fixed by BIP-85 (“bip”)
vendorID = uint32(592366788) // berlin.sneak
appID = uint32(733482323) // secret
firstH = bip32.FirstHardenedChild
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))
}
clamp(ent)
data, err := bech32.ConvertBits(ent, 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.
func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
seed := bip39.NewSeed(mnemonic, "")
root, err := bip32.NewMasterKey(seed)
if err != nil {
return nil, err
}
// m / purpose / vendor
purp, err := root.NewChildKey(purpose + firstH)
if err != nil {
return nil, err
}
vend, err := purp.NewChildKey(vendorID + firstH)
if err != nil {
return nil, err
}
return bip85.NewFromBip32(vend).DeriveRawEntropy(appID, 32, n)
}
// DeriveIdentity is the primary public helper.
func DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error) {
ent, err := DeriveEntropy(mnemonic, n)
if err != nil {
return nil, err
}
return IdentityFromEntropy(ent)
}

View File

@@ -0,0 +1,47 @@
package agehd
import (
"bytes"
"io"
"testing"
"filippo.io/age"
)
const mnemonic = "abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon about"
func TestEncryptDecrypt(t *testing.T) {
id, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive: %v", err)
}
t.Logf("secret: %s", id.String())
t.Logf("recipient: %s", id.Recipient().String())
var ct bytes.Buffer
w, err := age.Encrypt(&ct, id.Recipient())
if err != nil {
t.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, "hello world"); err != nil {
t.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), id)
if err != nil {
t.Fatalf("decrypt init: %v", err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got := string(dec); got != "hello world" {
t.Fatalf("round-trip mismatch: %q", got)
}
}