diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index bc5c3523..930c3088 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -1532,6 +1532,10 @@ dispatch: cmdSetPrivate(cmdArgs) case "deregister": cmdDeregister(cmdArgs) + case "verify": + cmdVerify(cmdArgs) + case "recovery": + cmdRecovery(cmdArgs) // Discovery case "find": diff --git a/cmd/pilotctl/verify.go b/cmd/pilotctl/verify.go new file mode 100644 index 00000000..0a74d398 --- /dev/null +++ b/cmd/pilotctl/verify.go @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "os" + + "github.com/pilot-protocol/common/crypto" + "github.com/pilot-protocol/common/protocol" + registry "github.com/pilot-protocol/common/registry/client" +) + +// loadJSONFile reads a small JSON credential file into v, exiting on error. +func loadJSONFile(path string, v interface{}) { + data, err := os.ReadFile(path) + if err != nil { + fatalCode("invalid_argument", "read %s: %v", path, err) + } + if err := json.Unmarshal(data, v); err != nil { + fatalCode("invalid_argument", "parse %s: %v", path, err) + } +} + +// nodeArgToID accepts either a textual Pilot address (N:NNNN.HHHH.LLLL) or a +// decimal node ID and returns the 32-bit node ID. +func nodeArgToID(s string) uint32 { + if addr, err := protocol.ParseAddr(s); err == nil { + return addr.Node + } + return parseNodeID(s) +} + +// cmdVerify attaches a verified-address badge to this node. The badge and its +// issuer signature are produced by the verifier (`pilot-verify verify`); this +// command hands them to the daemon, which proves ownership with the node key +// and submits to the registry. +// +// pilotctl verify --badge --badge-sig +// pilotctl verify --from cred.json # {"badge":..,"badge_sig":..} +func cmdVerify(args []string) { + flags, _ := parseFlags(args) + badge := flagString(flags, "badge", "") + badgeSig := flagString(flags, "badge-sig", "") + if from := flagString(flags, "from", ""); from != "" { + var m struct { + Badge string `json:"badge"` + BadgeSig string `json:"badge_sig"` + } + loadJSONFile(from, &m) + if badge == "" { + badge = m.Badge + } + if badgeSig == "" { + badgeSig = m.BadgeSig + } + } + if badge == "" || badgeSig == "" { + fatalCode("invalid_argument", + "usage: pilotctl verify --badge --badge-sig (or --from cred.json)") + } + d := connectDriver() + defer d.Close() + resp, err := d.SubmitBadge(badge, badgeSig) + if err != nil { + fatalCode("connection_failed", "verify: %v", err) + } + output(resp) +} + +// cmdRecovery dispatches the recovery subcommands. +func cmdRecovery(args []string) { + if len(args) < 1 { + fatalCode("invalid_argument", "usage: pilotctl recovery ...") + } + switch args[0] { + case "enroll": + cmdRecoveryEnroll(args[1:]) + case "new-key": + cmdRecoveryNewKey(args[1:]) + case "recover": + cmdRecoveryRecover(args[1:]) + default: + fatalCode("invalid_argument", "unknown recovery subcommand %q (want enroll|new-key|recover)", args[0]) + } +} + +// cmdRecoveryEnroll records this node's opaque recovery commitment so the +// address can be recovered later if the key is lost. The enrollment + signature +// come from `pilot-verify enroll`. +// +// pilotctl recovery enroll --enrollment --enrollment-sig +// pilotctl recovery enroll --from enroll.json +func cmdRecoveryEnroll(args []string) { + flags, _ := parseFlags(args) + enrollment := flagString(flags, "enrollment", "") + enrollSig := flagString(flags, "enrollment-sig", "") + if from := flagString(flags, "from", ""); from != "" { + var m struct { + Enrollment string `json:"enrollment"` + EnrollmentSig string `json:"enrollment_sig"` + } + loadJSONFile(from, &m) + if enrollment == "" { + enrollment = m.Enrollment + } + if enrollSig == "" { + enrollSig = m.EnrollmentSig + } + } + if enrollment == "" || enrollSig == "" { + fatalCode("invalid_argument", + "usage: pilotctl recovery enroll --enrollment --enrollment-sig (or --from enroll.json)") + } + d := connectDriver() + defer d.Close() + resp, err := d.EnrollRecovery(enrollment, enrollSig) + if err != nil { + fatalCode("connection_failed", "recovery enroll: %v", err) + } + output(resp) +} + +// cmdRecoveryNewKey generates a fresh keypair for an address that is being +// recovered and prints its public key. The custodian of the cold recovery key +// signs an authorization binding this public key; `pilotctl recovery recover` +// then submits it. The private key stays local in --out. +// +// pilotctl recovery new-key [--out ] +func cmdRecoveryNewKey(args []string) { + flags, _ := parseFlags(args) + out := flagString(flags, "out", configDir()+"/recovery-identity.json") + id, err := crypto.GenerateIdentity() + if err != nil { + fatalCode("internal_error", "recovery new-key: generate: %v", err) + } + if err := crypto.SaveIdentity(out, id); err != nil { + fatalCode("internal_error", "recovery new-key: save %s: %v", out, err) + } + output(map[string]interface{}{ + "new_identity_path": out, + "new_public_key": crypto.EncodePublicKey(id.PublicKey), + "next": "give new_public_key and your node address to the recovery-key custodian; " + + "they return a recovery authorization for `pilotctl recovery recover`", + }) +} + +// cmdRecoveryRecover force-rotates a lost address to the new key created by +// `recovery new-key`, using a cold-key-signed authorization. No current key is +// required โ€” that is the point of recovery. On success the new identity is +// installed at the daemon identity path; restart the daemon to adopt it. +// +// pilotctl recovery recover --node --new-key \ +// --recovery --recovery-sig [--from recover.json] \ +// [--identity ] [--registry host:port] +func cmdRecoveryRecover(args []string) { + flags, _ := parseFlags(args) + nodeArg := flagString(flags, "node", "") + recovery := flagString(flags, "recovery", "") + recoverySig := flagString(flags, "recovery-sig", "") + newKeyPath := flagString(flags, "new-key", "") + if from := flagString(flags, "from", ""); from != "" { + var m struct { + Recovery string `json:"recovery"` + RecoverySig string `json:"recovery_sig"` + } + loadJSONFile(from, &m) + if recovery == "" { + recovery = m.Recovery + } + if recoverySig == "" { + recoverySig = m.RecoverySig + } + } + if nodeArg == "" || recovery == "" || recoverySig == "" || newKeyPath == "" { + fatalCode("invalid_argument", + "usage: pilotctl recovery recover --node --new-key --recovery --recovery-sig ") + } + nodeID := nodeArgToID(nodeArg) + id, err := crypto.LoadIdentity(newKeyPath) + if err != nil { + fatalCode("invalid_argument", "recovery recover: load new key %s: %v", newKeyPath, err) + } + newPub := crypto.EncodePublicKey(id.PublicKey) + + addr := flagString(flags, "registry", getRegistry()) + rc, err := registry.Dial(addr) + if err != nil { + fatalCode("connection_failed", "recovery recover: cannot reach registry at %s: %v", addr, err) + } + defer rc.Close() + + resp, err := rc.RecoverIdentity(nodeID, recovery, recoverySig, newPub) + if err != nil { + fatalCode("permission_denied", "recovery recover: %v", err) + } + + // The registry rotated the address to the new key; install it locally so + // the daemon can authenticate as the recovered node after a restart. + idPath := flagString(flags, "identity", configDir()+"/identity.json") + if err := crypto.SaveIdentity(idPath, id); err != nil { + fatalCode("internal_error", + "recovery recover: registry rotated key but installing new identity at %s failed: %v", idPath, err) + } + output(map[string]interface{}{ + "recovered": true, + "node_id": nodeID, + "new_public_key": newPub, + "identity_path": idPath, + "next": "restart the daemon so it authenticates with the recovered key", + "registry": resp, + }) +} diff --git a/cmd/pilotctl/zz_fake_daemon_test.go b/cmd/pilotctl/zz_fake_daemon_test.go index 6dfb154d..64afb450 100644 --- a/cmd/pilotctl/zz_fake_daemon_test.go +++ b/cmd/pilotctl/zz_fake_daemon_test.go @@ -53,6 +53,10 @@ const ( tdCmdRotateKeyOK byte = 0x26 tdCmdBroadcast byte = 0x29 tdCmdBroadcastOK byte = 0x2A + tdCmdSubmitBadge byte = 0x2F + tdCmdSubmitBadgeOK byte = 0x30 + tdCmdEnrollRecovery byte = 0x31 + tdCmdEnrollRecoveryOK byte = 0x32 ) // shortSock returns a /tmp/ps-XXX.sock path short enough for macOS's diff --git a/cmd/pilotctl/zz_verify_cmds_test.go b/cmd/pilotctl/zz_verify_cmds_test.go new file mode 100644 index 00000000..ae494023 --- /dev/null +++ b/cmd/pilotctl/zz_verify_cmds_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pilot-protocol/common/crypto" +) + +// TestCmdVerifyForwardsBadge drives `pilotctl verify` against a fake daemon and +// asserts the badge round-trips to the daemon's submit_badge handler. +func TestCmdVerifyForwardsBadge(t *testing.T) { + d := newFakeDaemon(t) + d.useDaemon(t) + + var gotBadge, gotSig string + d.on(tdCmdSubmitBadge, func(frame []byte) [][]byte { + var req map[string]string + _ = json.Unmarshal(frame[1:], &req) + gotBadge, gotSig = req["badge"], req["badge_sig"] + return [][]byte{append([]byte{tdCmdSubmitBadgeOK}, []byte(`{"ok":true}`)...)} + }) + + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = true + out := captureStdout(t, func() { + cmdVerify([]string{"--badge", "pilotbadge:v1:7:github:1781827200:0:bdg-v1:", "--badge-sig", "c2ln"}) + }) + if gotBadge != "pilotbadge:v1:7:github:1781827200:0:bdg-v1:" || gotSig != "c2ln" { + t.Errorf("daemon got badge=%q sig=%q", gotBadge, gotSig) + } + if !strings.Contains(out, "ok") { + t.Errorf("output missing ok: %s", out) + } +} + +// TestCmdVerifyFromFile covers the --from JSON convenience path. +func TestCmdVerifyFromFile(t *testing.T) { + d := newFakeDaemon(t) + d.useDaemon(t) + var gotBadge string + d.on(tdCmdSubmitBadge, func(frame []byte) [][]byte { + var req map[string]string + _ = json.Unmarshal(frame[1:], &req) + gotBadge = req["badge"] + return [][]byte{append([]byte{tdCmdSubmitBadgeOK}, []byte(`{"ok":true}`)...)} + }) + + dir := t.TempDir() + cred := filepath.Join(dir, "cred.json") + _ = os.WriteFile(cred, []byte(`{"badge":"pilotbadge:v1:7:github:1:0:bdg-v1:","badge_sig":"eA"}`), 0o600) + + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = true + _ = captureStdout(t, func() { cmdVerify([]string{"--from", cred}) }) + if gotBadge != "pilotbadge:v1:7:github:1:0:bdg-v1:" { + t.Errorf("daemon got badge=%q from --from file", gotBadge) + } +} + +// TestCmdRecoveryEnrollForwards drives `pilotctl recovery enroll`. +func TestCmdRecoveryEnrollForwards(t *testing.T) { + d := newFakeDaemon(t) + d.useDaemon(t) + var gotEnroll string + d.on(tdCmdEnrollRecovery, func(frame []byte) [][]byte { + var req map[string]string + _ = json.Unmarshal(frame[1:], &req) + gotEnroll = req["enrollment"] + return [][]byte{append([]byte{tdCmdEnrollRecoveryOK}, []byte(`{"ok":true}`)...)} + }) + + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = true + _ = captureStdout(t, func() { + cmdRecovery([]string{"enroll", "--enrollment", "pilotenroll:v1:7:github:Yw:1:bdg-v1", "--enrollment-sig", "cw"}) + }) + if gotEnroll != "pilotenroll:v1:7:github:Yw:1:bdg-v1" { + t.Errorf("daemon got enrollment=%q", gotEnroll) + } +} + +// TestCmdRecoveryNewKeyGeneratesLoadableIdentity covers the local key +// generation step: the printed public key matches the saved identity, and the +// file is a valid loadable identity. +func TestCmdRecoveryNewKeyGeneratesLoadableIdentity(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "rec.json") + + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = true + stdout := captureStdout(t, func() { cmdRecovery([]string{"new-key", "--out", out}) }) + + var env struct { + Data struct { + NewPublicKey string `json:"new_public_key"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("output not JSON: %v (%s)", err, stdout) + } + id, err := crypto.LoadIdentity(out) + if err != nil { + t.Fatalf("saved identity not loadable: %v", err) + } + if got := crypto.EncodePublicKey(id.PublicKey); got != env.Data.NewPublicKey { + t.Errorf("printed pubkey %q != saved identity pubkey %q", env.Data.NewPublicKey, got) + } +} + +// TestNodeArgToID accepts both a decimal node ID and a textual address. +func TestNodeArgToID(t *testing.T) { + if got := nodeArgToID("99"); got != 99 { + t.Errorf("decimal: got %d, want 99", got) + } + if got := nodeArgToID("0:0000.0000.0063"); got != 99 { + t.Errorf("address: got %d, want 99", got) + } +} diff --git a/go.mod b/go.mod index f0f1be62..ae9433f6 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/coder/websocket v1.8.14 github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72 github.com/pilot-protocol/beacon v0.2.6 - github.com/pilot-protocol/common v0.4.9-0.20260617090851-05b869280522 + github.com/pilot-protocol/common v0.5.2 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 5d298c58..c35ae8df 100644 --- a/go.sum +++ b/go.sum @@ -1,68 +1,38 @@ +github.com/TeoSlayer/pilotprotocol v1.10.5 h1:2zIVdjLoGSBnRtzpKSFvM9J1tf4Vp1fNmsZGBbBWLus= +github.com/TeoSlayer/pilotprotocol v1.10.5/go.mod h1:gabsXHzwAeIJGSU5zdwoeSolP5dWfPrmZZj6obS0sBw= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/pilot-protocol/app-store v0.2.0 h1:/SbLPa2dKnvhv0ITeaYMZw/2KXXxWDRuYUdM5z9eyKk= -github.com/pilot-protocol/app-store v0.2.0/go.mod h1:f0umeJxswDG8/CctHpSFMlr5GLtE2GlPKkijIQErZuc= -github.com/pilot-protocol/app-store v1.0.0-rc1.0.20260609015400-d02db7da3924 h1:pUK7tOsFIqYr2vwcfKT4t81+ntuVDbyffajkJa+2A3U= -github.com/pilot-protocol/app-store v1.0.0-rc1.0.20260609015400-d02db7da3924/go.mod h1:f0umeJxswDG8/CctHpSFMlr5GLtE2GlPKkijIQErZuc= -github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264 h1:NL9rFdakbVQ0V7xfJbCk8RJZSaQ1AmvdhAJwFIouMsk= -github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264/go.mod h1:zoCxHYoNdj0V44OkG3Yzcye0jnwZDVUcJgAvR5Z1kwc= github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72 h1:vDiQ7ZheKIzlNqfviu5zeQzGVTMP63k1hC5HodEuyeQ= github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72/go.mod h1:leZPtX43gE2JB7xeljexXri81g6qhdZfYExLtzI+bhg= -github.com/pilot-protocol/beacon v0.2.5 h1:5+pkSPoA35r+u4Hfrph/ZfOltOyiy8lh1sCfK5XqXKs= -github.com/pilot-protocol/beacon v0.2.5/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4= 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.4.8 h1:eS2Bc+XcZWJ/qhwwOZbXwIWhtNdOijuoEp716kQE+/c= -github.com/pilot-protocol/common v0.4.8/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= -github.com/pilot-protocol/common v0.4.9-0.20260615113553-d5cbbfb3e5b6 h1:Us3qSMPTBHPDQXFPY07BoUanriw1rVzS6SAHcbddqzY= -github.com/pilot-protocol/common v0.4.9-0.20260615113553-d5cbbfb3e5b6/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= -github.com/pilot-protocol/common v0.4.9-0.20260617090851-05b869280522 h1:QzAoRVpzatZCr7nUemEbqPgcWkQ5Ssb9q+vgfzqSB3U= -github.com/pilot-protocol/common v0.4.9-0.20260617090851-05b869280522/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= -github.com/pilot-protocol/dataexchange v0.2.0 h1:ldE6AyrES1uvdnn1NBl0KZ7C+SSWNtmeHHU3CQhwSCo= -github.com/pilot-protocol/dataexchange v0.2.0/go.mod h1:JVy2+hr/IjzMPshxjExbGO/4SbJTs7ZJ7iYvT/ODF3Q= +github.com/pilot-protocol/common v0.5.2 h1:9XfXChC/9fX+BH0TT/wBwcgOMh7m+0IDjf80pjxqiRk= +github.com/pilot-protocol/common v0.5.2/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= 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= github.com/pilot-protocol/eventstream v0.2.2/go.mod h1:gUjoMEItW1SRJYEq39VlcIeDe2LcE5B18/4bcaUJNrs= -github.com/pilot-protocol/handshake v0.2.0 h1:uLeV8iNHcsHbcVH+GZ9p7uuIbObA8BReDByF5XGjzB8= -github.com/pilot-protocol/handshake v0.2.0/go.mod h1:wMFeDFbz8Wu1aPZkcLAFDosZhYa3l5ab/SxUbRqhOig= github.com/pilot-protocol/handshake v0.2.1 h1:ZPRLKPV5Heigzj4HNkwb/JOiabdVPg3XzH4LocCCCZo= github.com/pilot-protocol/handshake v0.2.1/go.mod h1:g88rTfLUY9sxj9j41IqjpuEBZ5Rwe4xsOjh8JmTeGT8= github.com/pilot-protocol/nameserver v0.2.1 h1:ACCa6WVkEoDZVctEe09WB24Lfobt/JoLDGIqX2MEnss= github.com/pilot-protocol/nameserver v0.2.1/go.mod h1:ze4VFe74xdbyqu5oFPt0YGRrRMMnHWb/AczJhirG+h4= -github.com/pilot-protocol/policy v0.2.1 h1:s3aMgeDFVYzDNBcdvO4mzDRlrRF5CvMepW7az4HSmVU= -github.com/pilot-protocol/policy v0.2.1/go.mod h1:jzsAO71uGlRVyduPrQmS/UeqSwUpeUO/CjykHm/IfXM= github.com/pilot-protocol/policy v0.2.2 h1:Co8sqZ4lRQfFA0Ot8l33VhsEwfiEVqcPz+kHY49QbkM= github.com/pilot-protocol/policy v0.2.2/go.mod h1:jzsAO71uGlRVyduPrQmS/UeqSwUpeUO/CjykHm/IfXM= -github.com/pilot-protocol/rendezvous v0.2.4 h1:nxYm12RzEUA6zzNGcnDqxNcGBSIALL5uRH9zX+Q0CSg= -github.com/pilot-protocol/rendezvous v0.2.4/go.mod h1:w7SC0nZCmWPyc7hxdFZ200zQVA75UoaC25MznUQXNFE= github.com/pilot-protocol/rendezvous v0.2.5-0.20260615154750-f09cf1a708b0 h1:kKeSXIEfE677+yanooStfSnneQ3dFCb22WPJbyj+yng= github.com/pilot-protocol/rendezvous v0.2.5-0.20260615154750-f09cf1a708b0/go.mod h1:Gv1BwKx5oBUZCMNpCxa9enBFa6uy9hHDSI2r28QdIrg= -github.com/pilot-protocol/runtime v0.3.0 h1:OVPv7hyaAZsw5EPWtf2XQGfCqxgaM/iANPhIYOMp+0w= -github.com/pilot-protocol/runtime v0.3.0/go.mod h1:GfFEIji0w7H9SSNR9Wl2q72pd2OYN3PHY9Qhcbvyrqk= github.com/pilot-protocol/runtime v0.3.1 h1:+W9ww0dZY/FgOBtCmIOV3w5L5Z4Upt/RIsrYElXZ1zs= github.com/pilot-protocol/runtime v0.3.1/go.mod h1:GfFEIji0w7H9SSNR9Wl2q72pd2OYN3PHY9Qhcbvyrqk= -github.com/pilot-protocol/skillinject v0.2.1 h1:r7cwDlRTLHGPhL2+RtGa0GWz/M89yw0My+OND+wP5Ic= -github.com/pilot-protocol/skillinject v0.2.1/go.mod h1:toizAf7eI2IgsDRGiqF3mRiVpF4ISYwVQeO3ZltZEcM= -github.com/pilot-protocol/skillinject v0.2.2 h1:cQKvafj2hJM7ewhrRuWnb8a3uzdgSPvkNs1F2j1NlUA= -github.com/pilot-protocol/skillinject v0.2.2/go.mod h1:toizAf7eI2IgsDRGiqF3mRiVpF4ISYwVQeO3ZltZEcM= github.com/pilot-protocol/skillinject v0.2.3 h1:Bf0tqRe7tqYY27X5RGCOf4LGjtWpyQvN/03YumDBDJs= github.com/pilot-protocol/skillinject v0.2.3/go.mod h1:fCzivA/bjkXRgGjp6yd7nqfaIETtU+lQRocBu0J/O9g= -github.com/pilot-protocol/trustedagents v0.2.2 h1:EpK25654aN+CBeBhZkHUPh3J545pGoxLofLJmDmo1F0= -github.com/pilot-protocol/trustedagents v0.2.2/go.mod h1:r3wYwh5QpFDwG4nXbCA3RH2aA+hqM07KLMFFc3tbvKA= github.com/pilot-protocol/trustedagents v0.2.3 h1:QQJHYqzPrECJwkCev0xIDBMjd92uhtcxcCMc2aOrRHc= github.com/pilot-protocol/trustedagents v0.2.3/go.mod h1:gDgEOC9lHmXSS9v45h80XxlmUS861soIrA0AsbXiSV4= -github.com/pilot-protocol/updater v0.2.2-0.20260529065627-220ed5b8383f h1:1dyunPeEOriqTv1xbt0fuDwieA5V5NLU1nVSC2714jU= -github.com/pilot-protocol/updater v0.2.2-0.20260529065627-220ed5b8383f/go.mod h1:/I0uhVk1SljAOEYmjTdI/6CP7UmemmV4WB22ai1FxUw= github.com/pilot-protocol/updater v0.2.2-0.20260616131353-92a3a30a235e h1:vFzuw5dUVi0igwI2PdVzDY8OnY6FDLzM05wzI75zUZ8= github.com/pilot-protocol/updater v0.2.2-0.20260616131353-92a3a30a235e/go.mod h1:/I0uhVk1SljAOEYmjTdI/6CP7UmemmV4WB22ai1FxUw= github.com/pilot-protocol/webhook v0.2.0 h1:3UFU9X2yBb0iKlPbzVcism+Z6yCrBBaOgdo9+vd4Wf4= github.com/pilot-protocol/webhook v0.2.0/go.mod h1:WVXhHFg+o0pHHk+4nXMCh1zl/ZAyZ3AXrtx6mNuZS6g= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/pkg/daemon/ipc.go b/pkg/daemon/ipc.go index a0b21d8f..e0315318 100644 --- a/pkg/daemon/ipc.go +++ b/pkg/daemon/ipc.go @@ -5,6 +5,7 @@ package daemon import ( "context" "crypto/subtle" + "encoding/base64" "encoding/binary" "encoding/json" "errors" @@ -17,6 +18,7 @@ import ( "syscall" "time" + "github.com/pilot-protocol/common/badgeverify" "github.com/pilot-protocol/common/ipcutil" "github.com/pilot-protocol/common/protocol" ) @@ -82,6 +84,16 @@ const ( // the full 14-31 s retry budget, filling the per-conn dispatch // semaphore under burst load (ยง4.8 stress). CmdCancel byte = 0x2B + // CmdSubmitBadge attaches a verified-address badge to this node and + // CmdEnrollRecovery records its opaque recovery commitment. Both carry a + // JSON payload of verifier-produced credentials; the daemon adds a + // signature by the current key proving ownership before forwarding to + // the registry. Optional โ€” unverified nodes never send these. (Must + // match driver cmdSubmitBadge/cmdEnrollRecovery in common/driver.) + CmdSubmitBadge byte = 0x2F + CmdSubmitBadgeOK byte = 0x30 + CmdEnrollRecovery byte = 0x31 + CmdEnrollRecoveryOK byte = 0x32 ) // Network sub-commands (second byte of CmdNetwork payload) @@ -760,6 +772,10 @@ func (s *IPCServer) dispatch(conn *ipcConn, cmd byte, reqID uint64, payload []by s.handleRotateKey(conn, reqID) case CmdPreferDirect: s.handlePreferDirect(conn, reqID, payload) + case CmdSubmitBadge: + s.handleSubmitBadge(conn, reqID, payload) + case CmdEnrollRecovery: + s.handleEnrollRecovery(conn, reqID, payload) default: s.sendError(conn, reqID, fmt.Sprintf("unknown command: 0x%02X", cmd)) } @@ -1436,6 +1452,99 @@ func (s *IPCServer) handleRotateKey(conn *ipcConn, reqID uint64) { } } +// handleSubmitBadge attaches a verifier-produced badge to this node. The badge +// and badge_sig come straight from the verifier sidecar; here the daemon adds +// a signature by the CURRENT key over "submit_badge::" proving +// it owns the address, then forwards both to the registry (which also verifies +// the badge offline against the pinned issuer key). +func (s *IPCServer) handleSubmitBadge(conn *ipcConn, reqID uint64, payload []byte) { + var req struct { + Badge string `json:"badge"` + BadgeSig string `json:"badge_sig"` + } + if err := json.Unmarshal(payload, &req); err != nil { + s.sendError(conn, reqID, fmt.Sprintf("submit_badge: bad payload: %v", err)) + return + } + if req.Badge == "" || req.BadgeSig == "" { + s.sendError(conn, reqID, "submit_badge: badge and badge_sig required") + return + } + if s.daemon.regConn == nil { + s.sendError(conn, reqID, "submit_badge: registry connection unavailable") + return + } + nodeID := s.daemon.NodeID() + sig := s.daemon.Sign([]byte(fmt.Sprintf("submit_badge:%d:%s", nodeID, req.Badge))) + if sig == nil { + s.sendError(conn, reqID, "submit_badge: daemon has no identity") + return + } + sigB64 := base64.StdEncoding.EncodeToString(sig) + result, err := s.daemon.regConn.SubmitBadge(nodeID, req.Badge, req.BadgeSig, sigB64) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("submit_badge: %v", err)) + return + } + data, err := json.Marshal(result) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("submit_badge marshal: %v", err)) + return + } + if err := conn.writeReply(CmdSubmitBadgeOK, reqID, data); err != nil { + slog.Debug("IPC submit_badge reply failed", "err", err) + } +} + +// handleEnrollRecovery records this node's opaque recovery commitment so the +// address can later be recovered if the current key is lost. The daemon signs +// "enroll_recovery::" with the current key; the commitment +// is parsed from the verifier-produced enrollment so the raw external identity +// never reaches the daemon. +func (s *IPCServer) handleEnrollRecovery(conn *ipcConn, reqID uint64, payload []byte) { + var req struct { + Enrollment string `json:"enrollment"` + EnrollmentSig string `json:"enrollment_sig"` + } + if err := json.Unmarshal(payload, &req); err != nil { + s.sendError(conn, reqID, fmt.Sprintf("enroll_recovery: bad payload: %v", err)) + return + } + if req.Enrollment == "" || req.EnrollmentSig == "" { + s.sendError(conn, reqID, "enroll_recovery: enrollment and enrollment_sig required") + return + } + enr, err := badgeverify.ParseEnrollment(req.Enrollment) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("enroll_recovery: bad enrollment: %v", err)) + return + } + if s.daemon.regConn == nil { + s.sendError(conn, reqID, "enroll_recovery: registry connection unavailable") + return + } + nodeID := s.daemon.NodeID() + sig := s.daemon.Sign([]byte(fmt.Sprintf("enroll_recovery:%d:%s", nodeID, enr.Commitment))) + if sig == nil { + s.sendError(conn, reqID, "enroll_recovery: daemon has no identity") + return + } + sigB64 := base64.StdEncoding.EncodeToString(sig) + result, err := s.daemon.regConn.EnrollRecovery(nodeID, req.Enrollment, req.EnrollmentSig, sigB64) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("enroll_recovery: %v", err)) + return + } + data, err := json.Marshal(result) + if err != nil { + s.sendError(conn, reqID, fmt.Sprintf("enroll_recovery marshal: %v", err)) + return + } + if err := conn.writeReply(CmdEnrollRecoveryOK, reqID, data); err != nil { + slog.Debug("IPC enroll_recovery reply failed", "err", err) + } +} + func (s *IPCServer) handleSetWebhook(conn *ipcConn, reqID uint64, payload []byte) { url := string(payload) // empty string = clear webhook if url != "" { diff --git a/pkg/daemon/zz_ipc_badge_handlers_test.go b/pkg/daemon/zz_ipc_badge_handlers_test.go new file mode 100644 index 00000000..55c316f5 --- /dev/null +++ b/pkg/daemon/zz_ipc_badge_handlers_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "testing" + + "github.com/pilot-protocol/common/badgeverify" + "github.com/pilot-protocol/common/crypto" +) + +// --- handleSubmitBadge ----------------------------------------------------- + +// TestHandleSubmitBadgeSignsAndForwards proves the happy path: the handler +// signs "submit_badge::" with the CURRENT key, forwards the +// badge + badge_sig + node signature to the registry, and the signature +// actually verifies against the node's public key (so a registry can trust +// the address owns this badge). +func TestHandleSubmitBadgeSignsAndForwards(t *testing.T) { + t.Parallel() + + const badge = "pilotbadge:v1:7:github:1781827200:0:bdg-v1:" + const badgeSig = "aXNzdWVyLXNpZw==" + + var gotBadge, gotBadgeSig, gotSig string + client, cleanup := startFakeRegistry(t, func(req map[string]interface{}) map[string]interface{} { + if req["type"] == "submit_badge" { + gotBadge, _ = req["badge"].(string) + gotBadgeSig, _ = req["badge_sig"].(string) + gotSig, _ = req["signature"].(string) + return map[string]interface{}{"ok": true} + } + return map[string]interface{}{} + }) + defer cleanup() + + d, s := newSimpleHandlerDaemon(t, client) + id, err := crypto.GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + d.identity = id + + payload, _ := json.Marshal(map[string]string{"badge": badge, "badge_sig": badgeSig}) + ic, client2 := newIPCTestConn(t) + reply := runHandler(t, client2, func() { s.handleSubmitBadge(ic, 0, payload) }) + + if reply[0] != CmdSubmitBadgeOK { + t.Fatalf("opcode = 0x%02X, want CmdSubmitBadgeOK (0x%02X); body=%q", reply[0], CmdSubmitBadgeOK, reply[3:]) + } + if gotBadge != badge || gotBadgeSig != badgeSig { + t.Errorf("registry got badge=%q sig=%q, want %q / %q", gotBadge, gotBadgeSig, badge, badgeSig) + } + // The node-ownership signature must verify over the canonical challenge. + rawSig, err := base64.StdEncoding.DecodeString(gotSig) + if err != nil { + t.Fatalf("node signature not base64: %v", err) + } + challenge := fmt.Sprintf("submit_badge:%d:%s", d.NodeID(), badge) + if !ed25519.Verify(id.PublicKey, []byte(challenge), rawSig) { + t.Errorf("node-ownership signature does not verify over %q", challenge) + } +} + +func TestHandleSubmitBadgeBadPayloadReturnsError(t *testing.T) { + t.Parallel() + _, s := newSimpleHandlerDaemon(t, nil) + ic, client := newIPCTestConn(t) + reply := runHandler(t, client, func() { s.handleSubmitBadge(ic, 0, []byte("not json")) }) + if reply[0] != CmdError { + t.Fatalf("opcode 0x%02X, want CmdError", reply[0]) + } +} + +func TestHandleSubmitBadgeMissingFieldsReturnsError(t *testing.T) { + t.Parallel() + _, s := newSimpleHandlerDaemon(t, nil) + ic, client := newIPCTestConn(t) + payload, _ := json.Marshal(map[string]string{"badge": "x"}) // no badge_sig + reply := runHandler(t, client, func() { s.handleSubmitBadge(ic, 0, payload) }) + if reply[0] != CmdError { + t.Fatalf("opcode 0x%02X, want CmdError on missing badge_sig", reply[0]) + } +} + +func TestHandleSubmitBadgeNoIdentityReturnsError(t *testing.T) { + t.Parallel() + // regConn present but identity nil โ†’ cannot prove ownership. + client, cleanup := startFakeRegistry(t, func(req map[string]interface{}) map[string]interface{} { + return map[string]interface{}{"ok": true} + }) + defer cleanup() + d, s := newSimpleHandlerDaemon(t, client) + d.identity = nil + + payload, _ := json.Marshal(map[string]string{"badge": "b", "badge_sig": "s"}) + ic, client2 := newIPCTestConn(t) + reply := runHandler(t, client2, func() { s.handleSubmitBadge(ic, 0, payload) }) + if reply[0] != CmdError { + t.Fatalf("opcode 0x%02X, want CmdError when daemon has no identity", reply[0]) + } +} + +// --- handleEnrollRecovery -------------------------------------------------- + +// TestHandleEnrollRecoverySignsCommitment proves the handler parses the +// verifier-produced enrollment, signs the COMMITMENT (not the whole string) +// with the current key, and forwards everything to the registry. +func TestHandleEnrollRecoverySignsCommitment(t *testing.T) { + t.Parallel() + + const commitment = "Y29tbWl0bWVudC1obWFj" + enrollment, err := badgeverify.CanonicalEnrollment(badgeverify.Enrollment{ + NodeID: 7, + Provider: "github", + Commitment: commitment, + IssuedAt: 1781827200, + Kid: "bdg-v1", + }) + if err != nil { + t.Fatalf("CanonicalEnrollment: %v", err) + } + const enrollSig = "ZW5yb2xsLXNpZw==" + + var gotEnrollment, gotSig string + client, cleanup := startFakeRegistry(t, func(req map[string]interface{}) map[string]interface{} { + if req["type"] == "enroll_recovery" { + gotEnrollment, _ = req["enrollment"].(string) + gotSig, _ = req["signature"].(string) + return map[string]interface{}{"ok": true} + } + return map[string]interface{}{} + }) + defer cleanup() + + d, s := newSimpleHandlerDaemon(t, client) + id, err := crypto.GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + d.identity = id + + payload, _ := json.Marshal(map[string]string{"enrollment": enrollment, "enrollment_sig": enrollSig}) + ic, client2 := newIPCTestConn(t) + reply := runHandler(t, client2, func() { s.handleEnrollRecovery(ic, 0, payload) }) + + if reply[0] != CmdEnrollRecoveryOK { + t.Fatalf("opcode = 0x%02X, want CmdEnrollRecoveryOK (0x%02X); body=%q", reply[0], CmdEnrollRecoveryOK, reply[3:]) + } + if gotEnrollment != enrollment { + t.Errorf("registry got enrollment=%q, want %q", gotEnrollment, enrollment) + } + // Signature must be over the COMMITMENT, proving the daemon parsed it. + rawSig, err := base64.StdEncoding.DecodeString(gotSig) + if err != nil { + t.Fatalf("node signature not base64: %v", err) + } + challenge := fmt.Sprintf("enroll_recovery:%d:%s", d.NodeID(), commitment) + if !ed25519.Verify(id.PublicKey, []byte(challenge), rawSig) { + t.Errorf("recovery-enrollment signature does not verify over %q", challenge) + } +} + +func TestHandleEnrollRecoveryBadEnrollmentReturnsError(t *testing.T) { + t.Parallel() + d, s := newSimpleHandlerDaemon(t, nil) + id, _ := crypto.GenerateIdentity() + d.identity = id + payload, _ := json.Marshal(map[string]string{"enrollment": "garbage", "enrollment_sig": "s"}) + ic, client := newIPCTestConn(t) + reply := runHandler(t, client, func() { s.handleEnrollRecovery(ic, 0, payload) }) + if reply[0] != CmdError { + t.Fatalf("opcode 0x%02X, want CmdError on unparseable enrollment", reply[0]) + } +}