From 83f8fe3e0d36ebaeda33a73a9e1d1fd2de22e411 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 3 Jul 2026 15:18:28 +0300 Subject: [PATCH 1/4] Add envelope sign/verify IPC commands and pilotctl front-ends - CmdSignEnvelope (0x33): the daemon constructs and signs pilot-req-v1 envelopes itself; no code path signs caller-supplied bytes (the IPC socket admits any same-UID process, so this is the security boundary) - CmdVerifyEnvelope (0x35): local-first key resolution via the key-exchange peer cache, registry fallback, optional standing check - pilotctl sign-request / verify-request - docs/SIGNATURE-VERIFICATION.md: full three-surface spec --- cmd/pilotctl/appstore_update_test.go | 2 +- cmd/pilotctl/main.go | 51 +++ cmd/pilotctl/sign_request.go | 94 ++++ cmd/pilotctl/zz_sign_request_test.go | 184 ++++++++ docs/SIGNATURE-VERIFICATION.md | 213 +++++++++ pkg/daemon/ipc.go | 236 ++++++++++ pkg/daemon/tunnel.go | 7 + pkg/daemon/zz_ipc_envelope_handlers_test.go | 451 ++++++++++++++++++++ 8 files changed, 1237 insertions(+), 1 deletion(-) create mode 100644 cmd/pilotctl/sign_request.go create mode 100644 cmd/pilotctl/zz_sign_request_test.go create mode 100644 docs/SIGNATURE-VERIFICATION.md create mode 100644 pkg/daemon/zz_ipc_envelope_handlers_test.go diff --git a/cmd/pilotctl/appstore_update_test.go b/cmd/pilotctl/appstore_update_test.go index 2f081cc0..77f718b2 100644 --- a/cmd/pilotctl/appstore_update_test.go +++ b/cmd/pilotctl/appstore_update_test.go @@ -16,7 +16,7 @@ func TestSemverCompare(t *testing.T) { {"1.0.0", "1.0.1", -1}, {"1.2.0", "1.1.9", 1}, {"2.0.0", "1.9.9", 1}, - {"1.2", "1.2.0", 0}, // missing component == 0 + {"1.2", "1.2.0", 0}, // missing component == 0 {"1.2.3-rc.1", "1.2.3", 0}, // prerelease ignored in the core compare {"0.10.0", "0.9.0", 1}, // numeric, not lexical } diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index 11ceb89e..aa108b49 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -1136,6 +1136,39 @@ Use this for a clean shutdown when you no longer want this node to be reachable. Note: daemon stop does NOT deregister — the 5-minute TTL reaps inactive nodes automatically. Only use deregister if you want immediate removal. +`, + "sign-request": `Usage: pilotctl sign-request --audience (--body-file | --body-hash <64hex> | --body '') + +Sign a request-signature envelope (reqsig) proving a request originates from +this node. The daemon constructs the envelope itself — its own address, the +current timestamp, a fresh nonce, the body hash and the audience — and signs +the canonical form with the node's Ed25519 identity key. It never signs +caller-supplied raw strings. + +Exactly one body source is required: + --body-file hash the file's bytes (sha256) + --body '' hash the literal string + --body-hash <64hex> use a precomputed sha256 body hash + +Prints {envelope, signature, address}. Attach envelope + signature to the +request; the consuming service verifies them against this node's registered +public key (or via: pilotctl verify-request). +`, + "verify-request": `Usage: pilotctl verify-request --envelope '' --signature '' [--standing] [--max-skew ] + +Verify a request-signature envelope produced by a peer's sign-request. The +daemon parses the envelope, checks freshness, resolves the claimed node's +public key (local key cache first, registry on miss) and verifies the +signature. + +Flags: + --standing also report the signer's registry standing when + available (online, last_seen_unix, key_generation, + network_member) + --max-skew freshness window in seconds (default 300) + +Prints the daemon's verdict. Exit code 1 when the envelope does not verify +(reply carries valid:false plus a reason). `, "rotate-key": `Usage: pilotctl rotate-key @@ -1476,6 +1509,10 @@ Identity & recovery: pilotctl recovery ... enroll / rotate / reclaim the address if the key is lost pilotctl review [--rating <1-5>] [--text "..."] rate Pilot or an installed app +Request signing (prove a request originates from this node): + pilotctl sign-request --audience (--body-file | --body-hash <64hex> | --body '') + pilotctl verify-request --envelope '' --signature '' [--standing] [--max-skew ] + Management commands: pilotctl connections pilotctl disconnect @@ -1695,6 +1732,10 @@ dispatch: cmdVerify(cmdArgs) case "recovery": cmdRecovery(cmdArgs) + case "sign-request": + cmdSignRequest(cmdArgs) + case "verify-request": + cmdVerifyRequest(cmdArgs) // Discovery case "find": @@ -2158,6 +2199,16 @@ func contextCatalog() map[string]interface{} { "description": "Rotate this node's Ed25519 identity key", "returns": "node_id, address, new public_key", }, + "sign-request": map[string]interface{}{ + "args": []string{"--audience ", "(--body-file | --body-hash <64hex> | --body )"}, + "description": "Sign a request-signature envelope (reqsig) proving a request originates from this node", + "returns": "envelope, signature, address", + }, + "verify-request": map[string]interface{}{ + "args": []string{"--envelope ", "--signature ", "[--standing]", "[--max-skew ]"}, + "description": "Verify a peer's request-signature envelope; exit code 1 when invalid", + "returns": "valid, node_id, address, verified_via, trusted [, online, last_seen_unix, key_generation, network_member]", + }, // Trust "handshake": map[string]interface{}{ diff --git a/cmd/pilotctl/sign_request.go b/cmd/pilotctl/sign_request.go new file mode 100644 index 00000000..ad51602e --- /dev/null +++ b/cmd/pilotctl/sign_request.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "os" + + "github.com/pilot-protocol/common/reqsig" +) + +// cmdSignRequest asks the daemon to sign a request-signature envelope +// (common/reqsig) for a consuming service. The daemon constructs the +// envelope itself (its own address, current timestamp, fresh nonce) — the +// CLI only supplies the audience and the body hash. Exactly one body +// source is required: +// +// pilotctl sign-request --audience svc.example.io --body-file req.json +// pilotctl sign-request --audience svc.example.io --body '{"q":"x"}' +// pilotctl sign-request --audience svc.example.io --body-hash <64 hex> +func cmdSignRequest(args []string) { + flags, _ := parseFlags(args) + audience := flagString(flags, "audience", "") + if audience == "" { + fatalCode("invalid_argument", "usage: pilotctl sign-request --audience (--body-file | --body-hash <64hex> | --body '')") + } + + bodyHash := flagString(flags, "body-hash", "") + bodyFile := flagString(flags, "body-file", "") + body, bodySet := flags["body"] + sources := 0 + if bodyHash != "" { + sources++ + } + if bodyFile != "" { + sources++ + } + if bodySet { + sources++ + } + if sources != 1 { + fatalCode("invalid_argument", "sign-request: exactly one of --body-file, --body-hash, --body is required") + } + switch { + case bodyFile != "": + data, err := os.ReadFile(bodyFile) + if err != nil { + fatalCode("invalid_argument", "sign-request: read %s: %v", bodyFile, err) + } + bodyHash = reqsig.HashBody(data) + case bodySet: + bodyHash = reqsig.HashBody([]byte(body)) + } + + d := connectDriver() + defer d.Close() + resp, err := d.SignEnvelope(audience, bodyHash) + if err != nil { + fatalCode("connection_failed", "sign-request: %v", err) + } + output(map[string]interface{}{ + "envelope": resp["envelope"], + "signature": resp["signature"], + "address": resp["address"], + }) +} + +// cmdVerifyRequest checks a reqsig envelope + signature via the daemon, +// which resolves the claimed node's key from its local cache first and the +// registry on miss. Prints the daemon's reply; exits 1 when the daemon +// reports valid:false. +func cmdVerifyRequest(args []string) { + flags, _ := parseFlags(args) + envelope := flagString(flags, "envelope", "") + sig := flagString(flags, "signature", "") + if envelope == "" || sig == "" { + fatalCode("invalid_argument", "usage: pilotctl verify-request --envelope '' --signature '' [--standing] [--max-skew ]") + } + standing := flagBool(flags, "standing") + maxSkew := flagInt(flags, "max-skew", 0) + if maxSkew < 0 { + fatalCode("invalid_argument", "verify-request: --max-skew must be >= 0") + } + + d := connectDriver() + defer d.Close() + resp, err := d.VerifyEnvelopeMaxSkew(envelope, sig, standing, uint32(maxSkew)) + if err != nil { + fatalCode("connection_failed", "verify-request: %v", err) + } + output(resp) + if valid, _ := resp["valid"].(bool); !valid { + os.Exit(1) + } +} diff --git a/cmd/pilotctl/zz_sign_request_test.go b/cmd/pilotctl/zz_sign_request_test.go new file mode 100644 index 00000000..0909bf8a --- /dev/null +++ b/cmd/pilotctl/zz_sign_request_test.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/pilot-protocol/common/reqsig" +) + +// IPC cmd codes for the request-signature envelope commands — must match +// daemon CmdSignEnvelope/CmdVerifyEnvelope and driver cmdSignEnvelope/ +// cmdVerifyEnvelope. Kept here (not zz_fake_daemon_test.go) alongside the +// only tests that use them. +const ( + tdCmdSignEnvelope byte = 0x33 + tdCmdSignEnvelopeOK byte = 0x34 + tdCmdVerifyEnvelope byte = 0x35 + tdCmdVerifyEnvelopeOK byte = 0x36 +) + +// receivedPayload scans the fake daemon's received frames for cmd and +// returns the decoded JSON payload of the first match. +func receivedPayload(t *testing.T, d *fakeDaemon, cmd byte) map[string]interface{} { + t.Helper() + d.mu.Lock() + defer d.mu.Unlock() + for _, frame := range d.received { + if len(frame) > 1 && frame[0] == cmd { + var got map[string]interface{} + if err := json.Unmarshal(frame[1:], &got); err != nil { + t.Fatalf("frame 0x%02X payload not JSON: %v", cmd, err) + } + return got + } + } + t.Fatalf("no frame with cmd 0x%02X received", cmd) + return nil +} + +// TestCLISignRequestEnvelope covers the happy path: the CLI hashes --body +// locally, sends {audience, body_hash} to the daemon, and prints +// {envelope, signature, address}. +func TestCLISignRequestEnvelope(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + d.onJSON(tdCmdSignEnvelope, tdCmdSignEnvelopeOK, + `{"type":"sign_envelope_ok","envelope":"pilot-req-v1|env","signature":"c2ln","address":"0:0000.0000.0007"}`) + + stdout, stderr, code := runCLI(t, []string{ + "--json", "sign-request", + "--audience", "svc.example.io", + "--body", "hello envelope", + }, map[string]string{"PILOT_SOCKET": d.path}) + if code != 0 { + t.Fatalf("exit=%d\nstdout=%s\nstderr=%s", code, stdout, stderr) + } + + var env map[string]interface{} + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("json: %v (stdout=%q)", err, stdout) + } + data, _ := env["data"].(map[string]interface{}) + if data["envelope"] != "pilot-req-v1|env" || data["signature"] != "c2ln" || data["address"] != "0:0000.0000.0007" { + t.Errorf("data = %+v", data) + } + + // The daemon must have been asked for the locally computed sha256, never + // the raw body. + got := receivedPayload(t, d, tdCmdSignEnvelope) + if got["audience"] != "svc.example.io" { + t.Errorf("audience = %v", got["audience"]) + } + if got["body_hash"] != reqsig.HashBody([]byte("hello envelope")) { + t.Errorf("body_hash = %v, want sha256 of --body", got["body_hash"]) + } +} + +func TestCLISignRequestEnvelopeRequiresAudience(t *testing.T) { + t.Parallel() + _, stderr, code := runCLI(t, []string{"sign-request", "--body", "x"}, nil) + if code == 0 { + t.Error("expected non-zero exit without --audience") + } + if !strings.Contains(stderr, "audience") { + t.Errorf("expected usage mention of --audience, got: %s", stderr) + } +} + +func TestCLISignRequestEnvelopeRequiresOneBodySource(t *testing.T) { + t.Parallel() + // None given. + _, stderr, code := runCLI(t, []string{"sign-request", "--audience", "svc.example.io"}, nil) + if code == 0 { + t.Error("expected non-zero exit without a body source") + } + if !strings.Contains(stderr, "exactly one") { + t.Errorf("expected 'exactly one' error, got: %s", stderr) + } + // Two given. + _, stderr, code = runCLI(t, []string{ + "sign-request", "--audience", "svc.example.io", + "--body", "x", "--body-hash", strings.Repeat("a", 64), + }, nil) + if code == 0 { + t.Error("expected non-zero exit with two body sources") + } + if !strings.Contains(stderr, "exactly one") { + t.Errorf("expected 'exactly one' error, got: %s", stderr) + } +} + +// TestCLIVerifyRequestEnvelopeValid: a valid verdict prints the daemon reply +// and exits 0; flags round-trip onto the wire. +func TestCLIVerifyRequestEnvelopeValid(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + d.onJSON(tdCmdVerifyEnvelope, tdCmdVerifyEnvelopeOK, + `{"type":"verify_envelope_ok","valid":true,"node_id":7,"address":"0:0000.0000.0007","verified_via":"cache","trusted":true}`) + + stdout, stderr, code := runCLI(t, []string{ + "--json", "verify-request", + "--envelope", "pilot-req-v1|env", + "--signature", "c2ln", + "--standing", + "--max-skew", "600", + }, map[string]string{"PILOT_SOCKET": d.path}) + if code != 0 { + t.Fatalf("exit=%d\nstdout=%s\nstderr=%s", code, stdout, stderr) + } + var env map[string]interface{} + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("json: %v (stdout=%q)", err, stdout) + } + data, _ := env["data"].(map[string]interface{}) + if valid, _ := data["valid"].(bool); !valid { + t.Errorf("data = %+v, want valid=true", data) + } + + got := receivedPayload(t, d, tdCmdVerifyEnvelope) + if got["envelope"] != "pilot-req-v1|env" || got["signature"] != "c2ln" { + t.Errorf("payload = %+v", got) + } + if cs, _ := got["check_standing"].(bool); !cs { + t.Errorf("check_standing = %v, want true (--standing)", got["check_standing"]) + } + if skew, _ := got["max_skew_secs"].(float64); skew != 600 { + t.Errorf("max_skew_secs = %v, want 600 (--max-skew)", got["max_skew_secs"]) + } +} + +// TestCLIVerifyRequestEnvelopeInvalidExitsOne: valid:false still prints the +// verdict but the process exits 1 (scriptable gate). +func TestCLIVerifyRequestEnvelopeInvalidExitsOne(t *testing.T) { + t.Parallel() + d := newFakeDaemon(t) + d.onJSON(tdCmdVerifyEnvelope, tdCmdVerifyEnvelopeOK, + `{"type":"verify_envelope_ok","valid":false,"reason":"reqsig: signature verification failed"}`) + + stdout, _, code := runCLI(t, []string{ + "--json", "verify-request", + "--envelope", "pilot-req-v1|env", + "--signature", "c2ln", + }, map[string]string{"PILOT_SOCKET": d.path}) + if code != 1 { + t.Fatalf("exit=%d, want 1 on valid:false", code) + } + if !strings.Contains(stdout, "signature verification failed") { + t.Errorf("verdict reason missing from output: %s", stdout) + } +} + +func TestCLIVerifyRequestEnvelopeRequiresArgs(t *testing.T) { + t.Parallel() + _, stderr, code := runCLI(t, []string{"verify-request", "--envelope", "e"}, nil) + if code == 0 { + t.Error("expected non-zero exit without --signature") + } + if !strings.Contains(stderr, "--signature") && !strings.Contains(stderr, "signature") { + t.Errorf("expected usage mention of --signature, got: %s", stderr) + } +} diff --git a/docs/SIGNATURE-VERIFICATION.md b/docs/SIGNATURE-VERIFICATION.md new file mode 100644 index 00000000..6aa40b45 --- /dev/null +++ b/docs/SIGNATURE-VERIFICATION.md @@ -0,0 +1,213 @@ +# External Signature Verification + +Status: implemented (registry endpoint, daemon IPC, app-store capability) + +This document specifies how a service — on or off the Pilot network — can +verify that a request originates from a registered Pilot node, that the +signature was made by that node's registered key (not an arbitrary ed25519 +key), and whether the node is currently online and in good standing. It also +specifies the per-node rate-limiting capability this enables for apps. + +Three exposure surfaces share one envelope format: + +| Surface | Consumer | Package / endpoint | +|---|---|---| +| Registry HTTPS oracle | off-network web services | `POST /api/v1/verify` on the registry dashboard | +| Daemon IPC | processes on a Pilot host, incl. store apps | `CmdSignEnvelope` (0x33), `CmdVerifyEnvelope` (0x35) | +| Offline verdict check | anyone holding a verdict | `common/reqsig.VerifyVerdict*` | + +## 1. Request envelope (`common/reqsig`) + +A node proves "this request, from me, for you, now" by signing the canonical +envelope string: + +``` +pilot-req-v1|aaaaaaaaaaaa|ts|nnnnnnnnnnnnnnnn|hhhh…64…hhhh|audience +``` + +- `aaaa…` — 12 lowercase hex chars: 16-bit network + 32-bit node ID. The + text address format (`N:NNNN.HHHH.LLLL`) is never used inside the envelope + because it contains delimiter characters. +- `ts` — canonical decimal unix seconds. +- `nonce` — 16 lowercase hex chars (8 random bytes). +- `body hash` — sha256 hex of the request body (the body itself never + travels to a verifier). +- `audience` — the consuming service's identifier, `[a-z0-9.-]{1,64}`. + Binds the signature to one recipient so it cannot be replayed to another. + +Every field has a fixed charset that excludes `|`, so the encoding is +unambiguous. Parsers reject non-canonical encodings bit-for-bit. + +**Freshness.** Verifiers reject envelopes outside a ±300 s window +(`reqsig.DefaultMaxSkew` — generous deliberately: the fleet includes embedded +devices with real clock skew). Nonce deduplication within the window is the +consuming service's responsibility; verifiers echo the nonce so services can +key a dedup cache on it. + +**Domain separation.** The `pilot-req-v1` prefix ensures an envelope +signature can never be confused with protocol-internal signatures +(heartbeats, key exchange, badges) — and vice versa: a signature captured off +the wire never verifies as an envelope. + +## 2. Registry endpoint + +`POST /api/v1/verify` on the registry dashboard (HTTPS is terminated at the +edge; the endpoint must not be exposed without it). + +Request: `{"envelope": "", "signature": ""}` (body capped +at 8 KB). Response: + +```json +{ + "valid": true, + "online": true, + "network_member": true, + "address": "1:0001.ABCD.1234", + "last_seen": "2026-07-03T09:00:00Z", + "last_seen_unix": 1782205200, + "key_generation": 3, + "stale_threshold_secs": 1800, + "nonce": "0123456789abcdef", + "verdict": "pilot-verdict-v1|…", + "verdict_sig": "", + "verdict_kid": "vfy-v1" +} +``` + +Semantics: + +- `valid` — the signature verifies against the **registered, unexpired** key + for the node named in the envelope, and the envelope is fresh. A key that + was never registered has no address binding, so "random ed25519 key" + fails structurally. +- `online` — the node's last signature-verified heartbeat is within **180 s** + (three missed 60 s heartbeats). This is *standing*, not reachability: the + node is registered, heartbeating, unexpired, not reaped. Note the + registry's heartbeat path skips re-verification for 120 s after a verified + heartbeat, so a dead node can appear online for at most ~5 minutes. +- `network_member` — the node is a member of the network named in the + envelope's address prefix. Key binding is per-node; the network half is + membership, reported separately. +- Nodes offline longer than the stale threshold (default 30 min) are reaped + and become **unverifiable** (`valid: false`) until they re-register. Dead + identities stop authenticating by design. + +**Uniform failure.** Unknown node, reaped node, bad signature, stale +timestamp, and expired key all return the same `{"valid": false}` shape. +The endpoint is not an existence oracle over the address space. + +**Abuse controls.** Per-IP rate limit (60 req/min), `dashboard.verify` +breaker (operators can kill the endpoint during incidents), POST-only, +size-capped body. + +`GET /api/v1/verify/keys` returns the verdict issuer keys: +`{"keys":[{"kid":"vfy-v1","algo":"ed25519","public_key":""}]}`. + +## 3. Signed verdicts + +The registry signs its answer so consumers can cache it, forward it as +proof, or check it offline: + +``` +pilot-verdict-v1|envhash|aaaaaaaaaaaa|v|o|m|last_seen|keygen|verified_at +``` + +`envhash` binds the verdict to the exact envelope verified. The issuer key is +held by the registry (auto-generated, persisted next to the registry +snapshot; KMS custody is the planned upgrade) and published at +`/api/v1/verify/keys`. Consumers verify with +`reqsig.VerifyVerdictWithKey`, or `reqsig.VerifyVerdict(kid, …)` when a +keyring is baked at build time via +`-ldflags "-X github.com/pilot-protocol/common/reqsig.verdictKeyringB64=vfy-v1="`. +Negative verdicts are signed too. + +## 4. Daemon IPC + +Two commands (mirrored in `common/driver`): + +**`CmdSignEnvelope` (0x33)** — mint an envelope. Payload +`{"audience": "...", "body_hash": "<64 hex>"}` (or `body_b64`; nonce +optional). The daemon **constructs the envelope itself** — its own address, +its own clock — and signs only that. There is no code path that signs a +caller-supplied string. This restriction is the security boundary, because +the daemon socket admits any same-UID process: a hostile local process must +not be able to obtain a node signature over a heartbeat, key-rotation +payload, another node's request, or any other protocol-internal message. + +**`CmdVerifyEnvelope` (0x35)** — verify one. Payload +`{"envelope": "...", "signature": "...", "check_standing": bool, +"max_skew_secs": n}`. Key resolution is **local-first**: the key-exchange +peer-key cache (populated during authenticated handshakes), falling back to +a registry lookup on miss. `verified_via` reports which. `trusted` reports +handshake-trust-store membership. With `check_standing`, a registry lookup +adds `online` / `last_seen_unix` / `key_generation` / `network_member` when +the registry provides them. Verification of already-known peers never +touches the registry. + +pilotctl front-ends: `pilotctl sign-request --audience --body-file ` +and `pilotctl verify-request --envelope … --signature … [--standing]`. + +## 5. App-store capability + +Apps get verified sender identity two ways (`app-store` module): + +**Daemon-attested origin.** Brokered IPC envelopes carry an optional +`origin` block — `{node, node_id, authenticated, trusted}` — set only by the +trusted daemon bridge (`Service.CallWithOrigin`). The broker strips any +origin supplied by an app on cross-app calls, so apps cannot forge it +through the broker. Trust boundary: a same-UID process dialing `app.sock` +directly can forge anything; same-UID is the platform trust boundary until +OS sandboxing lands. + +**`pkg/appkit`.** Stdlib-only helpers: + +```go +limiter := appkit.PerNodeLimiter(100, time.Minute) +d.Register("myapp.query", + appkit.RequireOrigin(appkit.LimitPerNode(limiter, handler))) +``` + +- `RequireOrigin` — rejects requests without an authenticated origin. +- `LimitPerNode` — sliding-window rate limit keyed on the **verified node + identity** (transport addresses are meaningless here: relayed traffic + makes unrelated nodes share endpoints). +- `RequireEnvelope(verify, next)` — for out-of-band traffic (e.g. an app's + HTTPS backend): extracts `pilot_envelope` / `pilot_signature` from the + request payload, verifies via an injected `VerifyFunc` (wire it to + `driver.VerifyEnvelope`), synthesizes the origin on success. + +The `identity.verify` manifest capability declares that an app calls the +daemon's verification IPC (declarative/consent, like the rest of the grant +model). + +**Sybil note.** Per-node limiting is per-*identity* limiting. It stops one +node hammering a service; it does not stop someone registering many +identities. Under Sybil pressure, incorporate standing signals already +carried in the origin/verdict (trusted, network_member, badge status) into +the limiter key or quota. + +## 6. Threat model summary + +| Threat | Mitigation | +|---|---| +| Signature from unregistered key | address→registered-key lookup; nothing to look up | +| Replay of a captured request | timestamp window + audience binding + consumer nonce dedup | +| Cross-service replay | `audience` field in the signed envelope | +| Cross-protocol signature confusion | `pilot-req-v1` domain separation both directions | +| Address-space enumeration via the endpoint | uniform `valid:false`; per-IP rate limit | +| Local process abusing node key via IPC | CmdSignEnvelope constructs+signs only well-formed envelopes for its own address | +| Origin forgery through the broker | broker strips caller-supplied origin | +| Dead node appearing alive | 180 s online window; ≤~5 min worst case (documented) | +| Verdict tampering / transport trust | ed25519-signed verdicts, offline-checkable, negatives signed too | +| Registry endpoint abuse | rate limit + breaker + body cap + POST-only | + +## 7. Operational notes + +- The verdict key is generated on first start and persisted (0600) next to + the registry snapshot. Rotation = new kid + republish at + `/api/v1/verify/keys`; migrate custody to KMS alongside the badge key. +- `stale_threshold_secs` and `last_seen` are returned so consumers can apply + stricter freshness than the default `online` window. +- The `online` definition (180 s) is intentionally decoupled from the + reaper's stale threshold (30 min); do not conflate them — any node still + in the map is trivially within the reaper threshold. diff --git a/pkg/daemon/ipc.go b/pkg/daemon/ipc.go index 98bb050c..6e880ea8 100644 --- a/pkg/daemon/ipc.go +++ b/pkg/daemon/ipc.go @@ -21,6 +21,7 @@ import ( "github.com/pilot-protocol/common/badgeverify" "github.com/pilot-protocol/common/ipcutil" "github.com/pilot-protocol/common/protocol" + "github.com/pilot-protocol/common/reqsig" ) // IPC commands (daemon ↔ driver) @@ -96,6 +97,18 @@ const ( CmdSubmitBadgeOK byte = 0x30 CmdEnrollRecovery byte = 0x31 CmdEnrollRecoveryOK byte = 0x32 + // CmdSignEnvelope asks the daemon to sign a request-signature envelope + // (common/reqsig) naming its OWN address, and CmdVerifyEnvelope checks a + // peer-produced envelope + signature against the peer's registered + // Ed25519 key. The daemon CONSTRUCTS the envelope itself — network/node + // from its own address, timestamp = now — and signs only the reqsig + // canonical form; there is deliberately no path that signs a + // caller-supplied raw string. (Must match driver + // cmdSignEnvelope/cmdVerifyEnvelope in common/driver.) + CmdSignEnvelope byte = 0x33 + CmdSignEnvelopeOK byte = 0x34 + CmdVerifyEnvelope byte = 0x35 + CmdVerifyEnvelopeOK byte = 0x36 ) // Network sub-commands (second byte of CmdNetwork payload) @@ -829,6 +842,10 @@ func (s *IPCServer) dispatch(conn *ipcConn, cmd byte, reqID uint64, payload []by s.handleSubmitBadge(conn, reqID, payload) case CmdEnrollRecovery: s.handleEnrollRecovery(conn, reqID, payload) + case CmdSignEnvelope: + s.handleSignEnvelope(conn, reqID, payload) + case CmdVerifyEnvelope: + s.handleVerifyEnvelope(conn, reqID, payload) default: s.sendError(conn, reqID, fmt.Sprintf("unknown command: 0x%02X", cmd)) } @@ -1613,6 +1630,225 @@ func (s *IPCServer) handleEnrollRecovery(conn *ipcConn, reqID uint64, payload [] } } +// handleSignEnvelope services CmdSignEnvelope. The daemon constructs the +// reqsig.Envelope ITSELF — Network/Node from its own registered address, +// Timestamp = now, Nonce generated here unless the caller supplied one — and +// signs only the canonical string reqsig produces from those fields. There is +// deliberately NO code path that signs a caller-supplied raw string: the IPC +// socket admits any same-UID process, so refusing raw-string signing is the +// boundary that keeps a local process from using the daemon as a signing +// oracle for protocol-internal messages (heartbeats, key exchange, badges — +// see reqsig's domain prefix). +func (s *IPCServer) handleSignEnvelope(conn *ipcConn, reqID uint64, payload []byte) { + var req struct { + Audience string `json:"audience"` + BodyHash string `json:"body_hash"` + BodyB64 string `json:"body_b64"` + Nonce string `json:"nonce"` + } + if err := json.Unmarshal(payload, &req); err != nil { + s.sendError(conn, reqID, fmt.Sprintf("sign_envelope: bad payload: %v", err)) + return + } + if req.Audience == "" { + s.sendError(conn, reqID, "sign_envelope: audience required") + return + } + bodyHash := req.BodyHash + if req.BodyB64 != "" { + if bodyHash != "" { + s.sendError(conn, reqID, "sign_envelope: body_hash and body_b64 are mutually exclusive") + return + } + body, err := base64.StdEncoding.DecodeString(req.BodyB64) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("sign_envelope: body_b64 not base64: %v", err)) + return + } + bodyHash = reqsig.HashBody(body) + } + if bodyHash == "" { + s.sendError(conn, reqID, "sign_envelope: body_hash or body_b64 required") + return + } + nonce := req.Nonce + if nonce == "" { + var err error + if nonce, err = reqsig.NewNonce(); err != nil { + s.sendError(conn, reqID, fmt.Sprintf("sign_envelope: %v", err)) + return + } + } + addr := s.daemon.Addr() + if addr.Node == 0 { + addr.Node = s.daemon.NodeID() + } + if addr.Node == 0 { + s.sendError(conn, reqID, "sign_envelope: daemon not registered (no address)") + return + } + env := reqsig.Envelope{ + Network: addr.Network, + Node: addr.Node, + Timestamp: time.Now().Unix(), + Nonce: nonce, + BodyHash: bodyHash, + Audience: req.Audience, + } + // Canonical() enforces every field-level rule (audience charset, + // nonce/body-hash shape). Daemon.Sign rather than reqsig.Sign so the + // signature happens under identityMu — a concurrent RotateKey cannot + // zero the private-key buffer mid-signature. + canonical, err := env.Canonical() + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("sign_envelope: %v", err)) + return + } + sig := s.daemon.Sign([]byte(canonical)) + if sig == nil { + s.sendError(conn, reqID, "sign_envelope: daemon has no identity") + return + } + data, err := json.Marshal(map[string]interface{}{ + "type": "sign_envelope_ok", + "envelope": canonical, + "signature": base64.StdEncoding.EncodeToString(sig), + "address": addr.String(), + }) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("sign_envelope marshal: %v", err)) + return + } + if err := conn.writeReply(CmdSignEnvelopeOK, reqID, data); err != nil { + slog.Debug("IPC sign_envelope reply failed", "err", err) + } +} + +// handleVerifyEnvelope services CmdVerifyEnvelope: parse → freshness → +// resolve the claimed node's Ed25519 key (local keyexchange cache first, +// registry on miss) → signature check. A failed check replies +// CmdVerifyEnvelopeOK with valid:false plus a reason rather than CmdError — +// IPC callers are same-UID processes, so unlike the public registry endpoint +// the failure reasons here are deliberately detailed. +func (s *IPCServer) handleVerifyEnvelope(conn *ipcConn, reqID uint64, payload []byte) { + var req struct { + Envelope string `json:"envelope"` + Signature string `json:"signature"` + MaxSkewSecs float64 `json:"max_skew_secs"` + CheckStanding bool `json:"check_standing"` + } + if err := json.Unmarshal(payload, &req); err != nil { + s.sendError(conn, reqID, fmt.Sprintf("verify_envelope: bad payload: %v", err)) + return + } + if req.Envelope == "" || req.Signature == "" { + s.sendError(conn, reqID, "verify_envelope: envelope and signature required") + return + } + reject := func(reason string) { + data, err := json.Marshal(map[string]interface{}{ + "type": "verify_envelope_ok", + "valid": false, + "reason": reason, + }) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("verify_envelope marshal: %v", err)) + return + } + if err := conn.writeReply(CmdVerifyEnvelopeOK, reqID, data); err != nil { + slog.Debug("IPC verify_envelope reply failed", "err", err) + } + } + + e, err := reqsig.Parse(req.Envelope) + if err != nil { + reject(err.Error()) + return + } + if err := reqsig.CheckFresh(e, time.Now(), time.Duration(req.MaxSkewSecs)*time.Second); err != nil { + reject(err.Error()) + return + } + if s.daemon.tunnels == nil { + reject("no key source available") + return + } + // Record how the key resolves BEFORE looking it up: a cache hit means + // the key was already pinned locally (key exchange or a prior verify); + // on a miss getPeerPubKey falls through to the registry and memoizes. + verifiedVia := "registry" + if s.daemon.tunnels.hasPeerPubKey(e.Node) { + verifiedVia = "cache" + } + pub, err := s.daemon.tunnels.getPeerPubKey(e.Node) + if err != nil { + reject(fmt.Sprintf("public key for node %d unavailable: %v", e.Node, err)) + return + } + if _, err := reqsig.Verify(pub, req.Envelope, req.Signature); err != nil { + reject(err.Error()) + return + } + + resp := map[string]interface{}{ + "type": "verify_envelope_ok", + "valid": true, + "node_id": e.Node, + "address": protocol.Addr{Network: e.Network, Node: e.Node}.String(), + "verified_via": verifiedVia, + "trusted": s.daemon.handshakes != nil && s.daemon.handshakes.IsTrusted(e.Node), + } + if req.CheckStanding { + s.addEnvelopeStanding(resp, e) + } + data, err := json.Marshal(resp) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("verify_envelope marshal: %v", err)) + return + } + if err := conn.writeReply(CmdVerifyEnvelopeOK, reqID, data); err != nil { + slog.Debug("IPC verify_envelope reply failed", "err", err) + } +} + +// addEnvelopeStanding decorates a successful verify reply with the signer's +// registry standing. Best-effort: with no registry (regConn nil / +// ErrNoRegistry) or a failed lookup it adds nothing, and each field is +// emitted only when the registry actually returned it — last_seen_unix / +// key_generation are being added to lookup responses in a parallel work +// stream, so older registries simply omit them. +func (s *IPCServer) addEnvelopeStanding(resp map[string]interface{}, e reqsig.Envelope) { + if s.daemon.regConn == nil { + return + } + lk, err := s.daemon.regConn.Lookup(e.Node) + if err != nil { + return + } + if v, ok := lk["last_seen_unix"].(float64); ok { + lastSeen := int64(v) + resp["last_seen_unix"] = lastSeen + d := time.Now().Unix() - lastSeen + if d < 0 { + d = -d + } + resp["online"] = d <= 180 + } + if v, ok := lk["key_generation"].(float64); ok { + resp["key_generation"] = uint32(v) + } + if nets, ok := lk["networks"].([]interface{}); ok { + member := false + for _, n := range nets { + if f, ok := n.(float64); ok && uint16(f) == e.Network { + member = true + break + } + } + resp["network_member"] = member + } +} + func (s *IPCServer) handleSetWebhook(conn *ipcConn, reqID uint64, payload []byte) { url := string(payload) // empty string = clear webhook if url != "" { diff --git a/pkg/daemon/tunnel.go b/pkg/daemon/tunnel.go index 4c5e771c..5a558fd7 100644 --- a/pkg/daemon/tunnel.go +++ b/pkg/daemon/tunnel.go @@ -885,6 +885,13 @@ func (tm *TunnelManager) getPeerPubKey(nodeID uint32) (ed25519.PublicKey, error) return tm.kx.GetPeerPubKey(nodeID) } +// hasPeerPubKey reports whether a peer's Ed25519 public key is already +// cached (no registry fetch). Thin shim over +// keyexchange.Manager.HasPeerPubKey. +func (tm *TunnelManager) hasPeerPubKey(nodeID uint32) bool { + return tm.kx.HasPeerPubKey(nodeID) +} + // Listen starts the UDP listener for incoming tunnel traffic. This is // the default path used when the daemon is launched without // -transport=compat. The transport is *udpio.Socket; behavior is diff --git a/pkg/daemon/zz_ipc_envelope_handlers_test.go b/pkg/daemon/zz_ipc_envelope_handlers_test.go new file mode 100644 index 00000000..4f6b0ebd --- /dev/null +++ b/pkg/daemon/zz_ipc_envelope_handlers_test.go @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/common/crypto" + "github.com/pilot-protocol/common/reqsig" +) + +// --- test helpers ------------------------------------------------------------ + +// runSignEnvelope marshals payload, dispatches handleSignEnvelope, and returns +// the raw [cmd][body...] reply. +func runSignEnvelope(t *testing.T, s *IPCServer, payload map[string]interface{}) []byte { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + ic, client := newIPCTestConn(t) + return runHandler(t, client, func() { s.handleSignEnvelope(ic, 0, raw) }) +} + +// runVerifyEnvelope dispatches handleVerifyEnvelope and decodes the expected +// CmdVerifyEnvelopeOK JSON verdict. +func runVerifyEnvelope(t *testing.T, s *IPCServer, payload map[string]interface{}) map[string]interface{} { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + ic, client := newIPCTestConn(t) + reply := runHandler(t, client, func() { s.handleVerifyEnvelope(ic, 0, raw) }) + if reply[0] != CmdVerifyEnvelopeOK { + t.Fatalf("opcode = 0x%02X, want CmdVerifyEnvelopeOK (0x%02X); body=%q", reply[0], CmdVerifyEnvelopeOK, reply[1:]) + } + var resp map[string]interface{} + if err := json.Unmarshal(reply[1:], &resp); err != nil { + t.Fatalf("verdict json: %v (payload=%q)", err, reply[1:]) + } + return resp +} + +// signedTestEnvelope signs an envelope with the given key outside the daemon +// (as a remote peer would) so verify-side failure modes can be exercised with +// arbitrary node/timestamp values. +func signedTestEnvelope(t *testing.T, priv ed25519.PrivateKey, network uint16, node uint32, ts int64) (canonical, sigB64 string) { + t.Helper() + nonce, err := reqsig.NewNonce() + if err != nil { + t.Fatalf("NewNonce: %v", err) + } + canonical, sigB64, err = reqsig.Sign(priv, reqsig.Envelope{ + Network: network, + Node: node, + Timestamp: ts, + Nonce: nonce, + BodyHash: reqsig.HashBody([]byte("body")), + Audience: "svc.example.io", + }) + if err != nil { + t.Fatalf("reqsig.Sign: %v", err) + } + return canonical, sigB64 +} + +// --- handleSignEnvelope ------------------------------------------------------ + +// TestHandleSignAndVerifyEnvelopeRoundTrip proves the full loop: the daemon +// signs an envelope naming its OWN address, and the verify handler accepts it +// back (cache key-resolution path), reporting node/address/via/trusted. +func TestHandleSignAndVerifyEnvelopeRoundTrip(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + id, err := crypto.GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + d.identity = id + + bodyHash := reqsig.HashBody([]byte(`{"q":"x"}`)) + reply := runSignEnvelope(t, s, map[string]interface{}{ + "audience": "svc.example.io", + "body_hash": bodyHash, + }) + if reply[0] != CmdSignEnvelopeOK { + t.Fatalf("opcode = 0x%02X, want CmdSignEnvelopeOK (0x%02X); body=%q", reply[0], CmdSignEnvelopeOK, reply[1:]) + } + var signed map[string]interface{} + if err := json.Unmarshal(reply[1:], &signed); err != nil { + t.Fatalf("sign reply json: %v", err) + } + canonical, _ := signed["envelope"].(string) + sigB64, _ := signed["signature"].(string) + if addr, _ := signed["address"].(string); addr != "0:0000.0000.0007" { + t.Errorf("address = %q, want 0:0000.0000.0007", addr) + } + + // The envelope must name the daemon's own address and echo the body hash. + e, err := reqsig.Parse(canonical) + if err != nil { + t.Fatalf("Parse(%q): %v", canonical, err) + } + if e.Network != 0 || e.Node != 7 { + t.Errorf("envelope address = %d/%d, want 0/7", e.Network, e.Node) + } + if e.BodyHash != bodyHash || e.Audience != "svc.example.io" { + t.Errorf("envelope fields = %+v", e) + } + // Signature verifies directly against the daemon key. + if _, err := reqsig.Verify(id.PublicKey, canonical, sigB64); err != nil { + t.Fatalf("reqsig.Verify: %v", err) + } + + // Round trip through the verify handler: seed the keyexchange cache so + // the key resolves locally (verified_via=cache), no registry needed. + d.tunnels.kx.SetPeerPubKey(7, id.PublicKey) + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + }) + if valid, _ := verdict["valid"].(bool); !valid { + t.Fatalf("verdict = %+v, want valid=true", verdict) + } + if nid, _ := verdict["node_id"].(float64); uint32(nid) != 7 { + t.Errorf("node_id = %v, want 7", verdict["node_id"]) + } + if addr, _ := verdict["address"].(string); addr != "0:0000.0000.0007" { + t.Errorf("address = %q", addr) + } + if via, _ := verdict["verified_via"].(string); via != "cache" { + t.Errorf("verified_via = %q, want cache", via) + } + if trusted, ok := verdict["trusted"].(bool); !ok || trusted { + t.Errorf("trusted = %v, want false (no handshake service)", verdict["trusted"]) + } +} + +// TestHandleSignEnvelopeHashesBodyB64 proves the daemon hashes a b64 body +// itself and honors a caller-supplied nonce. +func TestHandleSignEnvelopeHashesBodyB64(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + id, _ := crypto.GenerateIdentity() + d.identity = id + + const nonce = "00112233aabbccdd" + body := []byte("hello envelope") + reply := runSignEnvelope(t, s, map[string]interface{}{ + "audience": "svc.example.io", + "body_b64": base64.StdEncoding.EncodeToString(body), + "nonce": nonce, + }) + if reply[0] != CmdSignEnvelopeOK { + t.Fatalf("opcode = 0x%02X, want CmdSignEnvelopeOK; body=%q", reply[0], reply[1:]) + } + var signed map[string]interface{} + if err := json.Unmarshal(reply[1:], &signed); err != nil { + t.Fatalf("json: %v", err) + } + e, err := reqsig.Parse(signed["envelope"].(string)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if e.BodyHash != reqsig.HashBody(body) { + t.Errorf("body hash = %q, want daemon-computed sha256", e.BodyHash) + } + if e.Nonce != nonce { + t.Errorf("nonce = %q, want caller-supplied %q", e.Nonce, nonce) + } +} + +// TestHandleSignEnvelopeRejectsBadInput pins the refusal paths — the daemon +// must never emit a signature for malformed envelope inputs (the IPC socket +// is same-UID-open, so these checks are the signing-oracle boundary). +func TestHandleSignEnvelopeRejectsBadInput(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + id, _ := crypto.GenerateIdentity() + d.identity = id + + goodHash := reqsig.HashBody([]byte("x")) + cases := []struct { + name string + payload map[string]interface{} + wantMsg string + }{ + {"missing audience", map[string]interface{}{"body_hash": goodHash}, "audience required"}, + {"bad audience charset", map[string]interface{}{"audience": "UPPER", "body_hash": goodHash}, "audience"}, + {"no body source", map[string]interface{}{"audience": "svc.example.io"}, "body_hash or body_b64"}, + {"both body sources", map[string]interface{}{"audience": "svc.example.io", "body_hash": goodHash, "body_b64": "aGk="}, "mutually exclusive"}, + {"short body hash", map[string]interface{}{"audience": "svc.example.io", "body_hash": "abcd"}, "body hash"}, + {"bad body_b64", map[string]interface{}{"audience": "svc.example.io", "body_b64": "!!!"}, "not base64"}, + {"bad nonce", map[string]interface{}{"audience": "svc.example.io", "body_hash": goodHash, "nonce": "zz"}, "nonce"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + reply := runSignEnvelope(t, s, tc.payload) + assertErrorReply(t, reply, tc.wantMsg) + }) + } +} + +func TestHandleSignEnvelopeNoIdentityReturnsError(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + d.identity = nil + reply := runSignEnvelope(t, s, map[string]interface{}{ + "audience": "svc.example.io", + "body_hash": reqsig.HashBody([]byte("x")), + }) + assertErrorReply(t, reply, "no identity") +} + +// --- handleVerifyEnvelope ---------------------------------------------------- + +func TestHandleVerifyEnvelopeRejectsTamperedEnvelope(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + peer, _ := crypto.GenerateIdentity() + d.tunnels.kx.SetPeerPubKey(42, peer.PublicKey) + + canonical, sigB64 := signedTestEnvelope(t, peer.PrivateKey, 0, 42, time.Now().Unix()) + // Flip the audience TLD — still a well-formed envelope, wrong signature. + tampered := strings.TrimSuffix(canonical, "svc.example.io") + "svc.example.iq" + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": tampered, + "signature": sigB64, + }) + if valid, _ := verdict["valid"].(bool); valid { + t.Fatalf("tampered envelope verified: %+v", verdict) + } + if reason, _ := verdict["reason"].(string); !strings.Contains(reason, "signature verification failed") { + t.Errorf("reason = %q, want signature failure", verdict["reason"]) + } +} + +func TestHandleVerifyEnvelopeRejectsStaleTimestamp(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + peer, _ := crypto.GenerateIdentity() + d.tunnels.kx.SetPeerPubKey(42, peer.PublicKey) + + canonical, sigB64 := signedTestEnvelope(t, peer.PrivateKey, 0, 42, time.Now().Add(-time.Hour).Unix()) + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + }) + if valid, _ := verdict["valid"].(bool); valid { + t.Fatalf("stale envelope verified: %+v", verdict) + } + if reason, _ := verdict["reason"].(string); !strings.Contains(reason, "window") { + t.Errorf("reason = %q, want freshness-window failure", verdict["reason"]) + } + + // A caller-widened skew window admits the same envelope. + verdict = runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + "max_skew_secs": 7200, + }) + if valid, _ := verdict["valid"].(bool); !valid { + t.Fatalf("wide-skew verify failed: %+v", verdict) + } +} + +// TestHandleVerifyEnvelopeUnknownPeerNoRegistry: no cached key and no +// registry fallback — the verdict is a detailed valid:false, not CmdError. +func TestHandleVerifyEnvelopeUnknownPeerNoRegistry(t *testing.T) { + t.Parallel() + _, s := newSimpleHandlerDaemon(t, nil) + peer, _ := crypto.GenerateIdentity() + canonical, sigB64 := signedTestEnvelope(t, peer.PrivateKey, 0, 4242, time.Now().Unix()) + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + }) + if valid, _ := verdict["valid"].(bool); valid { + t.Fatalf("verified without any key source: %+v", verdict) + } + if reason, _ := verdict["reason"].(string); !strings.Contains(reason, "4242") { + t.Errorf("reason = %q, want mention of node 4242", verdict["reason"]) + } +} + +// TestHandleVerifyEnvelopeRegistryPathAndTrusted covers the cache-miss key +// resolution path (verified_via=registry, then memoized to cache) plus the +// handshake-trust flag. +func TestHandleVerifyEnvelopeRegistryPathAndTrusted(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + peer, _ := crypto.GenerateIdentity() + // Stand in for the registry fetch (keyexchange verifyFunc). + d.tunnels.SetPeerVerifyFunc(func(nodeID uint32) (ed25519.PublicKey, error) { + return peer.PublicKey, nil + }) + fs := installFakeHandshake(d) + fs.trustedRecs = []HandshakeTrustRecord{{NodeID: 42}} + + canonical, sigB64 := signedTestEnvelope(t, peer.PrivateKey, 0, 42, time.Now().Unix()) + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + }) + if valid, _ := verdict["valid"].(bool); !valid { + t.Fatalf("verdict = %+v, want valid=true", verdict) + } + if via, _ := verdict["verified_via"].(string); via != "registry" { + t.Errorf("verified_via = %q, want registry on first resolve", via) + } + if trusted, _ := verdict["trusted"].(bool); !trusted { + t.Errorf("trusted = %v, want true", verdict["trusted"]) + } + + // Second verify hits the memoized key. + verdict = runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + }) + if via, _ := verdict["verified_via"].(string); via != "cache" { + t.Errorf("verified_via = %q, want cache on second resolve", via) + } +} + +// TestHandleVerifyEnvelopeCheckStanding covers the optional registry-standing +// decoration — fields appear only when the registry returns them, so the +// handler tolerates older registries (and the parallel work stream adding +// last_seen_unix/key_generation). +func TestHandleVerifyEnvelopeCheckStanding(t *testing.T) { + t.Parallel() + peer, _ := crypto.GenerateIdentity() + now := time.Now().Unix() + + t.Run("full standing fields", func(t *testing.T) { + t.Parallel() + client, cleanup := startFakeRegistry(t, func(req map[string]interface{}) map[string]interface{} { + if req["type"] == "lookup" { + return map[string]interface{}{ + "last_seen_unix": now - 30, + "key_generation": 3, + "networks": []interface{}{float64(0), float64(5)}, + } + } + return map[string]interface{}{} + }) + defer cleanup() + d, s := newSimpleHandlerDaemon(t, client) + d.tunnels.kx.SetPeerPubKey(42, peer.PublicKey) + + canonical, sigB64 := signedTestEnvelope(t, peer.PrivateKey, 0, 42, now) + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + "check_standing": true, + }) + if valid, _ := verdict["valid"].(bool); !valid { + t.Fatalf("verdict = %+v, want valid=true", verdict) + } + if online, _ := verdict["online"].(bool); !online { + t.Errorf("online = %v, want true (last_seen 30s ago)", verdict["online"]) + } + if ls, _ := verdict["last_seen_unix"].(float64); int64(ls) != now-30 { + t.Errorf("last_seen_unix = %v, want %d", verdict["last_seen_unix"], now-30) + } + if kg, _ := verdict["key_generation"].(float64); uint32(kg) != 3 { + t.Errorf("key_generation = %v, want 3", verdict["key_generation"]) + } + if member, _ := verdict["network_member"].(bool); !member { + t.Errorf("network_member = %v, want true (network 0 in [0 5])", verdict["network_member"]) + } + }) + + t.Run("registry without standing fields", func(t *testing.T) { + t.Parallel() + client, cleanup := startFakeRegistry(t, func(req map[string]interface{}) map[string]interface{} { + return map[string]interface{}{} // lookup succeeds, no standing data + }) + defer cleanup() + d, s := newSimpleHandlerDaemon(t, client) + d.tunnels.kx.SetPeerPubKey(42, peer.PublicKey) + + canonical, sigB64 := signedTestEnvelope(t, peer.PrivateKey, 0, 42, now) + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + "check_standing": true, + }) + if valid, _ := verdict["valid"].(bool); !valid { + t.Fatalf("verdict = %+v, want valid=true", verdict) + } + for _, key := range []string{"online", "last_seen_unix", "key_generation", "network_member"} { + if _, present := verdict[key]; present { + t.Errorf("%s should be omitted when the registry doesn't report it", key) + } + } + }) + + t.Run("no registry at all", func(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + d.tunnels.kx.SetPeerPubKey(42, peer.PublicKey) + + canonical, sigB64 := signedTestEnvelope(t, peer.PrivateKey, 0, 42, now) + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": canonical, + "signature": sigB64, + "check_standing": true, + }) + if valid, _ := verdict["valid"].(bool); !valid { + t.Fatalf("verdict = %+v, want valid=true (cache verify works offline)", verdict) + } + for _, key := range []string{"online", "last_seen_unix", "key_generation", "network_member"} { + if _, present := verdict[key]; present { + t.Errorf("%s should be omitted with no registry", key) + } + } + }) +} + +// TestHandleVerifyEnvelopeMalformedPayloads pins the CmdError (vs valid:false) +// boundary: transport-level garbage is an error, envelope-level problems are +// verdicts. +func TestHandleVerifyEnvelopeMalformedPayloads(t *testing.T) { + t.Parallel() + _, s := newSimpleHandlerDaemon(t, nil) + + ic, client := newIPCTestConn(t) + reply := runHandler(t, client, func() { s.handleVerifyEnvelope(ic, 0, []byte("not json")) }) + assertErrorReply(t, reply, "bad payload") + + raw, _ := json.Marshal(map[string]interface{}{"envelope": "x"}) // missing signature + ic2, client2 := newIPCTestConn(t) + reply = runHandler(t, client2, func() { s.handleVerifyEnvelope(ic2, 0, raw) }) + assertErrorReply(t, reply, "envelope and signature required") + + // A syntactically bad envelope is a verdict, not an error. + verdict := runVerifyEnvelope(t, s, map[string]interface{}{ + "envelope": "garbage", + "signature": "c2ln", + }) + if valid, _ := verdict["valid"].(bool); valid { + t.Fatal("garbage envelope verified") + } +} From 26b226370c532b80bd0523bce1458fc9c798cbd0 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 3 Jul 2026 23:47:40 +0300 Subject: [PATCH 2/4] Bump common to v0.5.7 for reqsig --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e309d640..09d02b11 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/coder/websocket v1.8.15 github.com/pilot-protocol/app-store v1.0.2 github.com/pilot-protocol/beacon v0.2.6 - github.com/pilot-protocol/common v0.5.6 + github.com/pilot-protocol/common v0.5.7 github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98 github.com/pilot-protocol/eventstream v0.2.2 github.com/pilot-protocol/handshake v0.2.1 diff --git a/go.sum b/go.sum index c79b349b..b284cffb 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/pilot-protocol/app-store v1.0.2 h1:oK7cNl3e/gfxVhhkUFKNLRN256+7sDSBw8 github.com/pilot-protocol/app-store v1.0.2/go.mod h1:deltPnaQkiTgMcxWU+honz3+Bl2R1cthhuZra4pQ4PI= github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S21DuVE= github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4= -github.com/pilot-protocol/common v0.5.6 h1:0A3W3HUQSPryGyh+zA89hEHHLcYVd9v9Vgk8EzOqpQI= -github.com/pilot-protocol/common v0.5.6/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= +github.com/pilot-protocol/common v0.5.7 h1:GY6KhB+PwV713HAAhVrVEZJN9e5DsnbfElPiOAEpzJE= +github.com/pilot-protocol/common v0.5.7/go.mod h1:Y6yaOsywr8J4aGvyoyuOtDpwZTheFM7GOjDt6Y/ZB9I= github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98 h1:Bqgnf4CZC7aZJyDzz/E7agwXotArJg2FvFlNDqouhLo= github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98/go.mod h1:tM9eyyruBdnxhhUtViasUjnAElwF/r5PQvCYKLdlTLY= github.com/pilot-protocol/eventstream v0.2.2 h1:E0IjveK7K+dsIbE/5hD3N821FkHzxVsx1tiAORMzt8k= From ef7a2f77c443f5a1db0d5a8c63def6ac00293f8d Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 4 Jul 2026 00:16:49 +0300 Subject: [PATCH 3/4] Address scanner findings and regenerate CLI reference - bound --max-skew to 0-86400 before the uint32 conversion - annotate the operator-supplied --body-file read (G304) - ignore best-effort cleanup errors explicitly in IPCServer.Start - regenerate docs/cli-reference.md for sign-request/verify-request --- cmd/pilotctl/sign_request.go | 5 +++-- docs/cli-reference.md | 4 ++++ pkg/daemon/ipc.go | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/cmd/pilotctl/sign_request.go b/cmd/pilotctl/sign_request.go index ad51602e..2ae8f2f8 100644 --- a/cmd/pilotctl/sign_request.go +++ b/cmd/pilotctl/sign_request.go @@ -42,6 +42,7 @@ func cmdSignRequest(args []string) { } switch { case bodyFile != "": + // #nosec G304 -- bodyFile is an operator-supplied CLI flag; reading it is the command's purpose data, err := os.ReadFile(bodyFile) if err != nil { fatalCode("invalid_argument", "sign-request: read %s: %v", bodyFile, err) @@ -77,8 +78,8 @@ func cmdVerifyRequest(args []string) { } standing := flagBool(flags, "standing") maxSkew := flagInt(flags, "max-skew", 0) - if maxSkew < 0 { - fatalCode("invalid_argument", "verify-request: --max-skew must be >= 0") + if maxSkew < 0 || maxSkew > 86400 { + fatalCode("invalid_argument", "verify-request: --max-skew must be 0-86400 seconds") } d := connectDriver() diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6afd0f48..053cffb9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -63,6 +63,10 @@ Identity & recovery: pilotctl recovery ... enroll / rotate / reclaim the address if the key is lost pilotctl review [--rating <1-5>] [--text "..."] rate Pilot or an installed app +Request signing (prove a request originates from this node): + pilotctl sign-request --audience (--body-file | --body-hash <64hex> | --body '') + pilotctl verify-request --envelope '' --signature '' [--standing] [--max-skew ] + Management commands: pilotctl connections pilotctl disconnect diff --git a/pkg/daemon/ipc.go b/pkg/daemon/ipc.go index 6e880ea8..f486d8f9 100644 --- a/pkg/daemon/ipc.go +++ b/pkg/daemon/ipc.go @@ -527,8 +527,8 @@ func NewIPCServer(socketPath string, d *Daemon) *IPCServer { } func (s *IPCServer) Start() error { - // Remove stale socket - os.Remove(s.socketPath) + // Remove stale socket; a missing file is fine. + _ = os.Remove(s.socketPath) // PILOT-246: Bind the socket inside a private, freshly-created 0700 // directory and atomically rename it into place. The socket therefore @@ -558,11 +558,11 @@ func (s *IPCServer) Start() error { // Restrict socket access to owner only before it is reachable at the // published path. if err := os.Chmod(stagePath, 0600); err != nil { - ln.Close() + _ = ln.Close() return fmt.Errorf("chmod socket %s: %w", s.socketPath, err) } if err := os.Rename(stagePath, s.socketPath); err != nil { - ln.Close() + _ = ln.Close() return fmt.Errorf("publish socket %s: %w", s.socketPath, err) } s.listener = ln From 62ee09f6636cae0557125cf0cceff583918d9db0 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sat, 4 Jul 2026 00:32:35 +0300 Subject: [PATCH 4/4] Annotate bounded max-skew conversion for gosec --- cmd/pilotctl/sign_request.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/pilotctl/sign_request.go b/cmd/pilotctl/sign_request.go index 2ae8f2f8..43432b79 100644 --- a/cmd/pilotctl/sign_request.go +++ b/cmd/pilotctl/sign_request.go @@ -84,7 +84,7 @@ func cmdVerifyRequest(args []string) { d := connectDriver() defer d.Close() - resp, err := d.VerifyEnvelopeMaxSkew(envelope, sig, standing, uint32(maxSkew)) + resp, err := d.VerifyEnvelopeMaxSkew(envelope, sig, standing, uint32(maxSkew)) // #nosec G115 -- bounded to 0-86400 above if err != nil { fatalCode("connection_failed", "verify-request: %v", err) }