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.
229 lines
6.5 KiB
Go
229 lines
6.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/cobra"
|
|
"github.com/tyler-smith/go-bip39"
|
|
)
|
|
|
|
// errPassphraseMismatch is returned when passphrase confirmation fails
|
|
var errPassphraseMismatch = errors.New("passphrases do not match")
|
|
|
|
// NewInitCmd creates the init command
|
|
func NewInitCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "init",
|
|
Short: "Initialize the secrets manager",
|
|
Long: `Create the necessary directory structure for storing ` +
|
|
`secrets and generate encryption keys.`,
|
|
RunE: RunInit,
|
|
}
|
|
}
|
|
|
|
// RunInit is the exported function that handles the init command
|
|
func RunInit(cmd *cobra.Command, _ []string) error {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
return cli.Init(cmd)
|
|
}
|
|
|
|
// promptMnemonic reads the mnemonic from the environment or interactively.
|
|
// The returned cleanup function must be deferred by the caller.
|
|
func promptMnemonic() (string, func(), error) {
|
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
|
secret.Debug("Using mnemonic from environment variable")
|
|
|
|
return envMnemonic, func() {}, nil
|
|
}
|
|
|
|
secret.Debug("Prompting user for mnemonic phrase")
|
|
|
|
// Read mnemonic securely without echo
|
|
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
|
if err != nil {
|
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
|
|
|
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
|
|
|
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
|
|
}
|
|
|
|
// setupDefaultVault creates the default vault and derives its long-term
|
|
// identity from the mnemonic
|
|
func (cli *Instance) setupDefaultVault(
|
|
stateDir, mnemonicStr string,
|
|
) (*vault.Vault, *age.X25519Identity, error) {
|
|
// Create the default vault - it will handle key derivation internally
|
|
secret.Debug("Creating default vault")
|
|
|
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
|
if err != nil {
|
|
secret.Debug("Failed to create default vault", "error", err)
|
|
|
|
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
|
|
}
|
|
|
|
// Get the vault metadata to retrieve the derivation index
|
|
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
|
|
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
|
if err != nil {
|
|
secret.Debug("Failed to load vault metadata", "error", err)
|
|
|
|
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
|
}
|
|
|
|
// Derive the long-term key using the same index that CreateVault used
|
|
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
|
if err != nil {
|
|
secret.Debug("Failed to derive long-term key", "error", err)
|
|
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to derive long-term key from mnemonic: %w", err)
|
|
}
|
|
|
|
return vlt, ltIdentity, nil
|
|
}
|
|
|
|
// Init initializes the secret manager
|
|
func (cli *Instance) Init(cmd *cobra.Command) error {
|
|
secret.Debug("Starting secret manager initialization")
|
|
|
|
// Create state directory
|
|
stateDir := cli.GetStateDir()
|
|
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
|
|
|
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
|
if err != nil {
|
|
secret.Debug("Failed to create state directory", "error", err)
|
|
|
|
return fmt.Errorf("failed to create state directory: %w", err)
|
|
}
|
|
|
|
if cmd != nil {
|
|
cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
|
|
}
|
|
|
|
// Prompt for mnemonic
|
|
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanupMnemonic()
|
|
|
|
if mnemonicStr == "" {
|
|
secret.Debug("Empty mnemonic provided")
|
|
|
|
return errMnemonicEmpty
|
|
}
|
|
|
|
// Validate the mnemonic using BIP39
|
|
secret.DebugWith("Validating BIP39 mnemonic",
|
|
slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
|
|
|
if !bip39.IsMnemonicValid(mnemonicStr) {
|
|
secret.Debug("Invalid BIP39 mnemonic provided")
|
|
|
|
return fmt.Errorf(
|
|
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
|
|
errInvalidMnemonicPhrase)
|
|
}
|
|
|
|
// Set mnemonic in environment for CreateVault to use
|
|
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
|
defer restoreMnemonicEnv()
|
|
|
|
// Create the default vault and derive its long-term key
|
|
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ltPubKey := ltIdentity.Recipient().String()
|
|
|
|
// Unlock the vault with the derived long-term key
|
|
vlt.Unlock(ltIdentity)
|
|
|
|
// Prompt for passphrase for unlocker
|
|
passphraseBuffer, err := resolvePassphrase()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer passphraseBuffer.Destroy()
|
|
|
|
// Create passphrase-protected unlocker
|
|
secret.Debug("Creating passphrase-protected unlocker")
|
|
|
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
if err != nil {
|
|
secret.Debug("Failed to create unlocker", "error", err)
|
|
|
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
|
}
|
|
|
|
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
|
|
// private key to longterm.age, so no need to do it again here.
|
|
|
|
if cmd != nil {
|
|
cmd.Printf("\nDefault vault created and configured\n")
|
|
cmd.Printf("Long-term public key: %s\n", ltPubKey)
|
|
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
|
cmd.Println("\nYour secret manager is ready to use!")
|
|
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
|
cmd.Println("unlockers are not required for secret operations.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
|
|
// This version adds confirmation (read twice) for creating new unlockers
|
|
// Returns a LockedBuffer containing the passphrase
|
|
func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
|
// Get the first passphrase
|
|
passphraseBuffer1, err := secret.ReadPassphrase(prompt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Read confirmation passphrase
|
|
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
|
if err != nil {
|
|
passphraseBuffer1.Destroy()
|
|
|
|
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
|
}
|
|
|
|
// Compare passphrases
|
|
if passphraseBuffer1.String() != passphraseBuffer2.String() {
|
|
passphraseBuffer1.Destroy()
|
|
passphraseBuffer2.Destroy()
|
|
|
|
return nil, errPassphraseMismatch
|
|
}
|
|
|
|
// Clean up the second buffer, we'll return the first
|
|
passphraseBuffer2.Destroy()
|
|
|
|
// Return the first buffer (caller is responsible for destroying it)
|
|
return passphraseBuffer1, nil
|
|
}
|