From d5f55b28af3cfe6d5db2171885c5693c37192d56 Mon Sep 17 00:00:00 2001 From: anhthii Date: Tue, 13 Oct 2026 11:28:21 +0700 Subject: [PATCH 1/3] feat: generate six-word storage passphrase on setup Fresh interactive installs now generate the storage passphrase instead of asking the operator to choose one, show it once behind an orange backup warning, and require it to be typed back. Unlock accepts the words separated by spaces or hyphens in any case, after first trying the exact input so existing chosen passwords keep working. Scripted installs using COSIGNER_PASSWORD_FD or COSIGNER_PASSWORD are unchanged. --- README.md | 2 +- internal/app/commands.go | 8 +++- internal/app/export.go | 4 +- internal/app/export_test.go | 26 +++++++++++ internal/app/password.go | 86 +++++++++++++++++++++++++++++++++++-- internal/app/ui.go | 1 + 6 files changed, 119 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bff762e..d1ee227 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ cosigner-cli up \ --config /secure/config.yaml ``` -`up` prompts for the pairing token and a storage password (min 12 chars, with upper, lower, digit and symbol). +`up` prompts for the pairing token, then generates a six-word storage passphrase and shows it once. Write it on paper or save it in a password manager: without it the encrypted MPC key shares can't be decrypted. On `start`/`restart`, paste it or type the six words; spaces or hyphens and any capitalisation work. Scripted installs can still supply their own password via `COSIGNER_PASSWORD_FD`. It pulls the newest signed release, verifies it and pins its digest. **3. Check it** diff --git a/internal/app/commands.go b/internal/app/commands.go index a802cfe..843a2b0 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -141,7 +141,11 @@ func (a *App) setup(cmd *cobra.Command, _ []string) error { if saved.Profile == "manual" { a.progress("Unlocking encrypted local storage…") // Cached on the App, so provisioning and startup reuse this entry. - password, err := a.readPassword(fresh) + readPassword := func() ([]byte, error) { return a.readPassword(false) } + if fresh { + readPassword = a.newStoragePassword + } + password, err := readPassword() if err != nil { return err } @@ -237,7 +241,7 @@ func (a *App) provision(l layout, s Settings, configPath string) error { return err } if s.Profile == "manual" { - password, err := a.readPassword(true) + password, err := a.newStoragePassword() if err != nil { return err } diff --git a/internal/app/export.go b/internal/app/export.go index 4181ec0..beae769 100644 --- a/internal/app/export.go +++ b/internal/app/export.go @@ -351,11 +351,11 @@ func (a *App) storageKEK(l layout) ([]byte, error) { if err != nil { return nil, err } - defer clear(password) - plaintext, err := decryptKEK(password, raw) + plaintext, used, err := openKEK(password, raw) if err != nil { return nil, err } + clear(used) defer clear(plaintext) trimmed := bytes.TrimSpace(plaintext) kek := make([]byte, hex.DecodedLen(len(trimmed))) diff --git a/internal/app/export_test.go b/internal/app/export_test.go index 6b81121..0fe10f3 100644 --- a/internal/app/export_test.go +++ b/internal/app/export_test.go @@ -216,3 +216,29 @@ func TestGeneratePassphraseUsesBIP39Words(t *testing.T) { require.NoError(t, err) assert.Len(t, strings.Split(p, "-"), msharePassphraseLen) } + +func TestNormalizePassphrase(t *testing.T) { + require.Equal(t, "abandon-ability-able-about-above-absent", string(normalizePassphrase([]byte(" Abandon ability-ABLE about\tabove absent ")))) + require.Nil(t, normalizePassphrase([]byte("abandon ability able about above"))) + require.Nil(t, normalizePassphrase([]byte("abandon ability able about above notaword"))) +} + +func TestOpenKEKAcceptsSpacedPassphraseAndExactPasswords(t *testing.T) { + passphrase := "abandon-ability-able-about-above-absent" + envelope, err := encryptKEK([]byte(passphrase), []byte("kek")) + require.NoError(t, err) + plain, used, err := openKEK([]byte("Abandon Ability Able About Above Absent"), envelope) + require.NoError(t, err) + require.Equal(t, "kek", string(plain)) + require.Equal(t, passphrase, string(used)) + + chosen := "abandon ability able about above absent" + envelope, err = encryptKEK([]byte(chosen), []byte("kek")) + require.NoError(t, err) + _, used, err = openKEK([]byte(chosen), envelope) + require.NoError(t, err) + require.Equal(t, chosen, string(used)) + + _, _, err = openKEK([]byte("abandon ability able about above above"), envelope) + require.ErrorIs(t, err, errWrongPassword) +} diff --git a/internal/app/password.go b/internal/app/password.go index 83cc6b3..a3bac6f 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -10,7 +10,10 @@ import ( "fmt" "io" "os" + "sort" "strconv" + "strings" + "unicode" "golang.org/x/crypto/argon2" "gopkg.in/yaml.v3" @@ -112,6 +115,47 @@ func (a *App) readPassword(confirm bool) (result []byte, resultErr error) { } } +// newStoragePassword generates a six-word passphrase for a fresh install typed at +// a terminal; passwords supplied by environment or descriptor are used as given. +func (a *App) newStoragePassword() ([]byte, error) { + if a.password != nil || !a.typedPassword() { + return a.readPassword(true) + } + passphrase, err := generatePassphrase(a.Random) + if err != nil { + return nil, err + } + a.showStoragePassphrase(passphrase) + file := a.In.(*os.File) + for { + fmt.Fprint(a.Out, "Type the passphrase to confirm you saved it: ") + typed, err := readHidden(file) + fmt.Fprintln(a.Out) + if err != nil { + return nil, fmt.Errorf("confirm storage encryption passphrase: %w", err) + } + normalized := normalizePassphrase(typed) + match := bytes.Equal(normalized, []byte(passphrase)) + clear(typed) + clear(normalized) + if match { + a.password = []byte(passphrase) + return []byte(passphrase), nil + } + fmt.Fprintln(a.Out, "That doesn't match; check your backup and try again (Ctrl-C to quit).") + } +} + +func (a *App) showStoragePassphrase(passphrase string) { + warn, bold, reset := "", "", "" + if a.interactive() { + warn, bold, reset = ansiOrange+ansiBold, ansiBold, ansiReset + } + fmt.Fprintf(a.Out, "\n%s⚠ BACK UP YOUR STORAGE PASSPHRASE NOW%s\n\n %s%s%s\n\n", warn, reset, bold, passphrase, reset) + fmt.Fprintf(a.Out, "%sWrite it on paper or save it in a secure password manager. It is shown only once and never stored.\n"+ + "Without it the encrypted MPC key shares on this host can't be decrypted, and the Cosigner can't start or export them.%s\n\n", warn, reset) +} + func encryptKEK(password, plaintext []byte) ([]byte, error) { if len(password) == 0 { return nil, errors.New("storage encryption password is required") @@ -194,12 +238,11 @@ func (a *App) storagePassword(envelope []byte) ([]byte, error) { if err != nil { return nil, err } - plain, err := decryptKEK(password, envelope) + plain, used, err := openKEK(password, envelope) clear(plain) if err == nil { - return password, nil + return used, nil } - clear(password) if !errors.Is(err, errWrongPassword) || !a.typedPassword() { return nil, err } @@ -209,6 +252,43 @@ func (a *App) storagePassword(envelope []byte) ([]byte, error) { } } +// openKEK tries the password exactly as given first, so chosen passwords keep +// working, then as a generated passphrase typed with spaces or capitals. +func openKEK(password, envelope []byte) (plain, used []byte, err error) { + plain, err = decryptKEK(password, envelope) + if err == nil { + return plain, password, nil + } + normalized := normalizePassphrase(password) + if !errors.Is(err, errWrongPassword) || normalized == nil || bytes.Equal(normalized, password) { + clear(password) + clear(normalized) + return nil, nil, err + } + clear(password) + plain, err = decryptKEK(normalized, envelope) + if err != nil { + clear(normalized) + return nil, nil, err + } + return plain, normalized, nil +} + +// normalizePassphrase returns the canonical hyphenated form of a generated +// passphrase, or nil if the input isn't six wordlist words. +func normalizePassphrase(input []byte) []byte { + words := strings.FieldsFunc(strings.ToLower(string(input)), func(r rune) bool { return r == '-' || unicode.IsSpace(r) }) + if len(words) != msharePassphraseLen { + return nil + } + for _, w := range words { + if i := sort.SearchStrings(wordlist, w); i == len(wordlist) || wordlist[i] != w { + return nil + } + } + return []byte(strings.Join(words, "-")) +} + func (a *App) typedPassword() bool { if a.Getenv("COSIGNER_PASSWORD_FD") != "" || a.Getenv(passwordEnv) != "" { return false diff --git a/internal/app/ui.go b/internal/app/ui.go index 906a399..4d2fea8 100644 --- a/internal/app/ui.go +++ b/internal/app/ui.go @@ -13,6 +13,7 @@ const ( ansiGreen = "\x1b[32m" ansiRed = "\x1b[31m" ansiYellow = "\x1b[33m" + ansiOrange = "\x1b[38;5;208m" ansiCyan = "\x1b[36m" ansiDim = "\x1b[2m" ansiBold = "\x1b[1m" From 15b5c37be3d781da6c857b152591bb28f0454eb1 Mon Sep 17 00:00:00 2001 From: anhthii Date: Tue, 13 Oct 2026 11:31:13 +0700 Subject: [PATCH 2/3] feat: show storage passphrase on the alternate screen The passphrase is drawn on the terminal's alternate screen so it never enters scrollback, and is cleared once the operator presses Enter. Ctrl-C restores the normal screen and terminal state before exiting. --- README.md | 2 +- internal/app/password.go | 44 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d1ee227..ace12bc 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ cosigner-cli up \ --config /secure/config.yaml ``` -`up` prompts for the pairing token, then generates a six-word storage passphrase and shows it once. Write it on paper or save it in a password manager: without it the encrypted MPC key shares can't be decrypted. On `start`/`restart`, paste it or type the six words; spaces or hyphens and any capitalisation work. Scripted installs can still supply their own password via `COSIGNER_PASSWORD_FD`. +`up` prompts for the pairing token, then generates a six-word storage passphrase and shows it once on a separate screen that is cleared after you press Enter, so it stays out of scrollback. Write it on paper or save it in a password manager: without it the encrypted MPC key shares can't be decrypted. On `start`/`restart`, paste it or type the six words; spaces or hyphens and any capitalisation work. Scripted installs can still supply their own password via `COSIGNER_PASSWORD_FD`. It pulls the newest signed release, verifies it and pins its digest. **3. Check it** diff --git a/internal/app/password.go b/internal/app/password.go index a3bac6f..28841ef 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -10,12 +10,15 @@ import ( "fmt" "io" "os" + "os/signal" "sort" "strconv" "strings" + "syscall" "unicode" "golang.org/x/crypto/argon2" + "golang.org/x/term" "gopkg.in/yaml.v3" ) @@ -125,8 +128,10 @@ func (a *App) newStoragePassword() ([]byte, error) { if err != nil { return nil, err } - a.showStoragePassphrase(passphrase) file := a.In.(*os.File) + if err := a.showStoragePassphrase(passphrase, file); err != nil { + return nil, fmt.Errorf("show storage encryption passphrase: %w", err) + } for { fmt.Fprint(a.Out, "Type the passphrase to confirm you saved it: ") typed, err := readHidden(file) @@ -146,14 +151,49 @@ func (a *App) newStoragePassword() ([]byte, error) { } } -func (a *App) showStoragePassphrase(passphrase string) { +// showStoragePassphrase draws the passphrase on the alternate screen, which never +// enters scrollback, and clears it once the operator presses Enter. +func (a *App) showStoragePassphrase(passphrase string, file *os.File) error { warn, bold, reset := "", "", "" if a.interactive() { warn, bold, reset = ansiOrange+ansiBold, ansiBold, ansiReset + defer a.altScreen(file)() } fmt.Fprintf(a.Out, "\n%s⚠ BACK UP YOUR STORAGE PASSPHRASE NOW%s\n\n %s%s%s\n\n", warn, reset, bold, passphrase, reset) fmt.Fprintf(a.Out, "%sWrite it on paper or save it in a secure password manager. It is shown only once and never stored.\n"+ "Without it the encrypted MPC key shares on this host can't be decrypted, and the Cosigner can't start or export them.%s\n\n", warn, reset) + if !a.interactive() { + return nil + } + fmt.Fprint(a.Out, "Press Enter once it's saved; it will be cleared from the screen. ") + enter, err := readHidden(file) + clear(enter) + return err +} + +// altScreen switches to the alternate screen and returns a func that switches +// back; Ctrl-C also switches back so the passphrase isn't left on screen. +func (a *App) altScreen(file *os.File) func() { + state, _ := term.GetState(int(file.Fd())) + fmt.Fprint(a.Out, "\x1b[?1049h\x1b[H\x1b[2J") + sig, done := make(chan os.Signal, 1), make(chan struct{}) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { + select { + case <-sig: + fmt.Fprint(a.Out, "\x1b[2J\x1b[?1049l") + if state != nil { + _ = term.Restore(int(file.Fd()), state) + } + os.Exit(130) + case <-done: + } + }() + return func() { + signal.Stop(sig) + close(done) + fmt.Fprint(a.Out, "\x1b[2J\x1b[?1049l") + } } func encryptKEK(password, plaintext []byte) ([]byte, error) { From 1e2bea9e94fb0f1b34dfa5de9cc513e0af17ff3a Mon Sep 17 00:00:00 2001 From: anhthii Date: Tue, 13 Oct 2026 11:36:50 +0700 Subject: [PATCH 3/3] feat: confirm storage passphrase with three random words Instead of retyping the whole passphrase, the operator enters three randomly chosen words one per prompt, on the alternate screen so they never reach scrollback. Words are matched case-insensitively and a wrong word is asked again. --- internal/app/export.go | 29 +++++++---- internal/app/export_test.go | 9 ++++ internal/app/password.go | 99 ++++++++++++++++++++++++++++++------- 3 files changed, 108 insertions(+), 29 deletions(-) diff --git a/internal/app/export.go b/internal/app/export.go index beae769..c30d79b 100644 --- a/internal/app/export.go +++ b/internal/app/export.go @@ -463,22 +463,31 @@ func jointPublicKey(protocol string, blob []byte) ([]byte, error) { func generatePassphrase(random io.Reader) (string, error) { words := make([]string, msharePassphraseLen) - limit := (uint64(1) << 32) - ((uint64(1) << 32) % uint64(len(wordlist))) - var buf [4]byte for i := range words { - for { - if _, err := io.ReadFull(random, buf[:]); err != nil { - return "", fmt.Errorf("generate passphrase: %w", err) - } - if v := uint64(binary.BigEndian.Uint32(buf[:])); v < limit { - words[i] = wordlist[v%uint64(len(wordlist))] - break - } + j, err := randomIndex(random, len(wordlist)) + if err != nil { + return "", fmt.Errorf("generate passphrase: %w", err) } + words[i] = wordlist[j] } return strings.Join(words, "-"), nil } +// randomIndex returns a uniform index below n, rejecting values that would bias +// the modulo. +func randomIndex(random io.Reader, n int) (int, error) { + limit := (uint64(1) << 32) - ((uint64(1) << 32) % uint64(n)) + var buf [4]byte + for { + if _, err := io.ReadFull(random, buf[:]); err != nil { + return 0, err + } + if v := uint64(binary.BigEndian.Uint32(buf[:])); v < limit { + return int(v % uint64(n)), nil + } + } +} + var unsafeSlugChars = regexp.MustCompile(`[^a-z0-9]+`) func slug(value string) string { diff --git a/internal/app/export_test.go b/internal/app/export_test.go index 0fe10f3..c4a8366 100644 --- a/internal/app/export_test.go +++ b/internal/app/export_test.go @@ -242,3 +242,12 @@ func TestOpenKEKAcceptsSpacedPassphraseAndExactPasswords(t *testing.T) { _, _, err = openKEK([]byte("abandon ability able about above above"), envelope) require.ErrorIs(t, err, errWrongPassword) } + +func TestRandomPositionsAreDistinctSortedAndInRange(t *testing.T) { + for range 200 { + positions, err := randomPositions(rand.Reader, 6, 3) + require.NoError(t, err) + require.Len(t, positions, 3) + require.True(t, positions[0] >= 0 && positions[0] < positions[1] && positions[1] < positions[2] && positions[2] < 6) + } +} diff --git a/internal/app/password.go b/internal/app/password.go index 28841ef..1bffdc0 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -30,6 +30,7 @@ const ( passwordKDFTime = 3 passwordKDFMemoryKiB = 64 * 1024 passwordKDFThreads = 4 + passphraseCheckWords = 3 ) // encryptedKEK is an encrypted, versioned representation of the hex storage @@ -129,35 +130,25 @@ func (a *App) newStoragePassword() ([]byte, error) { return nil, err } file := a.In.(*os.File) + // The alternate screen never enters scrollback, so neither the passphrase + // nor the confirmation words outlive this prompt. + if a.interactive() { + defer a.altScreen(file)() + } if err := a.showStoragePassphrase(passphrase, file); err != nil { return nil, fmt.Errorf("show storage encryption passphrase: %w", err) } - for { - fmt.Fprint(a.Out, "Type the passphrase to confirm you saved it: ") - typed, err := readHidden(file) - fmt.Fprintln(a.Out) - if err != nil { - return nil, fmt.Errorf("confirm storage encryption passphrase: %w", err) - } - normalized := normalizePassphrase(typed) - match := bytes.Equal(normalized, []byte(passphrase)) - clear(typed) - clear(normalized) - if match { - a.password = []byte(passphrase) - return []byte(passphrase), nil - } - fmt.Fprintln(a.Out, "That doesn't match; check your backup and try again (Ctrl-C to quit).") + if err := a.confirmPassphraseWords(passphrase, file); err != nil { + return nil, fmt.Errorf("confirm storage encryption passphrase: %w", err) } + a.password = []byte(passphrase) + return []byte(passphrase), nil } -// showStoragePassphrase draws the passphrase on the alternate screen, which never -// enters scrollback, and clears it once the operator presses Enter. func (a *App) showStoragePassphrase(passphrase string, file *os.File) error { warn, bold, reset := "", "", "" if a.interactive() { warn, bold, reset = ansiOrange+ansiBold, ansiBold, ansiReset - defer a.altScreen(file)() } fmt.Fprintf(a.Out, "\n%s⚠ BACK UP YOUR STORAGE PASSPHRASE NOW%s\n\n %s%s%s\n\n", warn, reset, bold, passphrase, reset) fmt.Fprintf(a.Out, "%sWrite it on paper or save it in a secure password manager. It is shown only once and never stored.\n"+ @@ -168,9 +159,79 @@ func (a *App) showStoragePassphrase(passphrase string, file *os.File) error { fmt.Fprint(a.Out, "Press Enter once it's saved; it will be cleared from the screen. ") enter, err := readHidden(file) clear(enter) + fmt.Fprint(a.Out, "\x1b[H\x1b[2J") return err } +// confirmPassphraseWords checks the backup by asking for randomly chosen words, +// one per prompt, instead of the whole passphrase. +func (a *App) confirmPassphraseWords(passphrase string, file *os.File) error { + words := strings.Split(passphrase, "-") + positions, err := randomPositions(a.Random, len(words), passphraseCheckWords) + if err != nil { + return err + } + fmt.Fprintf(a.Out, "\nConfirm your backup: enter %d words from your passphrase.\n\n", len(positions)) + for _, i := range positions { + for { + fmt.Fprintf(a.Out, "Word #%d: ", i+1) + var typed []byte + if a.interactive() { + typed, err = readLine(file) + } else { + typed, err = readHidden(file) + fmt.Fprintln(a.Out) + } + if err != nil { + return err + } + match := strings.EqualFold(string(bytes.TrimSpace(typed)), words[i]) + clear(typed) + if match { + break + } + fmt.Fprintln(a.Out, "That doesn't match; check your backup and try again (Ctrl-C to quit).") + } + } + return nil +} + +// randomPositions returns k distinct indexes below n in ascending order. +func randomPositions(random io.Reader, n, k int) ([]int, error) { + all := make([]int, n) + for i := range all { + all[i] = i + } + for i := 0; i < k; i++ { + j, err := randomIndex(random, n-i) + if err != nil { + return nil, err + } + all[i], all[i+j] = all[i+j], all[i] + } + picked := all[:k] + sort.Ints(picked) + return picked, nil +} + +// readLine reads one echoed line a byte at a time, so nothing past the newline +// is buffered away from later prompts. +func readLine(file *os.File) ([]byte, error) { + var line []byte + b := make([]byte, 1) + for len(line) < 256 { + if _, err := file.Read(b); err != nil { + clear(line) + return nil, err + } + if b[0] == '\n' { + break + } + line = append(line, b[0]) + } + return bytes.TrimSuffix(line, []byte("\r")), nil +} + // altScreen switches to the alternate screen and returns a func that switches // back; Ctrl-C also switches back so the passphrase isn't left on screen. func (a *App) altScreen(file *os.File) func() {