Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
94 changes: 94 additions & 0 deletions cmd/pilotctl/sign_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"os"

"github.com/pilot-protocol/common/reqsig"

Check failure on line 8 in cmd/pilotctl/sign_request.go

View workflow job for this annotation

GitHub Actions / govulncheck

could not import github.com/pilot-protocol/common/reqsig (invalid package name: "")

Check failure on line 8 in cmd/pilotctl/sign_request.go

View workflow job for this annotation

GitHub Actions / docs/cli-reference.md up-to-date

no required module provides package github.com/pilot-protocol/common/reqsig; to add it:
)

// 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 != "":
data, err := os.ReadFile(bodyFile)

Check failure

Code scanning / gosec

Potential file inclusion via variable Error

Potential file inclusion via variable
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)

Check failure on line 56 in cmd/pilotctl/sign_request.go

View workflow job for this annotation

GitHub Actions / govulncheck

d.SignEnvelope undefined (type *driver.Driver has no field or method SignEnvelope)
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 {
fatalCode("invalid_argument", "verify-request: --max-skew must be >= 0")
}

d := connectDriver()
defer d.Close()
resp, err := d.VerifyEnvelopeMaxSkew(envelope, sig, standing, uint32(maxSkew))

Check failure on line 86 in cmd/pilotctl/sign_request.go

View workflow job for this annotation

GitHub Actions / govulncheck

d.VerifyEnvelopeMaxSkew undefined (type *driver.Driver has no field or method VerifyEnvelopeMaxSkew)

Check failure

Code scanning / CodeQL

Incorrect conversion between integer types High

Incorrect conversion of an integer with architecture-dependent bit size from
strconv.Atoi
to a lower bit size type uint32 without an upper bound check.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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