Files
secret/internal/cli/init.go
T
clawbot 46c395e045
check / check (push) Waiting to run
Ask for the passphrase on the controlling terminal, not stdin (closes #144)
The passphrase and mnemonic prompts open /dev/tty and ask there, so
`echo v | secret add s1 --force`, `secret encrypt` and `secret decrypt`
with piped input ask at a terminal instead of failing. Only a process
with no controlling terminal still gets the error naming the variable to
set. `secret init` and `secret vault create` no longer write a newline
to stderr after the mnemonic.

On SIGINT or SIGTERM, Entry() restores the settings of /dev/tty instead
of stdin, since that is where the prompt turns echo off.

The two tests that pin the no-terminal message run the binary in a new
session, so it has no terminal wherever the tests run.

Unverified by a test: the echo restore after Ctrl-C, checked by hand.

Model: opus-5-5
2026-10-14 23:02:23 +02:00

198 lines
5.4 KiB
Go

package cli
import (
"errors"
"fmt"
"log"
"log/slog"
"strings"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
// 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)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.Init(cmd)
}
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
// or reads it interactively. The returned cleanup function must be deferred
// by the caller.
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
if cli.Mnemonic != nil {
secret.Debug("Using mnemonic from environment variable")
return cli.Mnemonic, func() {}, nil
}
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
if err != nil {
secret.Debug("Failed to read mnemonic", "error", err)
return nil, nil, err
}
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
}
// Init initializes the secret manager, holding the state directory lock
// while initialize runs
func (cli *Instance) Init(cmd *cobra.Command) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
return cli.initialize(cmd)
}
// initialize creates the state directory, the default vault and its first
// unlocker
func (cli *Instance) initialize(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
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
}
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
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)
}
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil {
return err
}
defer cleanupPassphrase()
// Create the default vault with its passphrase unlocker
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
mnemonic, passphraseBuffer)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
return fmt.Errorf("failed to create default vault: %w", err)
}
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return err
}
if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n")
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", unlocker.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
}