diff --git a/cli/README.md b/cli/README.md index 55d45d0..b1fc0fd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -46,29 +46,92 @@ and `internal/spec`. ## Commands -### `signet link ` +### `signet link` + +Attaches the wallet you deploy contracts from to your Signet handle. ```bash -signet link aquawolf --public-key GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD -# Linked aquawolf to GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD (testnet) +signet link +# Approve this link in your browser: +# +# https://signet.example/link?callback=…&code=… +# +# Waiting for approval… 4m58s remaining +# Approved. Proving control of the deploy key… +# Linked GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD to @aquawolf on testnet. ``` +There is no handle argument: the handle is whichever one you are signed in as +when you approve in the browser. Asking for it here would invite typing one you +do not own, and the server would refuse it anyway. There is no `--public-key` +either — the key is resolved from your local `stellar` identity, so what gets +signed and what gets linked cannot disagree. + +Approving proves you own the handle; signing a challenge proves you control the +deploy key. Both are required. + `--json` writes a single JSON object to stdout instead — `{handle, publicKey, -network, status}` — and suppresses the human-readable summary entirely, so a -CI pipeline can parse the result without scraping text that's free to change -between releases: +network, status}` — and sends progress to stderr, so a CI pipeline can parse +the result without scraping text that is free to change between releases: ```bash -signet link aquawolf --public-key GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD --json -# {"handle":"aquawolf","publicKey":"GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD","network":"testnet","status":"ok"} +signet link --json +# {"handle":"aquawolf","publicKey":"GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD","network":"testnet","status":"linked"} ``` -On an invalid handle or public key, stdout stays empty (in both modes) and -the error goes to stderr with a non-zero exit code — stdout is always safe -to parse as either the one JSON object or nothing at all. - +`--no-browser` prints the approval URL instead of trying to open one. `--network` defaults to `testnet`; pass `--network mainnet` for mainnet. +### `signet unlink` + +Removes the binding, proving control of the same deploy key. + +```bash +signet unlink +# Unlink GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD from its Signet profile? [y/N] y +# Unlinked GASAAEJC6P5UZGRLYJ2I2KYLR7RXGF44JZXDYGCFBN7T5VIHECUUEMCD from @aquawolf. +``` + +`--yes` skips the confirmation, for non-interactive use. Unlinking needs only +key control — no browser step — because it withdraws an attestation rather than +making one. + +On any failure stdout stays empty (in both modes) and the error goes to stderr +with a non-zero exit code — stdout is always safe to parse as either the one +JSON object or nothing at all. + +### Non-interactive use (CI) + +An interactive identity prompt has nothing to answer it in CI. Set the identity +and no prompt appears: + +```bash +# stellar already reads this for `tx sign`; signet honours the same variable, +# so there is only one name to keep in sync. +export STELLAR_SIGN_WITH_KEY=ci-deploy +signet link --json +``` + +or per-invocation: + +```bash +signet link --sign-with-key ci-deploy --json +``` + +Pass an **identity name**, not a secret. signet resolves your public key with +`stellar keys address ` before it can request a challenge — that is how +key material stays out of this process — and a secret on the command line is +visible in shell history and to anyone who can run `ps`. A value that looks +like a secret seed or a seed phrase is refused, and the value is never echoed +back in the error. Add the key once in the job and pass its name: + +```bash +stellar keys add ci-deploy --secret-key "$SIGNET_DEPLOY_KEY" +``` + +Unlike `--source`, neither `--sign-with-key` nor `STELLAR_SIGN_WITH_KEY` is +written to the config file. + ## Exit codes Stable and documented — scripts and CI wrapping this command can branch on @@ -107,9 +170,11 @@ Every command reads two settings — which Signet deployment to talk to, and which local identity to sign as — resolved in this order, highest priority first: -1. A command-line flag: `--url` / `--source` -2. An environment variable: `SIGNET_URL` (for the deployment URL only — - there is no environment override for the identity) +1. A command-line flag: `--url` / `--source` / `--sign-with-key` + (`--sign-with-key` outranks `--source`: it is the more specific statement + of intent) +2. An environment variable: `SIGNET_URL` for the deployment URL, + `STELLAR_SIGN_WITH_KEY` for the identity 3. The config file: `$XDG_CONFIG_HOME/signet/config.json` on Linux, `~/Library/Application Support/signet/config.json` on macOS, `%AppData%\signet\config.json` on Windows (`os.UserConfigDir()`) @@ -123,7 +188,10 @@ first: ``` Passing `--source` explicitly updates the config file's `source` so the next -invocation doesn't have to repeat it — that's what makes repeat runs not +invocation doesn't have to repeat it. `--sign-with-key` and +`STELLAR_SIGN_WITH_KEY` deliberately do not — `stellar tx sign` accepts key +material for that setting, and persisting a secret to disk on your behalf is +not signet's call — that's what makes repeat runs not re-ask which identity to use. `--url` is read from the config file but never written back by a flag; edit the file (or keep using `--url`/`SIGNET_URL`) to change the configured deployment. See `internal/config` for the resolution diff --git a/cli/internal/cmd/root.go b/cli/internal/cmd/root.go index 6a16a43..2001ff4 100644 --- a/cli/internal/cmd/root.go +++ b/cli/internal/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/blockchain-maxis/signet/cli/internal/config" "github.com/blockchain-maxis/signet/cli/internal/exitcode" + "github.com/blockchain-maxis/signet/cli/internal/keys" ) func newRootCmd(version, commit string) *cobra.Command { @@ -31,6 +32,8 @@ instance) over its HTTP API.`, fmt.Sprintf("Signet deployment URL (overrides %s, the config file, and the default)", config.EnvBaseURL)) root.PersistentFlags().String("source", "", "identity to sign as (remembered in the config file for next time)") + root.PersistentFlags().String("sign-with-key", "", + fmt.Sprintf("identity to sign with, for non-interactive use (same value as %s; not remembered)", config.EnvSignWithKey)) // Resolves --url/--source/SIGNET_URL/the config file into the // configuration this run actually uses, and attaches it to the command's @@ -45,16 +48,28 @@ instance) over its HTTP API.`, flagURL, _ := cmd.Flags().GetString("url") flagSource, _ := cmd.Flags().GetString("source") + flagSignWith, _ := cmd.Flags().GetString("sign-with-key") + if cmd.Flags().Changed("sign-with-key") { + if err := keys.ValidateSignWithKey(flagSignWith); err != nil { + return err + } + } opts := config.ResolveOptions{ - FlagURL: flagURL, - FlagURLSet: cmd.Flags().Changed("url"), - FlagSource: flagSource, - FlagSourceSet: cmd.Flags().Changed("source"), - EnvBaseURL: os.Getenv(config.EnvBaseURL), + FlagURL: flagURL, + FlagURLSet: cmd.Flags().Changed("url"), + FlagSource: flagSource, + FlagSourceSet: cmd.Flags().Changed("source"), + FlagSignWithKey: flagSignWith, + FlagSignWithKeySet: cmd.Flags().Changed("sign-with-key"), + EnvBaseURL: os.Getenv(config.EnvBaseURL), + EnvSignWithKey: os.Getenv(config.EnvSignWithKey), } resolved := config.Resolve(opts, file) cmd.SetContext(config.WithResolved(cmd.Context(), resolved)) + // Deliberately only --source: --sign-with-key and STELLAR_SIGN_WITH_KEY + // are not written to the config file, because either may carry a secret + // and neither is a preference the user asked signet to remember. if opts.FlagSourceSet { if err := config.RememberSource(resolved.Source); err != nil { return fmt.Errorf("%w: saving identity to config file: %w", exitcode.ErrConfiguration, err) diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 466b205..ace973e 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -19,6 +19,12 @@ const DefaultBaseURL = "https://signet-web-pearl.vercel.app" // BaseURL, one level below an explicit --url flag. const EnvBaseURL = "SIGNET_URL" +// EnvSignWithKey is the identity `stellar tx sign --sign-with-key` already +// reads from the environment. signet honours the same variable rather than +// inventing a second one, so a CI job exports its deploy identity once and +// both tools see it (#254). +const EnvSignWithKey = "STELLAR_SIGN_WITH_KEY" + // fileName is the config file's name inside Dir(). const fileName = "config.json" @@ -127,10 +133,20 @@ type ResolveOptions struct { FlagURLSet bool FlagSource string FlagSourceSet bool + // FlagSignWithKey is --sign-with-key. It outranks --source because it is + // the more specific statement of intent ("sign non-interactively, as + // this"), and unlike --source it is never written to the config file: + // `stellar tx sign` accepts a raw secret or seed phrase for this, and + // persisting one to disk on the user's behalf is not signet's call. + FlagSignWithKey string + FlagSignWithKeySet bool // EnvBaseURL is the SIGNET_URL environment variable's value, or "" if // unset. Passed in rather than read via os.Getenv inside Resolve so tests // don't need to mutate process-global environment state. EnvBaseURL string + // EnvSignWithKey is STELLAR_SIGN_WITH_KEY's value, or "" if unset. Same + // reasoning as EnvBaseURL for why it is passed rather than read here. + EnvSignWithKey string } // Resolve combines flags, environment, the config file, and the built-in @@ -152,9 +168,19 @@ func Resolve(opts ResolveOptions, file File) Resolved { r.BaseURL = opts.FlagURL } + // Environment beats the config file but loses to an explicit flag, the same + // precedence BaseURL uses. The point is that a CI job which exports + // STELLAR_SIGN_WITH_KEY never reaches the interactive identity prompt — + // there is no terminal there to answer it. + if opts.EnvSignWithKey != "" { + r.Source = opts.EnvSignWithKey + } if opts.FlagSourceSet { r.Source = opts.FlagSource } + if opts.FlagSignWithKeySet { + r.Source = opts.FlagSignWithKey + } return r } diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 952d1f1..393e849 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -178,3 +178,53 @@ func TestResolveAnExplicitlyEmptyFlagStillWins(t *testing.T) { t.Fatalf("BaseURL = %q, want the explicit empty flag value to win", got.BaseURL) } } + +// ── non-interactive identity (#254) ────────────────────────────────────── + +func TestResolve_EnvSignWithKeySuppliesTheIdentity(t *testing.T) { + // A CI job exports STELLAR_SIGN_WITH_KEY once; both `stellar tx sign` and + // signet read it, so there is no second variable to keep in sync and no + // interactive prompt to hang on. + got := Resolve(ResolveOptions{EnvSignWithKey: "ci-deploy"}, File{}) + if got.Source != "ci-deploy" { + t.Fatalf("Source = %q, want ci-deploy", got.Source) + } +} + +func TestResolve_EnvSignWithKeyBeatsTheConfigFile(t *testing.T) { + got := Resolve(ResolveOptions{EnvSignWithKey: "ci-deploy"}, File{Source: "laptop"}) + if got.Source != "ci-deploy" { + t.Fatalf("Source = %q, want the environment to win over the config file", got.Source) + } +} + +func TestResolve_FlagsBeatTheEnvironment(t *testing.T) { + got := Resolve(ResolveOptions{ + EnvSignWithKey: "ci-deploy", + FlagSource: "laptop", + FlagSourceSet: true, + }, File{}) + if got.Source != "laptop" { + t.Fatalf("Source = %q, want --source to win", got.Source) + } +} + +func TestResolve_SignWithKeyFlagOutranksSource(t *testing.T) { + // --sign-with-key is the more specific statement of intent. + got := Resolve(ResolveOptions{ + FlagSource: "laptop", + FlagSourceSet: true, + FlagSignWithKey: "ci-deploy", + FlagSignWithKeySet: true, + }, File{}) + if got.Source != "ci-deploy" { + t.Fatalf("Source = %q, want --sign-with-key to win", got.Source) + } +} + +func TestResolve_UnsetSignWithKeyChangesNothing(t *testing.T) { + got := Resolve(ResolveOptions{}, File{Source: "laptop"}) + if got.Source != "laptop" { + t.Fatalf("Source = %q, want the config file's identity", got.Source) + } +} diff --git a/cli/internal/keys/sign.go b/cli/internal/keys/sign.go index 9a56ada..56fe2d8 100644 --- a/cli/internal/keys/sign.go +++ b/cli/internal/keys/sign.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os/exec" + "regexp" "strings" "github.com/blockchain-maxis/signet/cli/internal/exitcode" @@ -27,6 +28,9 @@ func runCommandWithStdin(binary, stdin string, args ...string) ([]byte, []byte, var runStdin stdinRunner = runCommandWithStdin +// secretKeyPattern matches a Stellar StrKey secret seed. +var secretKeyPattern = regexp.MustCompile(`^S[A-Z2-7]{55}$`) + // SignChallenge signs a SEP-10 challenge transaction with the named local // identity and returns the signed envelope. // @@ -101,3 +105,38 @@ func looksLikeXDR(s string) bool { } return true } + +// seedPhrasePattern matches a value that looks like a BIP-39 mnemonic: several +// lowercase words separated by single spaces. +var seedPhrasePattern = regexp.MustCompile(`^(?:[a-z]+ ){11,}[a-z]+$`) + +// ValidateSignWithKey rejects key *material* passed where an identity name +// belongs. +// +// `stellar tx sign --sign-with-key` accepts a raw secret or a seed phrase, but +// signet cannot: it has to resolve the deploy account's public key before it +// can ask for a challenge, and it does that with `stellar keys address ` +// precisely so key material never enters this process. Accepting a secret here +// would mean either holding it or lying about what got linked. +// +// It is also the wrong place for a secret regardless of what signet does with +// it — a value on argv is visible in shell history and to anyone who can run +// `ps`. Use `stellar keys add ` once and pass the name. +// +// The offending value is never echoed back, which is the point. +func ValidateSignWithKey(value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("%w: --sign-with-key was given an empty value", exitcode.ErrConfiguration) + } + if secretKeyPattern.MatchString(trimmed) || seedPhrasePattern.MatchString(trimmed) { + return fmt.Errorf( + "%w: --sign-with-key looks like key material, not an identity name. "+ + "signet resolves your public key through `stellar keys address`, so it needs a "+ + "name — and a secret on the command line is visible in shell history and to `ps`. "+ + "Run `stellar keys add ` once, then pass that name", + exitcode.ErrConfiguration, + ) + } + return nil +} diff --git a/cli/internal/keys/sign_test.go b/cli/internal/keys/sign_test.go index a00b9b5..134d13a 100644 --- a/cli/internal/keys/sign_test.go +++ b/cli/internal/keys/sign_test.go @@ -113,3 +113,39 @@ func TestLooksLikeXDR(t *testing.T) { } } } + +// ── non-interactive signing (#254) ─────────────────────────────────────── + +func TestValidateSignWithKey_AcceptsAnIdentityName(t *testing.T) { + for _, ok := range []string{"alice", "ci-deploy", "deploy_key_1"} { + if err := ValidateSignWithKey(ok); err != nil { + t.Fatalf("ValidateSignWithKey(%q) = %v", ok, err) + } + } +} + +func TestValidateSignWithKey_RefusesKeyMaterial(t *testing.T) { + secret := "S" + strings.Repeat("A", 55) + phrase := "abandon ability able about above absent absorb abstract absurd abuse access accident" + + for _, bad := range []string{secret, phrase} { + err := ValidateSignWithKey(bad) + if err == nil { + t.Fatalf("accepted key material: %q", bad[:8]) + } + if !errors.Is(err, exitcode.ErrConfiguration) { + t.Fatalf("err = %v, want a configuration error", err) + } + // The whole point: the value must not come back out in the message, + // which is headed for a terminal, a CI log, and a pasted bug report. + if strings.Contains(err.Error(), bad) { + t.Fatal("echoed the secret back in the error") + } + } +} + +func TestValidateSignWithKey_RefusesEmpty(t *testing.T) { + if err := ValidateSignWithKey(" "); !errors.Is(err, exitcode.ErrConfiguration) { + t.Fatalf("err = %v", err) + } +} diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 62b3b25..75c8efb 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -141,6 +141,8 @@ const ENV_ALLOW = new Set([ 'STELLAR_ACCOUNT', // shell override for deploy-contract.sh 'ADMIN_ADDRESS', // shell override for deploy-contract.sh 'SIGNET_URL', // shell env for the cli/ Go module, not the pnpm workspace's .env + 'STELLAR_SIGN_WITH_KEY', // shell env the stellar CLI already reads; cli/ honours it, not app env + 'SIGNET_DEPLOY_KEY', // CI secret holding the deploy key, exported as STELLAR_SIGN_WITH_KEY 'WAYLAND_DISPLAY', // standard desktop-session env var, not app config 'CLI_RELEASE_ENABLED', // GitHub repo variable, not app env — see release-cli.yml 'NPM_TOKEN', // GitHub Actions secret, not app env — see release-cli.yml