From ba97297f6665c6205019ad59502e292a5f52fa18 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 17:01:36 +0200 Subject: [PATCH] mesh-cli: give a terminal line the standard input mesh-cli carried, refuse it to any other, keep it nowhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A secret given at the controller's terminal through mesh-cli (secret accept … --from -) never reached the line: every line ran with no standard input. A line that runs as the terminal now reads what mesh-cli carried (at most 64 KiB); an ordinary call carrying any is refused and nothing runs; the calls record keeps only that some was given, the journal and the answer nothing of it. --- cmd/mesh-controller/meshcli.go | 12 ++- cmd/mesh-controller/meshcli_stdin_test.go | 99 +++++++++++++++++++++++ cmd/mesh-controller/sayable_test.go | 5 ++ internal/link/calls.go | 2 +- internal/link/meshcli.go | 30 ++++++- internal/link/meshcli_test.go | 27 +++++++ 6 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 cmd/mesh-controller/meshcli_stdin_test.go 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/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) + } +}