diff --git a/cmd/mesh-controller/meshcli.go b/cmd/mesh-controller/meshcli.go index c0255895..48c36865 100644 --- a/cmd/mesh-controller/meshcli.go +++ b/cmd/mesh-controller/meshcli.go @@ -141,6 +141,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV if v.refused != "" { return link.CLIRefusal(v.refused) } + // Standard input is the terminal's alone: a secret given at the terminal reaches `secret accept`, and no ordinary + // call is handed what the asker's standard input held (novox/hq ADR 0259 §10, ADR 0272). + if len(asked.Stdin) > 0 && !v.terminal { + return link.CLIAnswer{Exit: 1, Why: v.why, Refused: "standard input is given to a line that runs as the " + + "controller's terminal alone, and this one does not. Nothing ran"} + } if cliServers[asked.Line[0]] { return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+ "command line mesh-cli runs. Nothing ran", asked.Line[0])} @@ -155,8 +161,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV } cmd := selfCommand(ctx, line) cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal) - // No standard input: a command that reads one gets nothing, and fails saying so (ADR 0272 §5). + // No standard input unless mesh-cli carried one for a terminal line: a command that reads one gets nothing, and + // fails saying so (ADR 0272 §5). cmd.Stdin = nil + if len(asked.Stdin) > 0 { + cmd.Stdin = bytes.NewReader(asked.Stdin) + } var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() diff --git a/cmd/mesh-controller/meshcli_stdin_test.go b/cmd/mesh-controller/meshcli_stdin_test.go new file mode 100644 index 00000000..78971630 --- /dev/null +++ b/cmd/mesh-controller/meshcli_stdin_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" +) + +// secretAcceptWants makes the test binary, run as a command line, read a secret as `secret accept` reads it and +// say whether it is the value whose SHA-256 the variable names (TestMain). +const secretAcceptWants = "MESH_TEST_SECRET_ACCEPT_WANTS" + +// readAsSecretAccept is that process: `secret accept [--from -]`, the value read by +// valueFor, compared by digest, and only the verdict printed. +func readAsSecretAccept(want string, argv []string) int { + if len(argv) < 5 || argv[0] != "secret" || argv[1] != "accept" { + fmt.Printf("not a secret accept line: %q\n", argv) + return 2 + } + from := "" + if len(argv) == 7 && argv[5] == "--from" { + from = argv[6] + } + value, err := valueFor(argv[2], argv[3], argv[4], from) + if err != nil { + fmt.Printf("secret accept read nothing: %v\n", err) + return 1 + } + sum := sha256.Sum256([]byte(asSupplied(value))) + if hex.EncodeToString(sum[:]) != want { + fmt.Printf("secret accept read something else (%d bytes)\n", len(value)) + return 1 + } + fmt.Println("secret accept read the value it was given") + return 0 +} + +// novox/hq ADR 0259 §10, ADR 0272: what mesh-cli's standard input held reaches `secret accept` on a line that runs +// as the controller's terminal, and appears nowhere else — not in the answer, not in the journal, not in the calls +// record; an ordinary line carrying it is refused and nothing runs. +func TestStandardInputReachesSecretAcceptAtTheTerminalAndNowhereElse(t *testing.T) { + token := "123456789:AAEhBOweik6ad9r_QxGivenAtTheTerminal" + sum := sha256.Sum256([]byte(token)) + t.Setenv(secretAcceptWants, hex.EncodeToString(sum[:])) + var journal []string + var mu sync.Mutex + was := cliJournal + cliJournal = func(line string) { mu.Lock(); journal = append(journal, line); mu.Unlock() } + t.Cleanup(func() { cliJournal = was }) + ctx := context.Background() + + for _, line := range [][]string{ + {"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"}, + {"secret", "accept", "anchor", "telegram", "telegram-token"}, // the prompt's path, a line on standard input + } { + asked := cliAsked("operator", 1000, line...) + asked.Stdin = []byte(token + "\n") + a := runForMeshCLI(ctx, "control", asked, cliVerdict{terminal: true, why: "the terminal"}) + if a.Exit != 0 || !strings.Contains(string(a.Stdout), "read the value it was given") { + t.Fatalf("%q: secret accept did not read what mesh-cli carried: %+v (%s)", line, a, a.Stdout) + } + if body, _ := json.Marshal(a); strings.Contains(string(body), "AAEh") || strings.Contains(string(body), base64.StdEncoding.EncodeToString([]byte(token))) { + t.Fatalf("the answer carries the secret: %s", body) + } + } + + // Without standard input, the line reads nothing, as before. + a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "secret", "accept", "anchor", "telegram", + "telegram-token", "--from", "-"), cliVerdict{terminal: true, why: "the terminal"}) + if a.Exit == 0 { + t.Fatalf("a line with no standard input read a value: %+v", a) + } + + // An ordinary call is never handed it: refused, and nothing ran. + asked := cliAsked("operator", 1000, "status") + asked.Stdin = []byte(token) + a = runForMeshCLI(ctx, "laptop", asked, cliVerdict{why: "not the terminal"}) + if a.Exit == 0 || !strings.Contains(a.Refused, "terminal alone") || len(a.Stdout) != 0 { + t.Fatalf("an ordinary line was given standard input: %+v", a) + } + + mu.Lock() + defer mu.Unlock() + for _, l := range journal { + if strings.Contains(l, "AAEh") { + t.Fatalf("the journal says the secret: %s", l) + } + } + if len(journal) == 0 { + t.Fatal("the lines were not said in the journal at all") + } + +} diff --git a/cmd/mesh-controller/sayable_test.go b/cmd/mesh-controller/sayable_test.go index ab7951d5..d0919ec1 100644 --- a/cmd/mesh-controller/sayable_test.go +++ b/cmd/mesh-controller/sayable_test.go @@ -28,6 +28,11 @@ import ( func TestMain(m *testing.M) { // The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and // ends (meshcli_test.go). + // The process a mesh-cli test runs as `secret accept`: it reads its value exactly as `secret accept` does + // (valueFor) and says whether it is the one the test gave, never the value (meshcli_stdin_test.go). + if want := os.Getenv(secretAcceptWants); want != "" { + os.Exit(readAsSecretAccept(want, os.Args[1:])) + } if os.Getenv(echoEnvironment) != "" { fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal()) os.Exit(0) diff --git a/go.mod b/go.mod index d93602d8..9d115b51 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/novox/mesh-host v0.0.0 golang.org/x/crypto v0.57.0 golang.org/x/net v0.58.0 + golang.org/x/sys v0.48.0 ) require ( @@ -24,7 +25,6 @@ require ( github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/sync v0.23.0 // indirect - golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect ) @@ -35,4 +35,4 @@ require ( // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // `go mod vendor` fails loudly, at once, everywhere. -replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 diff --git a/go.sum b/go.sum index 8e1e030c..8fa5f4c3 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,5 @@ -git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e h1:g9h4QRaAMg5yaJLwqtb0FoOs23DVGUYpW6qvnQ3oY5A= -git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= -git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/t4GweK+JL0OjKSNxsrVP3/nMdpii8o= -git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= -git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E= -git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs= -git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74= -git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= -git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0= -git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= -git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI= -git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnLNInW7/oImV2OlXTrcQ4/GSM0Y8tAb+AhmY= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 h1:WHW6CgbuTxP7M+qRBOgzsiG9vT49xdkZ/rarc9/vKMA= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 h1:Vut7OdwSAL0rFaavaFmv3+FIGS3ISM4jA+xTiS88nvg= +git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56/go.mod h1:mBxSf6wULwn0bdpHkIHUnhTvNzqnULnjoRN28dUgSBU= git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU= git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= @@ -56,8 +38,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= diff --git a/internal/link/calls.go b/internal/link/calls.go index 29bbdc5a..1698df15 100644 --- a/internal/link/calls.go +++ b/internal/link/calls.go @@ -461,7 +461,7 @@ func kept(args json.RawMessage) json.RawMessage { for k, v := range given { s, isString := v.(string) switch { - case k == "values" || k == "secret": + case k == "values" || k == "secret" || k == "stdin": out[k] = "(given, not kept)" case k == "line": // A mesh-cli line (ADR 0272): its command word, never the rest, which may carry settings. diff --git a/internal/link/meshcli.go b/internal/link/meshcli.go index d18ef939..e58d0a7c 100644 --- a/internal/link/meshcli.go +++ b/internal/link/meshcli.go @@ -41,6 +41,30 @@ type CLIAsked struct { // (`session-.scope` under the account's own slice), or empty: a service, a user unit. Only a login session is // the terminal (novox/hq ADR 0272). Session string `json:"session,omitempty"` + // Stdin is what mesh-cli's standard input held, at most CLIMaxStdin: given to a line that runs as the terminal, + // as its standard input, and refused with any other (novox/hq ADR 0259 §10, ADR 0272). It is kept nowhere: not in + // the calls record (cliRecorded), not in the journal, not in the answer. + Stdin []byte `json:"stdin,omitempty"` +} + +// CLIMaxStdin bounds the standard input a mesh-cli line carries (mesh-sdk go/cli MaxStdin). +const CLIMaxStdin = 64 << 10 + +// cliRecorded is the request as the calls record keeps it: everything but the standard input, which the record +// never holds in any form. The line itself is cut to its command word by the record's own rule (kept). +func cliRecorded(raw json.RawMessage) json.RawMessage { + var m map[string]json.RawMessage + if json.Unmarshal(raw, &m) != nil { + return json.RawMessage(`{}`) + } + if _, given := m["stdin"]; given { + // Said as given, under a key of its own: the record still reads as the request it was (followCLI decodes + // it), and holds nothing of the input. + delete(m, "stdin") + m["stdin-given"] = json.RawMessage(`true`) + } + body, _ := json.Marshal(m) + return body } // CLIAnswer is what the controller answers, as the `result` of a call's answer: what the command printed, how it @@ -135,6 +159,10 @@ func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{}, case len(asked.Line) == 0: respond(cliEnvelope(CLIRefusal("the request names no command, so nothing ran"))) return + case len(asked.Stdin) > CLIMaxStdin: + respond(cliEnvelope(CLIRefusal(fmt.Sprintf("standard input of more than %d bytes is not taken, so nothing ran", + CLIMaxStdin)))) + return } select { case slots <- struct{}{}: @@ -145,7 +173,7 @@ func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{}, return } limit := b.Conn.MaxPayload() - calls.serveCallWithin(CLISeat, node, msg.Data, msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) { + calls.serveCallWithin(CLISeat, node, cliRecorded(msg.Data), msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) { return fitCLI(handle(ctx, node, asked), limit-4096), nil }, msg.Respond, limit, logger) } diff --git a/internal/link/meshcli_test.go b/internal/link/meshcli_test.go index 9a7f79d9..1fc50085 100644 --- a/internal/link/meshcli_test.go +++ b/internal/link/meshcli_test.go @@ -30,6 +30,10 @@ func TestTheMeshCLIRequestAndAnswerKeepTheirFieldNames(t *testing.T) { if got := keysIn(t, body); got != "cut exit refused stderr stdout terminal why" { t.Fatalf("the answer's fields are %q", got) } + body, _ = json.Marshal(CLIAsked{Line: []string{"secret"}, Account: "a", UID: 1, Stdin: []byte("x")}) + if got := keysIn(t, body); got != "account line stdin uid" { + t.Fatalf("a request with standard input has the fields %q", got) + } body, _ = json.Marshal(CLIAsked{Follow: "call-1", Account: "a", UID: 1}) if got := keysIn(t, body); got != "account follow uid" { t.Fatalf("a follow's fields are %q", got) @@ -267,3 +271,26 @@ func TestTheWithholdingTestsHoldSomething(t *testing.T) { t.Fatalf("a record carrying the answer is not found carrying it: %s", body) } } + +// novox/hq ADR 0259 §10: mesh-cli's standard input — a secret given at the terminal — is never in the calls record, +// in any form, whichever way the request reaches it. +func TestAMeshCLIRecordNeverKeepsItsStandardInput(t *testing.T) { + secret := "123456789:AAEhBOweik6ad9r_QxGivenAtTheTerminal" + raw, _ := json.Marshal(CLIAsked{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"}, + Account: "operator", UID: 1000, Stdin: []byte(secret)}) + for name, got := range map[string]json.RawMessage{"as answerCLI records it": kept(cliRecorded(raw)), + "as the record alone would keep it": kept(raw)} { + if carries(got, secret) || strings.Contains(string(got), base64.StdEncoding.EncodeToString([]byte(secret))[:20]) { + t.Fatalf("%s, the record keeps %s", name, got) + } + if !strings.Contains(string(got), "secret") || !strings.Contains(string(got), "given, not kept") && + !strings.Contains(string(got), "stdin-given") { + t.Fatalf("%s, the record does not say a line was asked with standard input: %s", name, got) + } + } + // The record still reads as the request, so the asker can follow its call. + var asked CLIAsked + if err := json.Unmarshal(kept(cliRecorded(raw)), &asked); err != nil || asked.Account != "operator" || len(asked.Stdin) > 0 { + t.Fatalf("the recorded request reads as %+v (%v)", asked, err) + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 93fd8b8f..48e84f6e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -78,7 +78,7 @@ github.com/nats-io/nkeys # github.com/nats-io/nuid v1.0.1 ## explicit github.com/nats-io/nuid -# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -135,4 +135,4 @@ golang.org/x/text/width # golang.org/x/time v0.15.0 ## explicit; go 1.25.0 golang.org/x/time/rate -# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56