All checks were successful
check / check (push) Successful in 43s
Bumps golangci-lint from v2.1.6 (digest-only pin in the `Dockerfile` lint stage) to v2.12.2, pinned by tag and digest (Debian-based image). Replaces `.golangci.yml` with the canonical strict config: all linters enabled except the standard disable list (`exhaustruct`, `depguard`, `godot`, `wsl`, `wrapcheck`, `varnamelen`), `lll` at 88, `funlen` 80/50, `cyclop` 15, `dupl` 100, and test files are now linted (the old config had `tests: false`, an enable-only list of ~20 linters, `lll` 120, and a blanket exclusion of `internal/macse`). The stricter config surfaced ~1550 findings, all fixed: - `wsl_v5` (439) / `nlreturn` (24): blank-line insertions - `lll` (309): line wrapping at 88 columns; long literals split with `+` concatenation, values unchanged - `noinlineerr` (130): `if err := ...` split into assignment plus check - `paralleltest` (116): `t.Parallel()` added to tests without shared state; reasoned `//nolint` where `t.Setenv` or shared fixtures forbid it - `err113` (97): package-level sentinel errors (new `internal/vault/errors.go`), `%w` wrapping, `errors.Is` - `perfsprint` (74) / `modernize` (39) / `intrange`: `strconv`, `errors.New`, `slices.Contains`, `any`, `SplitSeq` - `goconst` (40) / `dupword` (41) / `testifylint` (42) / `thelper` (33): constants, assertion fixes, `t.Helper()` - `noctx` (22): `exec.CommandContext` for gpg/CLI invocations - `testpackage` (18): black-box tests moved to `_test` packages where they use only exported identifiers; white-box files carry a reasoned `//nolint` - `funlen`/`cyclop`/`gocognit`/`nestif`/`dupl`: behavior-preserving helper extraction - assorted singletons: `gosec`, `gosmopolitan`, `funcorder`, `nonamedreturns`, `makezero`, `prealloc`, `godox`, `nolintlint`, `ireturn`, `nilnil`, `gochecknoinits` ## User-visible strings **None changed.** Every error message this branch composes is byte-identical to the one `main` composes. The `err113` sentinels are shaped so `fmt.Errorf` reassembles the original text around them: the sentinel carries the fixed words and the caller supplies the interpolated value in the position it has always occupied. Where the value sits mid-sentence the sentinel holds only a fragment (e.g. `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, not by inspection: every `fmt.Errorf` and `errors.New` call site in both trees is parsed, the `Error()` text of any sentinel passed to `%w` is substituted in, and the resulting sets of composed message templates are compared. All 350 templates `main` produces are still produced, character for character. The set of lost or altered messages is empty. ## `unlocker list` `findUnlockerIDByMetadata` returns `(string, error)` rather than signalling failure with an empty ID, so an unreadable `unlockers.d` is no longer indistinguishable from "no matching entry". `UnlockersList` skips such an entry with a warning naming the directory — its behavior before the scan was extracted into a helper — instead of 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 `internal/cli/unlockers_list_test.go`. `TODO.md` records the change plus follow-ups (version-completion TODOs formerly in code comments, darwin-gated files exceeding 88 columns that Linux CI does not lint). `make check` is green and the pinned v2.12.2 image reports `0 issues.` Note the test suite needs the memlock ulimit from `script/cibuild` for the 10MB memguard test; that requirement is pre-existing. Not changed: `script/bootstrap` installs golangci-lint via the system package manager (no version pin to bump), and `script/lint` invokes whatever `golangci-lint` is on PATH. golangci-lint v2.12 deprecates `gomodguard` in favor of `gomodguard_v2` (warning only); the canonical config owns that decision. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #29 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
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
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/pkg/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)
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/pkg/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
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/pkg/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
package main
import (
"fmt"
"log"
"git.eeqj.de/sneak/secret/pkg/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 phrasen: 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 formatn: 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 phrasen: 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 formatn: 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
- BIP85 Entropy Derivation: The package uses the BIP85 standard to derive 64 bytes of entropy from the input source
- DRNG: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 is seeded with the 64-byte entropy
- Key Generation: 32 bytes are read from the DRNG to generate the age private key
- RFC-7748 Clamping: The private key is clamped according to RFC-7748 for X25519
- 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:
go test -v ./internal/agehd