diff --git a/README.md b/README.md index 52fd35c..8620208 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,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 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`. +`up` prompts for the pairing token, then generates a six-word storage passphrase and shows it once, then erases it from the screen 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** @@ -150,9 +150,9 @@ cosigner-cli logs -f | Command | What it does | | --------- | --------------------------------------------------------------- | | `up` | First-time setup: pair, verify image, start | -| `start` | Start a paired instance (prompts for the storage password) | +| `start` | Start a paired instance (prompts for the storage passphrase) | | `stop` | Stop the container, keep all state | -| `restart` | Restart (prompts for the storage password) | +| `restart` | Restart (prompts for the storage passphrase) | | `status` | Show runtime status | | `logs` | Show logs (`-f` to follow, `--tail N`) | | `doctor` | Check Docker, config, image signature and install state | diff --git a/internal/app/app.go b/internal/app/app.go index b1d94e5..d917506 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -25,8 +25,10 @@ import ( const ( DefaultApexEndpoint = "https://api.enterprise-sandbox.fystack.io" - defaultTokenMax = 4096 - operatorSchema = 2 + // ProductionApexEndpoint is listed in the setup menu but not selectable yet. + ProductionApexEndpoint = "https://api.fystack.io" + defaultTokenMax = 4096 + operatorSchema = 2 ) //go:embed docker-compose.yaml @@ -35,6 +37,9 @@ var templates embed.FS //go:embed assets/banner var banner string +//go:embed assets/logo +var logo string + type Settings struct { SchemaVersion int `yaml:"schema_version"` ApexEndpoint string `yaml:"apex_endpoint"` @@ -117,10 +122,31 @@ func (a *App) printBanner() { if !ok || !isTerminal(file) { return } - const cyan = "\x1b[38;5;45m" - const bold = "\x1b[1m" + // Fystack brand blue for the logo and the letters' shadow edges; white faces. + const blue = "\x1b[38;2;59;130;246m" + const white = "\x1b[1;97m" const reset = "\x1b[0m" - fmt.Fprint(a.Out, cyan+bold+"\n"+banner+"\n"+reset) + icon := strings.Split(strings.TrimRight(logo, "\n"), "\n") + fmt.Fprintln(a.Out) + for i, line := range strings.Split(strings.TrimRight(banner, "\n"), "\n") { + var mark string + if i < len(icon) { + mark = icon[i] + } + var text strings.Builder + for _, r := range strings.TrimRight(line, " ") { + switch r { + case '█': + text.WriteString(white + "█" + reset) + case ' ': + text.WriteRune(r) + default: + text.WriteString(blue + string(r) + reset) + } + } + fmt.Fprintf(a.Out, "%s%-11s%s %s\n", blue, mark, reset, text.String()) + } + fmt.Fprintln(a.Out) } func (a *App) home(cmd *cobra.Command) (string, error) { diff --git a/internal/app/assets/logo b/internal/app/assets/logo new file mode 100644 index 0000000..981bbb8 --- /dev/null +++ b/internal/app/assets/logo @@ -0,0 +1,6 @@ + ▄████████▄ + ██████████ + ██████████ + ██████████ + █████████▀ + █▀ diff --git a/internal/app/commands.go b/internal/app/commands.go index 843a2b0..8773986 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -1,7 +1,6 @@ package app import ( - "bufio" "bytes" "context" "encoding/hex" @@ -200,22 +199,36 @@ func (a *App) preflight(ctx context.Context, docker string) error { }) } -// promptEndpoint lets an interactive operator review the Apex endpoint on a -// fresh setup; non-interactive runs keep the default. +// promptEndpoint lets an interactive operator pick the Fystack environment or +// enter a custom endpoint on a fresh setup; non-interactive runs keep the default. func (a *App) promptEndpoint(defaultValue string) (string, error) { file, ok := a.In.(*os.File) if !ok || !isTerminal(file) { return defaultValue, nil } - fmt.Fprintf(a.Out, "Fystack Endpoint (Optional) [%s]: ", defaultValue) - value, err := bufio.NewReader(file).ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return "", fmt.Errorf("read Apex endpoint: %w", err) + const custom = 2 + options := []menuOption{ + {Label: fmt.Sprintf("Sandbox %s", defaultValue)}, + {Label: fmt.Sprintf("Production %s (coming soon)", ProductionApexEndpoint), Disabled: true}, + {Label: "Custom endpoint"}, } - if value = strings.TrimRight(strings.TrimSpace(value), "/"); value != "" { - return value, nil + choice, err := a.choose(file, "Fystack environment", options) + if err != nil { + return "", fmt.Errorf("read Fystack environment: %w", err) + } + if choice != custom { + return defaultValue, nil + } + for { + fmt.Fprint(a.Out, "Custom endpoint: ") + value, err := readLine(file) + if err != nil { + return "", fmt.Errorf("read Fystack endpoint: %w", err) + } + if endpoint := strings.TrimRight(strings.TrimSpace(string(value)), "/"); endpoint != "" { + return endpoint, nil + } } - return defaultValue, nil } func (a *App) provision(l layout, s Settings, configPath string) error { for _, dir := range []string{l.state, filepath.Join(l.home, "secrets")} { diff --git a/internal/app/export_test.go b/internal/app/export_test.go index c4a8366..a7adec1 100644 --- a/internal/app/export_test.go +++ b/internal/app/export_test.go @@ -160,7 +160,7 @@ func TestExportListAndWrongPassword(t *testing.T) { l = seedExportHome(t, a, "other password", map[string][]byte{"shares:EdDSA:wallet-1": eddsaShareBlob(t)}) cmd = a.Command() cmd.SetArgs([]string{"--home", l.home, "export", "--list"}) - assert.ErrorContains(t, cmd.Execute(), "check the password") + assert.ErrorContains(t, cmd.Execute(), "check the passphrase") } func TestExportRejectsUnknownWallet(t *testing.T) { diff --git a/internal/app/password.go b/internal/app/password.go index 1bffdc0..7d7d70c 100644 --- a/internal/app/password.go +++ b/internal/app/password.go @@ -14,6 +14,7 @@ import ( "sort" "strconv" "strings" + "sync" "syscall" "unicode" @@ -89,14 +90,14 @@ func (a *App) readPassword(confirm bool) (result []byte, resultErr error) { return nil, fmt.Errorf("%s is required in non-interactive mode", passwordEnv) } for { - fmt.Fprint(a.Out, "Storage encryption password: ") + fmt.Fprint(a.Out, "Storage encryption passphrase: ") password, err := readHidden(file) fmt.Fprintln(a.Out) if err != nil { - return nil, fmt.Errorf("read storage encryption password: %w", err) + return nil, fmt.Errorf("read storage encryption passphrase: %w", err) } if len(password) == 0 { - fmt.Fprintln(a.Out, "Password can't be empty; try again (Ctrl-C to quit).") + fmt.Fprintln(a.Out, "Passphrase can't be empty; try again (Ctrl-C to quit).") continue } if !confirm { @@ -130,57 +131,60 @@ 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 { + block := &screenBlock{out: a.Out, erasable: a.interactive()} + defer block.erase() + defer a.onInterrupt(file, block.erase)() + if err := a.showStoragePassphrase(passphrase, file, block); err != nil { return nil, fmt.Errorf("show storage encryption passphrase: %w", err) } - if err := a.confirmPassphraseWords(passphrase, file); err != nil { + if err := a.confirmPassphraseWords(passphrase, file, block); err != nil { return nil, fmt.Errorf("confirm storage encryption passphrase: %w", err) } + block.erase() + a.success("Storage passphrase backed up") a.password = []byte(passphrase) return []byte(passphrase), nil } -func (a *App) showStoragePassphrase(passphrase string, file *os.File) error { +func (a *App) showStoragePassphrase(passphrase string, file *os.File, block *screenBlock) error { warn, bold, reset := "", "", "" - if a.interactive() { + if block.erasable { 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() { + block.printf("\n%s⚠ BACK UP YOUR STORAGE PASSPHRASE NOW%s\n\n %s%s%s\n\n", warn, reset, bold, passphrase, reset) + block.printf("%sWrite it on paper or save it in a secure password manager.\n"+ + "It is shown only once and never stored. Without it the encrypted\n"+ + "MPC key shares on this host can't be decrypted, and the Cosigner\n"+ + "can't start or export them.%s\n\n", warn, reset) + if !block.erasable { return nil } - fmt.Fprint(a.Out, "Press Enter once it's saved; it will be cleared from the screen. ") + block.printf("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") + block.erase() 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 { +func (a *App) confirmPassphraseWords(passphrase string, file *os.File, block *screenBlock) 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)) + block.printf("\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) + block.printf("Word #%d: ", i+1) var typed []byte - if a.interactive() { + if block.erasable { typed, err = readLine(file) + block.echoed() } else { typed, err = readHidden(file) - fmt.Fprintln(a.Out) + block.printf("\n") } if err != nil { return err @@ -190,7 +194,7 @@ func (a *App) confirmPassphraseWords(passphrase string, file *os.File) error { if match { break } - fmt.Fprintln(a.Out, "That doesn't match; check your backup and try again (Ctrl-C to quit).") + block.printf("That doesn't match; check your backup and try again (Ctrl-C to quit).\n") } } return nil @@ -232,17 +236,54 @@ func readLine(file *os.File) ([]byte, error) { 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() { +// screenBlock counts the lines a prompt prints so they can be erased in place; +// while they fit on screen, none of them reach scrollback. +type screenBlock struct { + mu sync.Mutex + out io.Writer + erasable bool + lines int +} + +func (b *screenBlock) printf(format string, args ...any) { + b.mu.Lock() + defer b.mu.Unlock() + text := fmt.Sprintf(format, args...) + b.lines += strings.Count(text, "\n") + fmt.Fprint(b.out, text) +} + +// echoed records a line break the terminal echoed for typed input. +func (b *screenBlock) echoed() { + b.mu.Lock() + defer b.mu.Unlock() + b.lines++ +} + +func (b *screenBlock) erase() { + b.mu.Lock() + defer b.mu.Unlock() + if !b.erasable { + return + } + if b.lines > 0 { + fmt.Fprintf(b.out, "\x1b[%dF\x1b[J", b.lines) + } else { + fmt.Fprint(b.out, "\r\x1b[J") + } + b.lines = 0 +} + +// onInterrupt runs cleanup and restores the terminal if Ctrl-C arrives before +// the returned func is called. +func (a *App) onInterrupt(file *os.File, cleanup func()) 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") + cleanup() if state != nil { _ = term.Restore(int(file.Fd()), state) } @@ -253,7 +294,6 @@ func (a *App) altScreen(file *os.File) func() { return func() { signal.Stop(sig) close(done) - fmt.Fprint(a.Out, "\x1b[2J\x1b[?1049l") } } @@ -328,7 +368,7 @@ func decryptKEK(password, raw []byte) ([]byte, error) { return plaintext, nil } -var errWrongPassword = errors.New("could not decrypt storage key; check the password") +var errWrongPassword = errors.New("could not decrypt storage key; check the passphrase") // storagePassword returns a password that opens the envelope. A password typed // at a terminal is asked for again until it is right; one supplied by @@ -349,7 +389,7 @@ func (a *App) storagePassword(envelope []byte) ([]byte, error) { } clear(a.password) a.password = nil - fmt.Fprintln(a.Out, "Wrong password; try again (Ctrl-C to quit).") + fmt.Fprintln(a.Out, "Wrong passphrase; try again (Ctrl-C to quit).") } } diff --git a/internal/app/ui.go b/internal/app/ui.go index 4d2fea8..112a496 100644 --- a/internal/app/ui.go +++ b/internal/app/ui.go @@ -5,8 +5,12 @@ import ( "fmt" "io" "os" + "strconv" + "strings" "sync" "time" + + "golang.org/x/term" ) const ( @@ -127,3 +131,107 @@ func (w *logWriter) emit(line []byte) { } fmt.Fprint(w.out, w.prefix, string(line), "\n") } + +type menuOption struct { + Label string + Disabled bool +} + +// choose returns the index of the selected option. On a terminal it is an +// arrow-key menu; otherwise the operator types the option number. +func (a *App) choose(file *os.File, title string, options []menuOption) (int, error) { + first := -1 + for i, o := range options { + if !o.Disabled { + first = i + break + } + } + if !a.interactive() { + return a.chooseNumbered(file, title, options, first) + } + state, err := term.MakeRaw(int(file.Fd())) + if err != nil { + return a.chooseNumbered(file, title, options, first) + } + fmt.Fprintf(a.Out, "%s%s:%s %s(↑/↓ to move, Enter to select)%s\r\n\x1b[?25l", ansiBold, title, ansiReset, ansiDim, ansiReset) + selected := first + draw := func() { + for i, o := range options { + switch { + case i == selected: + fmt.Fprintf(a.Out, "\r\x1b[K%s❯ %s%s\r\n", ansiCyan+ansiBold, o.Label, ansiReset) + case o.Disabled: + fmt.Fprintf(a.Out, "\r\x1b[K%s %s%s\r\n", ansiDim, o.Label, ansiReset) + default: + fmt.Fprintf(a.Out, "\r\x1b[K %s\r\n", o.Label) + } + } + } + move := func(step int) { + for i := selected + step; i >= 0 && i < len(options); i += step { + if !options[i].Disabled { + selected = i + return + } + } + } + finish := func() { + fmt.Fprintf(a.Out, "\x1b[%dA\r\x1b[J\x1b[?25h", len(options)+1) + _ = term.Restore(int(file.Fd()), state) + } + pick := func(i int) (int, error) { + finish() + a.success(title + ": " + strings.Join(strings.Fields(options[i].Label), " ")) + return i, nil + } + draw() + buf := make([]byte, 8) + for { + n, err := file.Read(buf) + if err != nil { + finish() + return 0, err + } + key := string(buf[:n]) + switch { + case key == "\x1b[A" || key == "\x1bOA" || key == "k": + move(-1) + case key == "\x1b[B" || key == "\x1bOB" || key == "j": + move(1) + case key == "\r" || key == "\n": + return pick(selected) + case key == "\x03": + finish() + os.Exit(130) + default: + if i, err := strconv.Atoi(key); err == nil && i >= 1 && i <= len(options) && !options[i-1].Disabled { + return pick(i - 1) + } + } + fmt.Fprintf(a.Out, "\x1b[%dA", len(options)) + draw() + } +} + +func (a *App) chooseNumbered(file *os.File, title string, options []menuOption, first int) (int, error) { + fmt.Fprintln(a.Out, title+":") + for i, o := range options { + fmt.Fprintf(a.Out, " %d) %s\n", i+1, o.Label) + } + for { + fmt.Fprintf(a.Out, "Choice [%d]: ", first+1) + line, err := readLine(file) + if err != nil { + return 0, err + } + value := strings.TrimSpace(string(line)) + if value == "" { + return first, nil + } + if i, err := strconv.Atoi(value); err == nil && i >= 1 && i <= len(options) && !options[i-1].Disabled { + return i - 1, nil + } + fmt.Fprintln(a.Out, "That option isn't available; choose another.") + } +}