latest
This commit is contained in:
parent
7671eaaa57
commit
6a8bd3388c
206
internal/agehd/README.md
Normal file
206
internal/agehd/README.md
Normal 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
|
||||||
|
```
|
@ -13,17 +13,17 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"filippo.io/age"
|
"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/btcsuite/btcutil/bech32"
|
||||||
"github.com/tyler-smith/go-bip32"
|
|
||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
purpose = uint32(83696968) // fixed by BIP-85 (“bip”)
|
purpose = uint32(83696968) // fixed by BIP-85 ("bip")
|
||||||
vendorID = uint32(592366788) // berlin.sneak
|
vendorID = uint32(592366788) // berlin.sneak
|
||||||
appID = uint32(733482323) // secret
|
appID = uint32(733482323) // secret
|
||||||
firstH = bip32.FirstHardenedChild
|
|
||||||
hrp = "age-secret-key-" // Bech32 HRP used by age
|
hrp = "age-secret-key-" // Bech32 HRP used by age
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -40,9 +40,13 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
|||||||
if len(ent) != 32 {
|
if len(ent) != 32 {
|
||||||
return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent))
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bech32 convert: %w", err)
|
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
|
// 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) {
|
func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
|
||||||
|
// Convert mnemonic to seed
|
||||||
seed := bip39.NewSeed(mnemonic, "")
|
seed := bip39.NewSeed(mnemonic, "")
|
||||||
|
|
||||||
root, err := bip32.NewMasterKey(seed)
|
// Create master key from seed
|
||||||
|
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to create master key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// m / purpose′ / vendor′
|
// Build the BIP85 derivation path: m/83696968'/vendor'/app'/n'
|
||||||
purp, err := root.NewChildKey(purpose + firstH)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to derive BIP85 entropy: %w", err)
|
||||||
}
|
|
||||||
vend, err := purp.NewChildKey(vendorID + firstH)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 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) {
|
func DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error) {
|
||||||
ent, err := DeriveEntropy(mnemonic, n)
|
ent, err := DeriveEntropy(mnemonic, n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -84,3 +127,13 @@ func DeriveIdentity(mnemonic string, n uint32) (*age.X25519Identity, error) {
|
|||||||
}
|
}
|
||||||
return IdentityFromEntropy(ent)
|
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)
|
||||||
|
}
|
||||||
|
@ -8,9 +8,14 @@ import (
|
|||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mnemonic = "abandon abandon abandon abandon abandon " +
|
const (
|
||||||
|
mnemonic = "abandon abandon abandon abandon abandon " +
|
||||||
"abandon abandon abandon abandon abandon abandon about"
|
"abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// Test xprv from BIP85 test vectors
|
||||||
|
testXPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
|
||||||
|
)
|
||||||
|
|
||||||
func TestEncryptDecrypt(t *testing.T) {
|
func TestEncryptDecrypt(t *testing.T) {
|
||||||
id, err := DeriveIdentity(mnemonic, 0)
|
id, err := DeriveIdentity(mnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -45,3 +50,174 @@ func TestEncryptDecrypt(t *testing.T) {
|
|||||||
t.Fatalf("round-trip mismatch: %q", got)
|
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)
|
||||||
|
}
|
||||||
|
@ -8,7 +8,7 @@ BIP85 enables a variety of use cases:
|
|||||||
- Generate multiple BIP39 mnemonic seeds from a single master key
|
- Generate multiple BIP39 mnemonic seeds from a single master key
|
||||||
- Derive Bitcoin HD wallet seeds (WIF format)
|
- Derive Bitcoin HD wallet seeds (WIF format)
|
||||||
- Create extended private keys (XPRV)
|
- Create extended private keys (XPRV)
|
||||||
- Generate deterministic random values for dice rolls, hex values, and passwords
|
- Generate deterministic random values for hex values and passwords
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
@ -83,24 +83,6 @@ if err != nil {
|
|||||||
fmt.Println("32 bytes of hex:", hex)
|
fmt.Println("32 bytes of hex:", hex)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dice Rolls
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Generate dice rolls
|
|
||||||
// sides: number of sides on the die
|
|
||||||
// rolls: number of rolls to generate
|
|
||||||
// index: allows multiple sets of the same type
|
|
||||||
rolls, err := bip85.DeriveDiceRolls(masterKey, 6, 10, 0)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Print("10 rolls of a 6-sided die: ")
|
|
||||||
for _, roll := range rolls {
|
|
||||||
fmt.Print(roll, " ")
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
```
|
|
||||||
|
|
||||||
### DRNG (Deterministic Random Number Generator)
|
### DRNG (Deterministic Random Number Generator)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@ -140,7 +122,6 @@ Where:
|
|||||||
- `128169'` for HEX data
|
- `128169'` for HEX data
|
||||||
- `707764'` for Base64 passwords
|
- `707764'` for Base64 passwords
|
||||||
- `707785'` for Base85 passwords
|
- `707785'` for Base85 passwords
|
||||||
- `89101'` for dice rolls
|
|
||||||
- `828365'` for RSA keys
|
- `828365'` for RSA keys
|
||||||
- `{parameters}` are application-specific parameters
|
- `{parameters}` are application-specific parameters
|
||||||
|
|
||||||
@ -153,7 +134,8 @@ This implementation passes all the test vectors from the BIP85 specification:
|
|||||||
- HD-WIF keys
|
- HD-WIF keys
|
||||||
- XPRV
|
- XPRV
|
||||||
- SHAKE256 DRNG output
|
- SHAKE256 DRNG output
|
||||||
- Dice rolls
|
|
||||||
|
The implementation is also compatible with the Python reference implementation's test vectors for the DRNG functionality.
|
||||||
|
|
||||||
Run the tests with verbose output to see the test vectors and results:
|
Run the tests with verbose output to see the test vectors and results:
|
||||||
|
|
||||||
@ -164,6 +146,7 @@ go test -v git.eeqj.de/sneak/secret/internal/bip85
|
|||||||
## References
|
## References
|
||||||
|
|
||||||
- [BIP85 Specification](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki)
|
- [BIP85 Specification](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki)
|
||||||
|
- [Python Reference Implementation](https://github.com/ethankosakovsky/bip85)
|
||||||
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
||||||
- [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)
|
- [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)
|
||||||
- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
|
- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
|
@ -34,7 +34,6 @@ const (
|
|||||||
APP_HEX = 128169
|
APP_HEX = 128169
|
||||||
APP_PWD64 = 707764 // Base64 passwords
|
APP_PWD64 = 707764 // Base64 passwords
|
||||||
APP_PWD85 = 707785 // Base85 passwords
|
APP_PWD85 = 707785 // Base85 passwords
|
||||||
APP_DICE = 89101
|
|
||||||
APP_RSA = 828365
|
APP_RSA = 828365
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -329,130 +328,46 @@ func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// For the test vector specifically, match exactly what's in the spec
|
// Base85 encode all 64 bytes of entropy using the RFC1924 character set
|
||||||
if pwdLen == 12 && index == 0 {
|
encoded := encodeBase85WithRFC1924Charset(entropy)
|
||||||
// This is the test vector from the BIP85 spec
|
|
||||||
return "_s`{TW89)i4`", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ASCII85/Base85 encode the entropy
|
|
||||||
encodedStr := ascii85Encode(entropy)
|
|
||||||
|
|
||||||
// Slice to the desired password length
|
// Slice to the desired password length
|
||||||
if uint32(len(encodedStr)) < pwdLen {
|
if uint32(len(encoded)) < pwdLen {
|
||||||
return "", fmt.Errorf("derived password length %d is shorter than requested length %d", len(encodedStr), pwdLen)
|
return "", fmt.Errorf("encoded length %d is less than requested length %d", len(encoded), pwdLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodedStr[:pwdLen], nil
|
return encoded[:pwdLen], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ascii85Encode encodes the data into a Base85/ASCII85 string
|
// encodeBase85WithRFC1924Charset encodes data using Base85 with the RFC1924 character set
|
||||||
// This is a simple implementation that doesn't handle special cases
|
func encodeBase85WithRFC1924Charset(data []byte) string {
|
||||||
func ascii85Encode(data []byte) string {
|
// RFC1924 character set
|
||||||
// The maximum expansion of Base85 encoding is 5 characters for 4 input bytes
|
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
||||||
// For 64 bytes, that's potentially 80 characters
|
|
||||||
var result strings.Builder
|
|
||||||
result.Grow(80)
|
|
||||||
|
|
||||||
for i := 0; i < len(data); i += 4 {
|
// Pad data to multiple of 4
|
||||||
// Process 4 bytes at a time
|
padded := make([]byte, ((len(data)+3)/4)*4)
|
||||||
var value uint32
|
copy(padded, data)
|
||||||
for j := 0; j < 4 && i+j < len(data); j++ {
|
|
||||||
value |= uint32(data[i+j]) << (24 - j*8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert into 5 Base85 characters
|
var buf strings.Builder
|
||||||
|
buf.Grow(len(padded) * 5 / 4) // Each 4 bytes becomes 5 Base85 characters
|
||||||
|
|
||||||
|
// Process in 4-byte chunks
|
||||||
|
for i := 0; i < len(padded); i += 4 {
|
||||||
|
// Convert 4 bytes to uint32 (big-endian)
|
||||||
|
chunk := binary.BigEndian.Uint32(padded[i : i+4])
|
||||||
|
|
||||||
|
// Convert to 5 base-85 digits
|
||||||
|
digits := make([]byte, 5)
|
||||||
for j := 4; j >= 0; j-- {
|
for j := 4; j >= 0; j-- {
|
||||||
// Get the remainder when dividing by 85
|
idx := chunk % 85
|
||||||
remainder := value % 85
|
digits[j] = charset[idx]
|
||||||
// Convert to ASCII range (33-117) and add to result
|
chunk /= 85
|
||||||
result.WriteByte(byte(remainder) + 33)
|
|
||||||
// Integer division by 85
|
|
||||||
value /= 85
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.String()
|
buf.Write(digits)
|
||||||
}
|
|
||||||
|
|
||||||
// DeriveDiceRolls derives dice rolls according to the BIP85 specification
|
|
||||||
func DeriveDiceRolls(masterKey *hdkeychain.ExtendedKey, sides, rolls, index uint32) ([]uint32, error) {
|
|
||||||
if sides < 2 {
|
|
||||||
return nil, fmt.Errorf("sides must be at least 2")
|
|
||||||
}
|
|
||||||
if rolls < 1 {
|
|
||||||
return nil, fmt.Errorf("rolls must be at least 1")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_DICE, sides, rolls, index)
|
return buf.String()
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a DRNG
|
|
||||||
drng := NewBIP85DRNG(entropy)
|
|
||||||
|
|
||||||
// Calculate bits per roll
|
|
||||||
bitsPerRoll := calcBitsPerRoll(sides)
|
|
||||||
bytesPerRoll := (bitsPerRoll + 7) / 8
|
|
||||||
|
|
||||||
// The dice rolls test vector uses the following values:
|
|
||||||
// Sides: 6, Rolls: 10
|
|
||||||
if sides == 6 && rolls == 10 && index == 0 {
|
|
||||||
// Hard-coded values from the specification
|
|
||||||
return []uint32{1, 0, 0, 2, 0, 1, 5, 5, 2, 4}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the rolls
|
|
||||||
result := make([]uint32, 0, rolls)
|
|
||||||
buffer := make([]byte, bytesPerRoll)
|
|
||||||
|
|
||||||
for uint32(len(result)) < rolls {
|
|
||||||
// Read bytes for a single roll
|
|
||||||
_, err := drng.Read(buffer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to generate roll: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert bytes to uint32
|
|
||||||
var roll uint32
|
|
||||||
switch bytesPerRoll {
|
|
||||||
case 1:
|
|
||||||
roll = uint32(buffer[0])
|
|
||||||
case 2:
|
|
||||||
roll = uint32(binary.BigEndian.Uint16(buffer))
|
|
||||||
case 3:
|
|
||||||
roll = (uint32(buffer[0]) << 16) | (uint32(buffer[1]) << 8) | uint32(buffer[2])
|
|
||||||
case 4:
|
|
||||||
roll = binary.BigEndian.Uint32(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask extra bits
|
|
||||||
roll &= (1 << bitsPerRoll) - 1
|
|
||||||
|
|
||||||
// Check if roll is valid
|
|
||||||
if roll < sides {
|
|
||||||
result = append(result, roll)
|
|
||||||
}
|
|
||||||
// If roll >= sides, discard and generate a new one
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// calcBitsPerRoll calculates the minimum number of bits needed to represent a die with 'sides' sides
|
|
||||||
func calcBitsPerRoll(sides uint32) uint {
|
|
||||||
bitsNeeded := uint(0)
|
|
||||||
maxValue := uint32(1)
|
|
||||||
|
|
||||||
for maxValue < sides {
|
|
||||||
bitsNeeded++
|
|
||||||
maxValue <<= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return bitsNeeded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseMasterKey parses an extended key from a string
|
// ParseMasterKey parses an extended key from a string
|
||||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user