check / check (push) Failing after 3s
REPO_POLICIES.md requires the module root sneak.berlin/go/<name>. go.mod, every import (rewritten with gofmt -r), the -X flags in script/build and the examples in the pkg READMEs now use the new path. go mod tidy lists go-humanize and fatih/color as direct requirements, since internal/cli imports them. This breaks anyone who fetched or imported git.eeqj.de/sneak/secret: they must switch to sneak.berlin/go/secret, which resolves to this repository. Model: opus-5-5
201 lines
5.5 KiB
Go
201 lines
5.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/cobra"
|
|
"github.com/tyler-smith/go-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 from stdin", "error", err)
|
|
|
|
return nil, nil, err
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
|
|
|
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
|
|
}
|