Skip to content

Commit e8e09f2

Browse files
authored
Add envelope sign/verify IPC commands and pilotctl front-ends (#350)
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 carries the full three-surface spec. Bumps common to v0.5.7.
1 parent eb18f4a commit e8e09f2

11 files changed

Lines changed: 1249 additions & 8 deletions

cmd/pilotctl/appstore_update_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func TestSemverCompare(t *testing.T) {
1616
{"1.0.0", "1.0.1", -1},
1717
{"1.2.0", "1.1.9", 1},
1818
{"2.0.0", "1.9.9", 1},
19-
{"1.2", "1.2.0", 0}, // missing component == 0
19+
{"1.2", "1.2.0", 0}, // missing component == 0
2020
{"1.2.3-rc.1", "1.2.3", 0}, // prerelease ignored in the core compare
2121
{"0.10.0", "0.9.0", 1}, // numeric, not lexical
2222
}

cmd/pilotctl/main.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,39 @@ Use this for a clean shutdown when you no longer want this node to be reachable.
11361136
11371137
Note: daemon stop does NOT deregister — the 5-minute TTL reaps inactive nodes
11381138
automatically. Only use deregister if you want immediate removal.
1139+
`,
1140+
"sign-request": `Usage: pilotctl sign-request --audience <a> (--body-file <f> | --body-hash <64hex> | --body '<string>')
1141+
1142+
Sign a request-signature envelope (reqsig) proving a request originates from
1143+
this node. The daemon constructs the envelope itself — its own address, the
1144+
current timestamp, a fresh nonce, the body hash and the audience — and signs
1145+
the canonical form with the node's Ed25519 identity key. It never signs
1146+
caller-supplied raw strings.
1147+
1148+
Exactly one body source is required:
1149+
--body-file <f> hash the file's bytes (sha256)
1150+
--body '<string>' hash the literal string
1151+
--body-hash <64hex> use a precomputed sha256 body hash
1152+
1153+
Prints {envelope, signature, address}. Attach envelope + signature to the
1154+
request; the consuming service verifies them against this node's registered
1155+
public key (or via: pilotctl verify-request).
1156+
`,
1157+
"verify-request": `Usage: pilotctl verify-request --envelope '<canonical>' --signature '<b64>' [--standing] [--max-skew <secs>]
1158+
1159+
Verify a request-signature envelope produced by a peer's sign-request. The
1160+
daemon parses the envelope, checks freshness, resolves the claimed node's
1161+
public key (local key cache first, registry on miss) and verifies the
1162+
signature.
1163+
1164+
Flags:
1165+
--standing also report the signer's registry standing when
1166+
available (online, last_seen_unix, key_generation,
1167+
network_member)
1168+
--max-skew <secs> freshness window in seconds (default 300)
1169+
1170+
Prints the daemon's verdict. Exit code 1 when the envelope does not verify
1171+
(reply carries valid:false plus a reason).
11391172
`,
11401173
"rotate-key": `Usage: pilotctl rotate-key
11411174
@@ -1476,6 +1509,10 @@ Identity & recovery:
14761509
pilotctl recovery <enroll|new-key|recover> ... enroll / rotate / reclaim the address if the key is lost
14771510
pilotctl review <pilot|app-id> [--rating <1-5>] [--text "..."] rate Pilot or an installed app
14781511
1512+
Request signing (prove a request originates from this node):
1513+
pilotctl sign-request --audience <a> (--body-file <f> | --body-hash <64hex> | --body '<string>')
1514+
pilotctl verify-request --envelope '<canonical>' --signature '<b64>' [--standing] [--max-skew <secs>]
1515+
14791516
Management commands:
14801517
pilotctl connections
14811518
pilotctl disconnect <conn_id>
@@ -1695,6 +1732,10 @@ dispatch:
16951732
cmdVerify(cmdArgs)
16961733
case "recovery":
16971734
cmdRecovery(cmdArgs)
1735+
case "sign-request":
1736+
cmdSignRequest(cmdArgs)
1737+
case "verify-request":
1738+
cmdVerifyRequest(cmdArgs)
16981739

16991740
// Discovery
17001741
case "find":
@@ -2158,6 +2199,16 @@ func contextCatalog() map[string]interface{} {
21582199
"description": "Rotate this node's Ed25519 identity key",
21592200
"returns": "node_id, address, new public_key",
21602201
},
2202+
"sign-request": map[string]interface{}{
2203+
"args": []string{"--audience <a>", "(--body-file <f> | --body-hash <64hex> | --body <string>)"},
2204+
"description": "Sign a request-signature envelope (reqsig) proving a request originates from this node",
2205+
"returns": "envelope, signature, address",
2206+
},
2207+
"verify-request": map[string]interface{}{
2208+
"args": []string{"--envelope <canonical>", "--signature <b64>", "[--standing]", "[--max-skew <secs>]"},
2209+
"description": "Verify a peer's request-signature envelope; exit code 1 when invalid",
2210+
"returns": "valid, node_id, address, verified_via, trusted [, online, last_seen_unix, key_generation, network_member]",
2211+
},
21612212

21622213
// Trust
21632214
"handshake": map[string]interface{}{

cmd/pilotctl/sign_request.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"os"
7+
8+
"github.com/pilot-protocol/common/reqsig"
9+
)
10+
11+
// cmdSignRequest asks the daemon to sign a request-signature envelope
12+
// (common/reqsig) for a consuming service. The daemon constructs the
13+
// envelope itself (its own address, current timestamp, fresh nonce) — the
14+
// CLI only supplies the audience and the body hash. Exactly one body
15+
// source is required:
16+
//
17+
// pilotctl sign-request --audience svc.example.io --body-file req.json
18+
// pilotctl sign-request --audience svc.example.io --body '{"q":"x"}'
19+
// pilotctl sign-request --audience svc.example.io --body-hash <64 hex>
20+
func cmdSignRequest(args []string) {
21+
flags, _ := parseFlags(args)
22+
audience := flagString(flags, "audience", "")
23+
if audience == "" {
24+
fatalCode("invalid_argument", "usage: pilotctl sign-request --audience <a> (--body-file <f> | --body-hash <64hex> | --body '<string>')")
25+
}
26+
27+
bodyHash := flagString(flags, "body-hash", "")
28+
bodyFile := flagString(flags, "body-file", "")
29+
body, bodySet := flags["body"]
30+
sources := 0
31+
if bodyHash != "" {
32+
sources++
33+
}
34+
if bodyFile != "" {
35+
sources++
36+
}
37+
if bodySet {
38+
sources++
39+
}
40+
if sources != 1 {
41+
fatalCode("invalid_argument", "sign-request: exactly one of --body-file, --body-hash, --body is required")
42+
}
43+
switch {
44+
case bodyFile != "":
45+
// #nosec G304 -- bodyFile is an operator-supplied CLI flag; reading it is the command's purpose
46+
data, err := os.ReadFile(bodyFile)
47+
if err != nil {
48+
fatalCode("invalid_argument", "sign-request: read %s: %v", bodyFile, err)
49+
}
50+
bodyHash = reqsig.HashBody(data)
51+
case bodySet:
52+
bodyHash = reqsig.HashBody([]byte(body))
53+
}
54+
55+
d := connectDriver()
56+
defer d.Close()
57+
resp, err := d.SignEnvelope(audience, bodyHash)
58+
if err != nil {
59+
fatalCode("connection_failed", "sign-request: %v", err)
60+
}
61+
output(map[string]interface{}{
62+
"envelope": resp["envelope"],
63+
"signature": resp["signature"],
64+
"address": resp["address"],
65+
})
66+
}
67+
68+
// cmdVerifyRequest checks a reqsig envelope + signature via the daemon,
69+
// which resolves the claimed node's key from its local cache first and the
70+
// registry on miss. Prints the daemon's reply; exits 1 when the daemon
71+
// reports valid:false.
72+
func cmdVerifyRequest(args []string) {
73+
flags, _ := parseFlags(args)
74+
envelope := flagString(flags, "envelope", "")
75+
sig := flagString(flags, "signature", "")
76+
if envelope == "" || sig == "" {
77+
fatalCode("invalid_argument", "usage: pilotctl verify-request --envelope '<canonical>' --signature '<b64>' [--standing] [--max-skew <secs>]")
78+
}
79+
standing := flagBool(flags, "standing")
80+
maxSkew := flagInt(flags, "max-skew", 0)
81+
if maxSkew < 0 || maxSkew > 86400 {
82+
fatalCode("invalid_argument", "verify-request: --max-skew must be 0-86400 seconds")
83+
}
84+
85+
d := connectDriver()
86+
defer d.Close()
87+
resp, err := d.VerifyEnvelopeMaxSkew(envelope, sig, standing, uint32(maxSkew)) // #nosec G115 -- bounded to 0-86400 above
88+
if err != nil {
89+
fatalCode("connection_failed", "verify-request: %v", err)
90+
}
91+
output(resp)
92+
if valid, _ := resp["valid"].(bool); !valid {
93+
os.Exit(1)
94+
}
95+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"encoding/json"
7+
"strings"
8+
"testing"
9+
10+
"github.com/pilot-protocol/common/reqsig"
11+
)
12+
13+
// IPC cmd codes for the request-signature envelope commands — must match
14+
// daemon CmdSignEnvelope/CmdVerifyEnvelope and driver cmdSignEnvelope/
15+
// cmdVerifyEnvelope. Kept here (not zz_fake_daemon_test.go) alongside the
16+
// only tests that use them.
17+
const (
18+
tdCmdSignEnvelope byte = 0x33
19+
tdCmdSignEnvelopeOK byte = 0x34
20+
tdCmdVerifyEnvelope byte = 0x35
21+
tdCmdVerifyEnvelopeOK byte = 0x36
22+
)
23+
24+
// receivedPayload scans the fake daemon's received frames for cmd and
25+
// returns the decoded JSON payload of the first match.
26+
func receivedPayload(t *testing.T, d *fakeDaemon, cmd byte) map[string]interface{} {
27+
t.Helper()
28+
d.mu.Lock()
29+
defer d.mu.Unlock()
30+
for _, frame := range d.received {
31+
if len(frame) > 1 && frame[0] == cmd {
32+
var got map[string]interface{}
33+
if err := json.Unmarshal(frame[1:], &got); err != nil {
34+
t.Fatalf("frame 0x%02X payload not JSON: %v", cmd, err)
35+
}
36+
return got
37+
}
38+
}
39+
t.Fatalf("no frame with cmd 0x%02X received", cmd)
40+
return nil
41+
}
42+
43+
// TestCLISignRequestEnvelope covers the happy path: the CLI hashes --body
44+
// locally, sends {audience, body_hash} to the daemon, and prints
45+
// {envelope, signature, address}.
46+
func TestCLISignRequestEnvelope(t *testing.T) {
47+
t.Parallel()
48+
d := newFakeDaemon(t)
49+
d.onJSON(tdCmdSignEnvelope, tdCmdSignEnvelopeOK,
50+
`{"type":"sign_envelope_ok","envelope":"pilot-req-v1|env","signature":"c2ln","address":"0:0000.0000.0007"}`)
51+
52+
stdout, stderr, code := runCLI(t, []string{
53+
"--json", "sign-request",
54+
"--audience", "svc.example.io",
55+
"--body", "hello envelope",
56+
}, map[string]string{"PILOT_SOCKET": d.path})
57+
if code != 0 {
58+
t.Fatalf("exit=%d\nstdout=%s\nstderr=%s", code, stdout, stderr)
59+
}
60+
61+
var env map[string]interface{}
62+
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
63+
t.Fatalf("json: %v (stdout=%q)", err, stdout)
64+
}
65+
data, _ := env["data"].(map[string]interface{})
66+
if data["envelope"] != "pilot-req-v1|env" || data["signature"] != "c2ln" || data["address"] != "0:0000.0000.0007" {
67+
t.Errorf("data = %+v", data)
68+
}
69+
70+
// The daemon must have been asked for the locally computed sha256, never
71+
// the raw body.
72+
got := receivedPayload(t, d, tdCmdSignEnvelope)
73+
if got["audience"] != "svc.example.io" {
74+
t.Errorf("audience = %v", got["audience"])
75+
}
76+
if got["body_hash"] != reqsig.HashBody([]byte("hello envelope")) {
77+
t.Errorf("body_hash = %v, want sha256 of --body", got["body_hash"])
78+
}
79+
}
80+
81+
func TestCLISignRequestEnvelopeRequiresAudience(t *testing.T) {
82+
t.Parallel()
83+
_, stderr, code := runCLI(t, []string{"sign-request", "--body", "x"}, nil)
84+
if code == 0 {
85+
t.Error("expected non-zero exit without --audience")
86+
}
87+
if !strings.Contains(stderr, "audience") {
88+
t.Errorf("expected usage mention of --audience, got: %s", stderr)
89+
}
90+
}
91+
92+
func TestCLISignRequestEnvelopeRequiresOneBodySource(t *testing.T) {
93+
t.Parallel()
94+
// None given.
95+
_, stderr, code := runCLI(t, []string{"sign-request", "--audience", "svc.example.io"}, nil)
96+
if code == 0 {
97+
t.Error("expected non-zero exit without a body source")
98+
}
99+
if !strings.Contains(stderr, "exactly one") {
100+
t.Errorf("expected 'exactly one' error, got: %s", stderr)
101+
}
102+
// Two given.
103+
_, stderr, code = runCLI(t, []string{
104+
"sign-request", "--audience", "svc.example.io",
105+
"--body", "x", "--body-hash", strings.Repeat("a", 64),
106+
}, nil)
107+
if code == 0 {
108+
t.Error("expected non-zero exit with two body sources")
109+
}
110+
if !strings.Contains(stderr, "exactly one") {
111+
t.Errorf("expected 'exactly one' error, got: %s", stderr)
112+
}
113+
}
114+
115+
// TestCLIVerifyRequestEnvelopeValid: a valid verdict prints the daemon reply
116+
// and exits 0; flags round-trip onto the wire.
117+
func TestCLIVerifyRequestEnvelopeValid(t *testing.T) {
118+
t.Parallel()
119+
d := newFakeDaemon(t)
120+
d.onJSON(tdCmdVerifyEnvelope, tdCmdVerifyEnvelopeOK,
121+
`{"type":"verify_envelope_ok","valid":true,"node_id":7,"address":"0:0000.0000.0007","verified_via":"cache","trusted":true}`)
122+
123+
stdout, stderr, code := runCLI(t, []string{
124+
"--json", "verify-request",
125+
"--envelope", "pilot-req-v1|env",
126+
"--signature", "c2ln",
127+
"--standing",
128+
"--max-skew", "600",
129+
}, map[string]string{"PILOT_SOCKET": d.path})
130+
if code != 0 {
131+
t.Fatalf("exit=%d\nstdout=%s\nstderr=%s", code, stdout, stderr)
132+
}
133+
var env map[string]interface{}
134+
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
135+
t.Fatalf("json: %v (stdout=%q)", err, stdout)
136+
}
137+
data, _ := env["data"].(map[string]interface{})
138+
if valid, _ := data["valid"].(bool); !valid {
139+
t.Errorf("data = %+v, want valid=true", data)
140+
}
141+
142+
got := receivedPayload(t, d, tdCmdVerifyEnvelope)
143+
if got["envelope"] != "pilot-req-v1|env" || got["signature"] != "c2ln" {
144+
t.Errorf("payload = %+v", got)
145+
}
146+
if cs, _ := got["check_standing"].(bool); !cs {
147+
t.Errorf("check_standing = %v, want true (--standing)", got["check_standing"])
148+
}
149+
if skew, _ := got["max_skew_secs"].(float64); skew != 600 {
150+
t.Errorf("max_skew_secs = %v, want 600 (--max-skew)", got["max_skew_secs"])
151+
}
152+
}
153+
154+
// TestCLIVerifyRequestEnvelopeInvalidExitsOne: valid:false still prints the
155+
// verdict but the process exits 1 (scriptable gate).
156+
func TestCLIVerifyRequestEnvelopeInvalidExitsOne(t *testing.T) {
157+
t.Parallel()
158+
d := newFakeDaemon(t)
159+
d.onJSON(tdCmdVerifyEnvelope, tdCmdVerifyEnvelopeOK,
160+
`{"type":"verify_envelope_ok","valid":false,"reason":"reqsig: signature verification failed"}`)
161+
162+
stdout, _, code := runCLI(t, []string{
163+
"--json", "verify-request",
164+
"--envelope", "pilot-req-v1|env",
165+
"--signature", "c2ln",
166+
}, map[string]string{"PILOT_SOCKET": d.path})
167+
if code != 1 {
168+
t.Fatalf("exit=%d, want 1 on valid:false", code)
169+
}
170+
if !strings.Contains(stdout, "signature verification failed") {
171+
t.Errorf("verdict reason missing from output: %s", stdout)
172+
}
173+
}
174+
175+
func TestCLIVerifyRequestEnvelopeRequiresArgs(t *testing.T) {
176+
t.Parallel()
177+
_, stderr, code := runCLI(t, []string{"verify-request", "--envelope", "e"}, nil)
178+
if code == 0 {
179+
t.Error("expected non-zero exit without --signature")
180+
}
181+
if !strings.Contains(stderr, "--signature") && !strings.Contains(stderr, "signature") {
182+
t.Errorf("expected usage mention of --signature, got: %s", stderr)
183+
}
184+
}

0 commit comments

Comments
 (0)