Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/pilotctl/appstore_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
51 changes: 51 additions & 0 deletions cmd/pilotctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a> (--body-file <f> | --body-hash <64hex> | --body '<string>')

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 <f> hash the file's bytes (sha256)
--body '<string>' 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 '<canonical>' --signature '<b64>' [--standing] [--max-skew <secs>]

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 <secs> 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

Expand Down Expand Up @@ -1476,6 +1509,10 @@ Identity & recovery:
pilotctl recovery <enroll|new-key|recover> ... enroll / rotate / reclaim the address if the key is lost
pilotctl review <pilot|app-id> [--rating <1-5>] [--text "..."] rate Pilot or an installed app

Request signing (prove a request originates from this node):
pilotctl sign-request --audience <a> (--body-file <f> | --body-hash <64hex> | --body '<string>')
pilotctl verify-request --envelope '<canonical>' --signature '<b64>' [--standing] [--max-skew <secs>]

Management commands:
pilotctl connections
pilotctl disconnect <conn_id>
Expand Down Expand Up @@ -1695,6 +1732,10 @@ dispatch:
cmdVerify(cmdArgs)
case "recovery":
cmdRecovery(cmdArgs)
case "sign-request":
cmdSignRequest(cmdArgs)
case "verify-request":
cmdVerifyRequest(cmdArgs)

// Discovery
case "find":
Expand Down Expand Up @@ -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 <a>", "(--body-file <f> | --body-hash <64hex> | --body <string>)"},
"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 <canonical>", "--signature <b64>", "[--standing]", "[--max-skew <secs>]"},
"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{}{
Expand Down
95 changes: 95 additions & 0 deletions cmd/pilotctl/sign_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// 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 <a> (--body-file <f> | --body-hash <64hex> | --body '<string>')")
}

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 != "":
// #nosec G304 -- bodyFile is an operator-supplied CLI flag; reading it is the command's purpose
data, err := os.ReadFile(bodyFile)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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 '<canonical>' --signature '<b64>' [--standing] [--max-skew <secs>]")
}
standing := flagBool(flags, "standing")
maxSkew := flagInt(flags, "max-skew", 0)
if maxSkew < 0 || maxSkew > 86400 {
fatalCode("invalid_argument", "verify-request: --max-skew must be 0-86400 seconds")
}

d := connectDriver()
defer d.Close()
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)
}
output(resp)
if valid, _ := resp["valid"].(bool); !valid {
os.Exit(1)
}
}
184 changes: 184 additions & 0 deletions cmd/pilotctl/zz_sign_request_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading