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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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 |
Expand Down
36 changes: 31 additions & 5 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions internal/app/assets/logo
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
▄████████▄
██████████
██████████
██████████
█████████▀
█▀
33 changes: 23 additions & 10 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package app

import (
"bufio"
"bytes"
"context"
"encoding/hex"
Expand Down Expand Up @@ -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")} {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
104 changes: 72 additions & 32 deletions internal/app/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"syscall"
"unicode"

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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).")
}
}

Expand Down
Loading
Loading