Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
8 changes: 6 additions & 2 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
33 changes: 21 additions & 12 deletions internal/app/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions internal/app/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
187 changes: 184 additions & 3 deletions internal/app/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -24,6 +30,7 @@ const (
passwordKDFTime = 3
passwordKDFMemoryKiB = 64 * 1024
passwordKDFThreads = 4
passphraseCheckWords = 3
)

// encryptedKEK is an encrypted, versioned representation of the hex storage
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/app/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading