This commit is contained in:
2025-05-28 07:37:57 -07:00
parent 7671eaaa57
commit 6a8bd3388c
6 changed files with 1094 additions and 449 deletions

206
internal/agehd/README.md Normal file
View File

@@ -0,0 +1,206 @@
# agehd - Deterministic Age Identities from BIP85
The `agehd` package derives deterministic X25519 age identities using BIP85 entropy derivation and a deterministic random number generator (DRNG). This package only supports proper BIP85 sources: BIP39 mnemonics and extended private keys (xprv).
## Features
- **Deterministic key generation**: Same input always produces the same age identity
- **BIP85 compliance**: Uses the BIP85 standard for entropy derivation
- **Multiple key support**: Generate multiple keys from the same source using different indices
- **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private keys (xprv)
- **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid conflicts
## Derivation Path
The package uses the following BIP85 derivation path:
```
m/83696968'/592366788'/733482323'/n'
```
Where:
- `83696968'` is the BIP85 root path ("bip" in ASCII)
- `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff)
- `733482323'` is the application ID (sha256("secret") & 0x7fffffff)
- `n'` is the sequential index (0, 1, 2, ...)
## Usage
### From BIP39 Mnemonic
```go
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/internal/agehd"
)
func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive the first identity (index 0)
identity, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String())
}
```
### From Extended Private Key (XPRV)
```go
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/internal/agehd"
)
func main() {
xprv := "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
// Derive the first identity (index 0) from the xprv
identity, err := agehd.DeriveIdentityFromXPRV(xprv, 0)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String())
}
```
### Multiple Identities
```go
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/internal/agehd"
)
func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive multiple identities with different indices
for i := uint32(0); i < 3; i++ {
identity, err := agehd.DeriveIdentity(mnemonic, i)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Identity %d: %s\n", i, identity.Recipient().String())
}
}
```
### From Raw Entropy
```go
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/internal/agehd"
)
func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// First derive entropy using BIP85
entropy, err := agehd.DeriveEntropy(mnemonic, 0)
if err != nil {
log.Fatal(err)
}
// Then create identity from entropy
identity, err := agehd.IdentityFromEntropy(entropy)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String())
}
```
## API Reference
### Functions
#### `DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error)`
Derives a deterministic age identity from a BIP39 mnemonic and index.
- `mnemonic`: A valid BIP39 mnemonic phrase
- `n`: The derivation index (0, 1, 2, ...)
- Returns: An age X25519 identity or an error
#### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)`
Derives a deterministic age identity from an extended private key (xprv) and index.
- `xprv`: A valid extended private key in xprv format
- `n`: The derivation index (0, 1, 2, ...)
- Returns: An age X25519 identity or an error
#### `DeriveEntropy(mnemonic string, n uint32) ([]byte, error)`
Derives 32 bytes of entropy from a BIP39 mnemonic and index using BIP85.
- `mnemonic`: A valid BIP39 mnemonic phrase
- `n`: The derivation index
- Returns: 32 bytes of entropy or an error
#### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)`
Derives 32 bytes of entropy from an extended private key (xprv) and index using BIP85.
- `xprv`: A valid extended private key in xprv format
- `n`: The derivation index
- Returns: 32 bytes of entropy or an error
#### `IdentityFromEntropy(ent []byte) (*age.X25519Identity, error)`
Converts 32 bytes of entropy into an age X25519 identity.
- `ent`: Exactly 32 bytes of entropy
- Returns: An age X25519 identity or an error
## Implementation Details
1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive 64 bytes of entropy from the input source
2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 is seeded with the 64-byte entropy
3. **Key Generation**: 32 bytes are read from the DRNG to generate the age private key
4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for X25519
5. **Bech32 Encoding**: The key is encoded using Bech32 with the "age-secret-key-" prefix
## Security Considerations
- The same mnemonic/xprv and index will always produce the same identity
- Different indices produce cryptographically independent identities
- The vendor/application scoping prevents conflicts with other BIP85 applications
- The DRNG ensures high-quality randomness for key generation
- Private keys are properly clamped for X25519 usage
- Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary passphrases
## Testing
Run the tests with:
```bash
go test -v ./internal/agehd
```

View File

@@ -13,17 +13,17 @@ import (
"strings"
"filippo.io/age"
bip85 "github.com/bitmask-dev/go-bip85"
"git.eeqj.de/sneak/secret/internal/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"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)
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
)
@@ -40,9 +40,13 @@ 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)
// 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)
}
@@ -54,29 +58,68 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
}
// DeriveEntropy derives 32 bytes of application-scoped entropy from the
// supplied BIP-39 mnemonic and index n.
// 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, "")
root, err := bip32.NewMasterKey(seed)
// Create master key from seed
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create master key: %w", err)
}
// m / purpose / vendor
purp, err := root.NewChildKey(purpose + firstH)
// 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, err
}
vend, err := purp.NewChildKey(vendorID + firstH)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to derive BIP85 entropy: %w", err)
}
return bip85.NewFromBip32(vend).DeriveRawEntropy(appID, 32, n)
// 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.
// 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 {
@@ -84,3 +127,13 @@ func DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error) {
}
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)
}

View File

@@ -8,8 +8,13 @@ import (
"filippo.io/age"
)
const mnemonic = "abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon about"
const (
mnemonic = "abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon about"
// Test xprv from BIP85 test vectors
testXPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
)
func TestEncryptDecrypt(t *testing.T) {
id, err := DeriveIdentity(mnemonic, 0)
@@ -45,3 +50,174 @@ func TestEncryptDecrypt(t *testing.T) {
t.Fatalf("round-trip mismatch: %q", got)
}
}
func TestDeriveIdentityFromXPRV(t *testing.T) {
id, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive from xprv: %v", err)
}
t.Logf("xprv secret: %s", id.String())
t.Logf("xprv recipient: %s", id.Recipient().String())
// Test encryption/decryption with xprv-derived identity
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 from xprv"); 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 from xprv" {
t.Fatalf("round-trip mismatch: %q", got)
}
}
func TestDeterministicDerivation(t *testing.T) {
// Test that the same mnemonic and index always produce the same identity
id1, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive 1: %v", err)
}
id2, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive 2: %v", err)
}
if id1.String() != id2.String() {
t.Fatalf("identities should be deterministic: %s != %s", id1.String(), id2.String())
}
// Test that different indices produce different identities
id3, err := DeriveIdentity(mnemonic, 1)
if err != nil {
t.Fatalf("derive 3: %v", err)
}
if id1.String() == id3.String() {
t.Fatalf("different indices should produce different identities")
}
t.Logf("Index 0: %s", id1.String())
t.Logf("Index 1: %s", id3.String())
}
func TestDeterministicXPRVDerivation(t *testing.T) {
// Test that the same xprv and index always produce the same identity
id1, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive 1: %v", err)
}
id2, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive 2: %v", err)
}
if id1.String() != id2.String() {
t.Fatalf("xprv identities should be deterministic: %s != %s", id1.String(), id2.String())
}
// Test that different indices with same xprv produce different identities
id3, err := DeriveIdentityFromXPRV(testXPRV, 1)
if err != nil {
t.Fatalf("derive 3: %v", err)
}
if id1.String() == id3.String() {
t.Fatalf("different indices should produce different identities")
}
t.Logf("XPRV Index 0: %s", id1.String())
t.Logf("XPRV Index 1: %s", id3.String())
}
func TestMnemonicVsXPRVConsistency(t *testing.T) {
// Test that deriving from mnemonic and from the corresponding xprv produces the same result
// Note: This test is removed because the test mnemonic and test xprv are from different sources
// and are not expected to produce the same results.
t.Skip("Skipping consistency test - test mnemonic and xprv are from different sources")
}
func TestEntropyLength(t *testing.T) {
// Test that DeriveEntropy returns exactly 32 bytes
entropy, err := DeriveEntropy(mnemonic, 0)
if err != nil {
t.Fatalf("derive entropy: %v", err)
}
if len(entropy) != 32 {
t.Fatalf("expected 32 bytes of entropy, got %d", len(entropy))
}
t.Logf("Entropy (32 bytes): %x", entropy)
// Test that DeriveEntropyFromXPRV returns exactly 32 bytes
entropyXPRV, err := DeriveEntropyFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive entropy from xprv: %v", err)
}
if len(entropyXPRV) != 32 {
t.Fatalf("expected 32 bytes of entropy from xprv, got %d", len(entropyXPRV))
}
t.Logf("XPRV Entropy (32 bytes): %x", entropyXPRV)
// Note: We don't compare the entropy values since the test mnemonic and test xprv
// are from different sources and should produce different entropy values.
}
func TestIdentityFromEntropy(t *testing.T) {
// Test that IdentityFromEntropy works with custom entropy
entropy := make([]byte, 32)
for i := range entropy {
entropy[i] = byte(i)
}
id, err := IdentityFromEntropy(entropy)
if err != nil {
t.Fatalf("identity from entropy: %v", err)
}
t.Logf("Custom entropy identity: %s", id.String())
// Test that it rejects wrong-sized entropy
_, err = IdentityFromEntropy(entropy[:31])
if err == nil {
t.Fatalf("expected error for 31-byte entropy")
}
// Create a 33-byte slice to test rejection
entropy33 := make([]byte, 33)
copy(entropy33, entropy)
_, err = IdentityFromEntropy(entropy33)
if err == nil {
t.Fatalf("expected error for 33-byte entropy")
}
}
func TestInvalidXPRV(t *testing.T) {
// Test with invalid xprv
_, err := DeriveIdentityFromXPRV("invalid-xprv", 0)
if err == nil {
t.Fatalf("expected error for invalid xprv")
}
t.Logf("Got expected error for invalid xprv: %v", err)
}