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>
461 lines
13 KiB
Go
461 lines
13 KiB
Go
package vault_test
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
|
|
func TestVaultMetadata(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
|
|
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
|
|
testComputeDoubleSHA256(t)
|
|
})
|
|
|
|
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
|
testGetNextDerivationIndex(t, fs)
|
|
})
|
|
|
|
t.Run("MetadataPersistence", func(t *testing.T) {
|
|
testMetadataPersistence(t, fs)
|
|
})
|
|
|
|
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
|
testDifferentKeysForDifferentIndices(t)
|
|
})
|
|
}
|
|
|
|
func testComputeDoubleSHA256(t *testing.T) {
|
|
t.Helper()
|
|
|
|
// Test data
|
|
data := []byte("test data")
|
|
hash := vault.ComputeDoubleSHA256(data)
|
|
|
|
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
|
if len(hash) != 64 {
|
|
t.Errorf("Expected hash length of 64, got %d", len(hash))
|
|
}
|
|
|
|
// Verify consistency
|
|
hash2 := vault.ComputeDoubleSHA256(data)
|
|
if hash != hash2 {
|
|
t.Errorf("Hash should be consistent for same input")
|
|
}
|
|
|
|
// Verify different input produces different hash
|
|
hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
|
|
if hash == hash3 {
|
|
t.Errorf("Different input should produce different hash")
|
|
}
|
|
}
|
|
|
|
// createVaultDirWithMetadata creates a vault directory containing a public
|
|
// key derived from testMnemonic at the given index plus saved metadata, and
|
|
// returns the derived public key hash. An empty familyHash defaults to the
|
|
// derived key's own hash.
|
|
func createVaultDirWithMetadata(
|
|
t *testing.T, fs afero.Fs, vaultName string,
|
|
derivationIndex uint32, familyHash string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
vaultDir := filepath.Join(testStateDir, "vaults.d", vaultName)
|
|
|
|
err := fs.MkdirAll(vaultDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault directory: %v", err)
|
|
}
|
|
|
|
// Derive identity for the requested index
|
|
identity, err := agehd.DeriveIdentity(testMnemonic, derivationIndex)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive identity: %v", err)
|
|
}
|
|
|
|
pubKey := identity.Recipient().String()
|
|
pubKeyHash := vault.ComputeDoubleSHA256([]byte(pubKey))
|
|
|
|
// Write public key
|
|
err = afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
|
[]byte(pubKey), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write public key: %v", err)
|
|
}
|
|
|
|
if familyHash == "" {
|
|
familyHash = pubKeyHash
|
|
}
|
|
|
|
metadata := &vault.Metadata{
|
|
DerivationIndex: derivationIndex,
|
|
PublicKeyHash: pubKeyHash,
|
|
MnemonicFamilyHash: familyHash,
|
|
}
|
|
|
|
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save metadata: %v", err)
|
|
}
|
|
|
|
return pubKeyHash
|
|
}
|
|
|
|
func testGetNextDerivationIndex(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
// Test with no existing vaults
|
|
index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get derivation index: %v", err)
|
|
}
|
|
|
|
if index != 0 {
|
|
t.Errorf("Expected index 0 for first vault, got %d", index)
|
|
}
|
|
|
|
// Create a vault with metadata and matching public key (index 0; the
|
|
// family hash is the index 0 key hash)
|
|
pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
|
|
|
|
// Next index for same mnemonic should be 1
|
|
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get derivation index: %v", err)
|
|
}
|
|
|
|
if index != 1 {
|
|
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
|
}
|
|
|
|
// Different mnemonic should start at 0
|
|
//nolint:dupword // BIP39-style test mnemonic
|
|
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
|
|
|
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get derivation index: %v", err)
|
|
}
|
|
|
|
if index != 0 {
|
|
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d",
|
|
index)
|
|
}
|
|
|
|
// Add another vault with same mnemonic but higher index (5), sharing
|
|
// the same family hash since it's from the same mnemonic
|
|
createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
|
|
|
|
// Next index should be 1 (not 6): we look for the first available slot
|
|
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get derivation index: %v", err)
|
|
}
|
|
|
|
if index != 1 {
|
|
t.Errorf("Expected index 1 (first available), got %d", index)
|
|
}
|
|
}
|
|
|
|
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
|
|
|
err := fs.MkdirAll(vaultDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault directory: %v", err)
|
|
}
|
|
|
|
// Create and save metadata
|
|
metadata := &vault.Metadata{
|
|
DerivationIndex: 3,
|
|
PublicKeyHash: "test-public-key-hash",
|
|
}
|
|
|
|
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save metadata: %v", err)
|
|
}
|
|
|
|
// Load and verify
|
|
loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load metadata: %v", err)
|
|
}
|
|
|
|
if loaded.DerivationIndex != metadata.DerivationIndex {
|
|
t.Errorf("DerivationIndex mismatch: expected %d, got %d",
|
|
metadata.DerivationIndex, loaded.DerivationIndex)
|
|
}
|
|
|
|
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
|
t.Errorf("PublicKeyHash mismatch: expected %s, got %s",
|
|
metadata.PublicKeyHash, loaded.PublicKeyHash)
|
|
}
|
|
}
|
|
|
|
func testDifferentKeysForDifferentIndices(t *testing.T) {
|
|
t.Helper()
|
|
|
|
// Derive keys with different indices
|
|
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive identity with index 0: %v", err)
|
|
}
|
|
|
|
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive identity with index 1: %v", err)
|
|
}
|
|
|
|
// Compute public key hashes
|
|
pubKey0 := identity0.Recipient().String()
|
|
pubKey1 := identity1.Recipient().String()
|
|
hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
|
|
|
|
// Verify different indices produce different public keys
|
|
if pubKey0 == pubKey1 {
|
|
t.Errorf("Different derivation indices should produce different public keys")
|
|
}
|
|
|
|
// But the hash of index 0's public key should be the same for the same
|
|
// mnemonic. This is what we use as the identifier
|
|
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
|
pubKey0Again := identity0Again.Recipient().String()
|
|
hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
|
|
|
|
if hash0 != hash0Again {
|
|
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
|
}
|
|
}
|
|
|
|
func TestPublicKeyHashConsistency(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Derive identity from index 0 multiple times
|
|
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive first identity: %v", err)
|
|
}
|
|
|
|
identity2, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive second identity: %v", err)
|
|
}
|
|
|
|
// Verify identities are the same
|
|
if identity1.Recipient().String() != identity2.Recipient().String() {
|
|
t.Errorf("Identity derivation is not deterministic")
|
|
t.Logf("First: %s", identity1.Recipient().String())
|
|
t.Logf("Second: %s", identity2.Recipient().String())
|
|
}
|
|
|
|
// Compute public key hashes
|
|
hash1 := vault.ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
|
hash2 := vault.ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
|
|
|
// Verify hashes are the same
|
|
if hash1 != hash2 {
|
|
t.Errorf("Public key hash computation is not deterministic")
|
|
t.Logf("First hash: %s", hash1)
|
|
t.Logf("Second hash: %s", hash2)
|
|
}
|
|
|
|
t.Logf("Test mnemonic public key hash (index 0): %s", hash1)
|
|
}
|
|
|
|
func TestSampleHashCalculation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Test with the exact mnemonic from integration test if available. We
|
|
// also test with a few different mnemonics to make sure they produce
|
|
// different hashes
|
|
mnemonics := []string{
|
|
testMnemonic,
|
|
"legal winner thank year wave sausage worth useful legal winner thank yellow",
|
|
//nolint:dupword // BIP39-style test mnemonic
|
|
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
|
}
|
|
|
|
for i, mnemonic := range mnemonics {
|
|
identity, err := agehd.DeriveIdentity(mnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
|
|
}
|
|
|
|
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
|
t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
|
|
t.Logf(" Recipient: %s", identity.Recipient().String())
|
|
}
|
|
}
|
|
|
|
func TestWorkflowMismatch(t *testing.T) {
|
|
// Create a temporary directory for testing
|
|
tempDir := t.TempDir()
|
|
fs := afero.NewOsFs()
|
|
|
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
|
|
|
_, err := vault.CreateVault(fs, tempDir, "default")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
|
}
|
|
|
|
// Load metadata for vault1
|
|
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
|
|
|
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
|
}
|
|
|
|
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
|
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
|
|
|
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
|
t.Setenv("SB_SECRET_MNEMONIC", "")
|
|
|
|
_, err = vault.CreateVault(fs, tempDir, "work")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
|
}
|
|
|
|
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
|
|
|
// Simulate the vault import process
|
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
|
|
|
// Get the next available derivation index for this mnemonic
|
|
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get next derivation index: %v", err)
|
|
}
|
|
|
|
t.Logf("Next derivation index for import: %d", derivationIndex)
|
|
|
|
// Calculate public key hash from index 0 (same as in VaultImport)
|
|
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
|
}
|
|
|
|
publicKeyHash := vault.ComputeDoubleSHA256(
|
|
[]byte(identity0.Recipient().String()))
|
|
|
|
// Load existing metadata and update it (same as in VaultImport)
|
|
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load existing metadata: %v", err)
|
|
}
|
|
|
|
// Update metadata with new derivation info
|
|
existingMetadata.DerivationIndex = derivationIndex
|
|
existingMetadata.PublicKeyHash = publicKeyHash
|
|
|
|
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
|
|
if err != nil {
|
|
t.Fatalf("Failed to save vault metadata: %v", err)
|
|
}
|
|
|
|
// Load updated metadata for vault2
|
|
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
|
}
|
|
|
|
t.Logf("Vault2 (imported mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
|
metadata2.DerivationIndex, metadata2.PublicKeyHash)
|
|
|
|
// Verify that both vaults have the same public key hash
|
|
if metadata1.PublicKeyHash != metadata2.PublicKeyHash {
|
|
t.Errorf("Public key hashes don't match!")
|
|
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
|
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
|
} else {
|
|
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
|
|
metadata1.PublicKeyHash)
|
|
}
|
|
}
|
|
|
|
func TestReverseEngineerHash(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This is the hash that the work vault is getting in the failing test
|
|
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
|
|
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
|
|
|
|
// Calculate hash for test mnemonic
|
|
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive identity: %v", err)
|
|
}
|
|
|
|
calculatedHash := vault.ComputeDoubleSHA256(
|
|
[]byte(identity.Recipient().String()))
|
|
t.Logf("Test mnemonic hash: %s", calculatedHash)
|
|
|
|
if calculatedHash == correctHash {
|
|
t.Logf("Test mnemonic produces the correct hash")
|
|
} else {
|
|
t.Errorf("Test mnemonic does not produce the correct hash")
|
|
}
|
|
|
|
if calculatedHash == wrongHash {
|
|
t.Logf("Test mnemonic unexpectedly produces the wrong hash")
|
|
}
|
|
|
|
// Try some other possibilities: maybe a string normalization issue?
|
|
variations := []string{
|
|
testMnemonic,
|
|
" " + testMnemonic + " ",
|
|
testMnemonic + "\n",
|
|
strings.TrimSpace(testMnemonic),
|
|
}
|
|
|
|
for i, variation := range variations {
|
|
identity, err := agehd.DeriveIdentity(variation, 0)
|
|
if err != nil {
|
|
t.Logf("Variation %d failed: %v", i, err)
|
|
|
|
continue
|
|
}
|
|
|
|
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
|
t.Logf("Variation %d hash: %s", i, hash)
|
|
|
|
if hash == wrongHash {
|
|
t.Logf("Found variation that produces wrong hash: '%s'", variation)
|
|
}
|
|
}
|
|
|
|
// Maybe let's try an empty mnemonic or something else?
|
|
emptyMnemonics := []string{
|
|
"",
|
|
" ",
|
|
}
|
|
|
|
for i, emptyMnemonic := range emptyMnemonics {
|
|
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
|
|
if err != nil {
|
|
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
|
|
|
|
continue
|
|
}
|
|
|
|
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
|
t.Logf("Empty mnemonic %d hash: %s", i, hash)
|
|
|
|
if hash == wrongHash {
|
|
t.Logf("Empty mnemonic produces wrong hash!")
|
|
}
|
|
}
|
|
}
|