All checks were successful
check / check (push) Successful in 2m0s
- Replace .golangci.yml with the canonical strict config (all linters enabled except the standard disable list; lll 88, funlen 80/50, cyclop 15, dupl 100; test files now linted) - Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by tag and digest (Debian-based) - Fix all ~1550 findings surfaced by the new config: line wrapping, wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel errors, perfsprint/modernize rewrites, goconst constants, thelper, testifylint, noctx CommandContext, testpackage conversions, t.Parallel() where safe, and complexity/dupl helper extraction - Record the change and follow-up items in TODO.md User-visible strings -------------------- No user-visible string changes remain. Every error message this branch composes is byte-identical to the one main composes. The err113 sentinels are shaped so that fmt.Errorf reassembles the original text around them: a sentinel carries the fixed words of the message and the caller supplies the interpolated value in the position it has always occupied. Where the value sits in the middle of the sentence the sentinel therefore holds only a fragment (for example vault.ErrVaultNotFound is "does not exist", composed by its caller as "vault <name> does not exist"); each such sentinel documents the message it participates in. Verified mechanically rather than by inspection: every fmt.Errorf and errors.New call site in both trees was parsed, the Error() text of any sentinel passed to %w substituted in, and the resulting sets of composed message templates compared. All 350 templates main produces are still produced, character for character; the set of messages lost or altered is empty. unlocker list ------------- findUnlockerIDByMetadata now returns (string, error) instead of signalling failure with an empty ID. An unreadable unlockers.d is no longer indistinguishable from "no matching entry", so UnlockersList skips the entry with a warning naming the directory, as it did before the scan was extracted into a helper, rather than emitting a row under a synthesized fallback ID that no unlocker remove or unlocker select can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by tests in internal/cli/unlockers_list_test.go.
157 lines
4.6 KiB
Go
157 lines
4.6 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"
|
||
"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
|
||
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) {
|
||
// 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, 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)
|
||
}
|