Update golangci-lint to v2.12.2 with canonical config (#29)
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>
This commit was merged in pull request #29.
This commit is contained in:
2026-08-10 15:23:33 +02:00
committed by sneak
co-authored by sneak
parent 6e5e0db999
commit 41cea400a7
60 changed files with 6867 additions and 4875 deletions
+200 -107
View File
@@ -2,6 +2,7 @@ package secret
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"path/filepath"
@@ -20,12 +21,17 @@ const (
maxVersionsPerDay = 999
)
var (
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
errNilValueBuffer = errors.New("value buffer is nil")
)
// VersionMetadata contains information about a secret version
type VersionMetadata struct {
ID string `json:"id"` // ULID
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
NotAfter *time.Time `json:"notAfter,omitempty"` // When this version expires (nil = current)
NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current)
}
// Version represents a version of a secret
@@ -75,7 +81,8 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
versionsDir := filepath.Join(secretDir, "versions")
// Ensure versions directory exists
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
err := fs.MkdirAll(versionsDir, DirPerms)
if err != nil {
return "", fmt.Errorf("failed to create versions directory: %w", err)
}
@@ -101,8 +108,11 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
}
var serial int
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
if err != nil {
Warn("Skipping malformed version directory name",
"name", entry.Name(), "error", err)
continue
}
@@ -115,7 +125,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
// Generate new version name
newSerial := maxSerial + 1
if newSerial > maxVersionsPerDay {
return "", fmt.Errorf("exceeded maximum versions per day (999)")
return "", errMaxVersionsPerDay
}
return fmt.Sprintf("%s.%03d", today, newSerial), nil
@@ -124,7 +134,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
// Save saves the version metadata and value
func (sv *Version) Save(value *memguard.LockedBuffer) error {
if value == nil {
return fmt.Errorf("value buffer is nil")
return errNilValueBuffer
}
DebugWith("Saving secret version",
@@ -136,14 +146,16 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
fs := sv.vault.GetFilesystem()
// Create version directory
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
err := fs.MkdirAll(sv.Directory, DirPerms)
if err != nil {
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to create version directory: %w", err)
}
// Step 1: Generate a new keypair for this version
// Generate a new keypair for this version
Debug("Generating version-specific keypair", "version", sv.Version)
versionIdentity, err := age.GenerateX25519Identity()
if err != nil {
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
@@ -151,110 +163,33 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
return fmt.Errorf("failed to generate version keypair: %w", err)
}
versionPublicKey := versionIdentity.Recipient().String()
// Store private key in memguard buffer immediately
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
[]byte(versionIdentity.String()))
defer versionPrivateKeyBuffer.Destroy()
DebugWith("Generated version keypair",
slog.String("version", sv.Version),
slog.String("public_key", versionPublicKey),
slog.String("public_key", versionIdentity.Recipient().String()),
)
// Step 2: Store the version's public key
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write version public key: %w", err)
}
// Step 3: Encrypt the value to the version's public key
Debug("Encrypting value to version's public key", "version", sv.Version)
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
if err != nil {
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version value: %w", err)
return err
}
// Step 4: Store the encrypted value
valuePath := filepath.Join(sv.Directory, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted version value: %w", err)
}
// Step 5: Get vault's long-term public key for encrypting the version's private key
vaultDir, _ := sv.vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
Debug("Reading long-term public key", "path", ltPubKeyPath)
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
if err != nil {
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
return fmt.Errorf("failed to read long-term public key: %w", err)
return err
}
Debug("Parsing long-term public key")
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
err = sv.writeEncryptedMetadata(fs, versionIdentity)
if err != nil {
Debug("Failed to parse long-term public key", "error", err)
return fmt.Errorf("failed to parse long-term public key: %w", err)
return err
}
// Step 6: Encrypt the version's private key to the long-term public key
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
if err != nil {
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
// Step 7: Store the encrypted private key
privKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
return fmt.Errorf("failed to write encrypted version private key: %w", err)
}
// Step 8: Encrypt and store metadata
Debug("Encrypting version metadata", "version", sv.Version)
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
if err != nil {
Debug("Failed to marshal version metadata", "error", err)
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
// Encrypt metadata to the version's public key
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
defer metadataBuffer.Destroy()
encryptedMetadata, err := EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(sv.Directory, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
Debug("Successfully saved secret version",
"version", sv.Version, "secret_name", sv.SecretName)
return nil
}
@@ -270,9 +205,11 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
Debug("Failed to read encrypted version private key",
"error", err, "path", encryptedPrivKeyPath)
return fmt.Errorf("failed to read encrypted version private key: %w", err)
}
@@ -296,9 +233,11 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
// Step 4: Read encrypted metadata
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
if err != nil {
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
Debug("Failed to read encrypted version metadata",
"error", err, "path", encryptedMetadataPath)
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
}
@@ -314,20 +253,25 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
// Step 6: Unmarshal metadata
var metadata VersionMetadata
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
err = json.Unmarshal(metadataBuffer.Bytes(), &metadata)
if err != nil {
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
}
sv.Metadata = metadata
Debug("Successfully loaded version metadata", "version", sv.Version)
return nil
}
// GetValue retrieves and decrypts the version value
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
func (sv *Version) GetValue(
ltIdentity *age.X25519Identity,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting version value",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
@@ -345,16 +289,22 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
Debug("Failed to read encrypted version private key",
"error", err, "path", encryptedPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
return nil, fmt.Errorf(
"failed to read encrypted version private key: %w", err)
}
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
Debug("Successfully read encrypted version private key",
"path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
// Step 2: Decrypt version private key using long-term key
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
@@ -362,7 +312,9 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
Debug("Successfully decrypted version private key", "version", sv.Version, "size", versionPrivKeyBuffer.Size())
Debug("Successfully decrypted version private key",
"version", sv.Version, "size", versionPrivKeyBuffer.Size())
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
@@ -375,16 +327,21 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
// Step 4: Read encrypted value
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
Debug("Reading encrypted value", "path", encryptedValuePath)
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
if err != nil {
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
Debug("Failed to read encrypted version value",
"error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
}
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
Debug("Successfully read encrypted value",
"path", encryptedValuePath, "size", len(encryptedValue))
// Step 5: Decrypt value using version key
Debug("Decrypting value with version identity", "version", sv.Version)
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
if err != nil {
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
@@ -400,6 +357,139 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
return valueBuffer, nil
}
// writePublicKeyAndValue stores the version's public key and the value
// encrypted to it.
func (sv *Version) writePublicKeyAndValue(
fs afero.Fs,
versionIdentity *age.X25519Identity,
value *memguard.LockedBuffer,
) error {
versionPublicKey := versionIdentity.Recipient().String()
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
if err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write version public key: %w", err)
}
// Encrypt the value to the version's public key
Debug("Encrypting value to version's public key", "version", sv.Version)
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version value: %w", err)
}
valuePath := filepath.Join(sv.Directory, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
if err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted version value: %w", err)
}
return nil
}
// writeEncryptedPrivateKey encrypts the version's private key to the
// vault's long-term public key and stores it.
func (sv *Version) writeEncryptedPrivateKey(
fs afero.Fs,
versionPrivateKeyBuffer *memguard.LockedBuffer,
) error {
vaultDir, _ := sv.vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
Debug("Reading long-term public key", "path", ltPubKeyPath)
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
if err != nil {
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
return fmt.Errorf("failed to read long-term public key: %w", err)
}
Debug("Parsing long-term public key")
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
if err != nil {
Debug("Failed to parse long-term public key", "error", err)
return fmt.Errorf("failed to parse long-term public key: %w", err)
}
Debug("Encrypting version private key to long-term public key",
"version", sv.Version)
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
if err != nil {
Debug("Failed to encrypt version private key",
"error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
privKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
if err != nil {
Debug("Failed to write encrypted version private key",
"error", err, "path", privKeyPath)
return fmt.Errorf("failed to write encrypted version private key: %w", err)
}
return nil
}
// writeEncryptedMetadata encrypts the version metadata to the version's
// public key and stores it.
func (sv *Version) writeEncryptedMetadata(
fs afero.Fs,
versionIdentity *age.X25519Identity,
) error {
Debug("Encrypting version metadata", "version", sv.Version)
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
if err != nil {
Debug("Failed to marshal version metadata", "error", err)
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
// Encrypt metadata to the version's public key
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
defer metadataBuffer.Destroy()
encryptedMetadata, err := EncryptToRecipient(
metadataBuffer, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(sv.Directory, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
if err != nil {
Debug("Failed to write encrypted version metadata",
"error", err, "path", metadataPath)
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
return nil
}
// ListVersions lists all versions of a secret
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
versionsDir := filepath.Join(secretDir, "versions")
@@ -409,6 +499,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
if err != nil {
return nil, fmt.Errorf("failed to check versions directory: %w", err)
}
if !exists {
return []string{}, nil
}
@@ -420,6 +511,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
}
var versions []string
for _, entry := range entries {
if entry.IsDir() {
versions = append(versions, entry.Name())
@@ -456,7 +548,8 @@ func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
_ = fs.Remove(currentPath)
// Write just the version name to the file
if err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms); err != nil {
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
if err != nil {
return fmt.Errorf("failed to create current version file: %w", err)
}