diff --git a/README.md b/README.md index bff762e..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 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 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/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..c30d79b 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))) @@ -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 6b81121..c4a8366 100644 --- a/internal/app/export_test.go +++ b/internal/app/export_test.go @@ -216,3 +216,38 @@ 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) +} + +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 83cc6b3..1bffdc0 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -10,9 +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" ) @@ -24,6 +30,7 @@ const ( passwordKDFTime = 3 passwordKDFMemoryKiB = 64 * 1024 passwordKDFThreads = 4 + passphraseCheckWords = 3 ) // encryptedKEK is an encrypted, versioned representation of the hex storage @@ -112,6 +119,144 @@ 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 + } + 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) + } + 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 +} + +func (a *App) showStoragePassphrase(passphrase string, file *os.File) error { + 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) + 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) + 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() { + 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) { if len(password) == 0 { return nil, errors.New("storage encryption password is required") @@ -194,12 +339,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 +353,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"