From 8aa9b67a7da75dadd1a20293932b77189267eec4 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 21 Jun 2026 14:09:15 +0300 Subject: [PATCH] Add pilotctl verify status with offline check and how-to --- cmd/pilotctl/verify.go | 111 +++++++++++++++++++++++++- cmd/pilotctl/zz_verify_status_test.go | 85 ++++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 cmd/pilotctl/zz_verify_status_test.go diff --git a/cmd/pilotctl/verify.go b/cmd/pilotctl/verify.go index 0a74d398..866cb866 100644 --- a/cmd/pilotctl/verify.go +++ b/cmd/pilotctl/verify.go @@ -4,8 +4,10 @@ package main import ( "encoding/json" + "fmt" "os" + "github.com/pilot-protocol/common/badgeverify" "github.com/pilot-protocol/common/crypto" "github.com/pilot-protocol/common/protocol" registry "github.com/pilot-protocol/common/registry/client" @@ -36,9 +38,15 @@ func nodeArgToID(s string) uint32 { // command hands them to the daemon, which proves ownership with the node key // and submits to the registry. // +// pilotctl verify # show your own verification status +// pilotctl verify status # same // pilotctl verify --badge --badge-sig // pilotctl verify --from cred.json # {"badge":..,"badge_sig":..} func cmdVerify(args []string) { + if len(args) >= 1 && args[0] == "status" { + cmdVerifyStatus(args[1:]) + return + } flags, _ := parseFlags(args) badge := flagString(flags, "badge", "") badgeSig := flagString(flags, "badge-sig", "") @@ -55,9 +63,11 @@ func cmdVerify(args []string) { badgeSig = m.BadgeSig } } + // Bare `pilotctl verify` with nothing to submit is a status check — show + // the user whether they are verified and, if not, how to become verified. if badge == "" || badgeSig == "" { - fatalCode("invalid_argument", - "usage: pilotctl verify --badge --badge-sig (or --from cred.json)") + cmdVerifyStatus(args) + return } d := connectDriver() defer d.Close() @@ -68,6 +78,103 @@ func cmdVerify(args []string) { output(resp) } +// cmdVerifyStatus shows whether THIS node carries a verified-address badge. +// The badge is read from the registry (untrusted transport) and then verified +// OFFLINE against the pinned issuer key — we never take the registry's word for +// it. When unverified, it prints how to get verified. +// +// pilotctl verify status [--node ] +func cmdVerifyStatus(args []string) { + flags, _ := parseFlags(args) + + var nodeID uint32 + var address string + if n := flagString(flags, "node", ""); n != "" { + nodeID = nodeArgToID(n) + } else { + d := connectDriver() + info, err := d.Info() + d.Close() + if err != nil { + fatalCode("connection_failed", + "verify status: cannot reach the daemon (is it running?): %v", err) + } + if v, ok := info["node_id"].(float64); ok { + nodeID = uint32(v) + } + address, _ = info["address"].(string) + } + + rc := connectRegistry() + defer rc.Close() + resp, err := rc.Lookup(nodeID) + if err != nil { + fatalCode("connection_failed", "verify status: registry lookup: %v", err) + } + + badge, _ := resp["badge"].(string) + badgeSig, _ := resp["badge_sig"].(string) + provider, _ := resp["verification_provider"].(string) + verifiedAt, _ := resp["verified_at"].(string) + + out := map[string]interface{}{"node_id": nodeID} + if address != "" { + out["address"] = address + } + + status := "not_verified" + verified := false + var detail string + if badge != "" { + if _, verr := badgeverify.VerifyForNode(badge, badgeSig, nodeID); verr == nil { + status, verified = "verified", true + } else { + status, detail = "badge_present_unvalidated", verr.Error() + } + } + out["verified"] = verified + out["status"] = status + if provider != "" { + out["provider"] = provider + } + if verifiedAt != "" { + out["verified_at"] = verifiedAt + } + if status == "not_verified" { + out["how_to_verify"] = []string{ + fmt.Sprintf("1. pilot-verify verify --provider github --node-id %d --github-client-id ", nodeID), + " (authorize in your browser; it prints a badge + signature)", + "2. pilotctl verify --badge --badge-sig ", + } + } + if detail != "" { + out["detail"] = detail + } + + if jsonOutput { + output(out) + return + } + switch status { + case "verified": + line := fmt.Sprintf("✓ Verified via %s", provider) + if verifiedAt != "" { + line += fmt.Sprintf(" (since %s)", verifiedAt) + } + fmt.Println(line) + case "badge_present_unvalidated": + fmt.Printf("A %s badge is on file but could not be validated by this build.\n", provider) + fmt.Println("(the issuer key may not be pinned yet)") + default: + fmt.Println("Not verified.") + fmt.Println() + fmt.Println("To get a verified badge:") + fmt.Printf(" 1. pilot-verify verify --provider github --node-id %d --github-client-id \n", nodeID) + fmt.Println(" (authorize in your browser; it prints a badge + signature)") + fmt.Println(" 2. pilotctl verify --badge --badge-sig ") + } +} + // cmdRecovery dispatches the recovery subcommands. func cmdRecovery(args []string) { if len(args) < 1 { diff --git a/cmd/pilotctl/zz_verify_status_test.go b/cmd/pilotctl/zz_verify_status_test.go new file mode 100644 index 00000000..0e906669 --- /dev/null +++ b/cmd/pilotctl/zz_verify_status_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestCmdVerifyStatusNotVerified: registry returns no badge for our node → +// status not_verified with a how-to-verify hint. +func TestCmdVerifyStatusNotVerified(t *testing.T) { + r := newFakeRegistry(t) + r.onOK("lookup", map[string]interface{}{"node_id": float64(99), "address": "0:0000.0000.0063"}) + useRegistry(t, r) + + out := captureStdout(t, func() { + withJSON(func() { cmdVerify([]string{"status", "--node", "99"}) }) + }) + var env map[string]interface{} + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("json: %v\n%s", err, out) + } + data, _ := env["data"].(map[string]interface{}) + if v, _ := data["verified"].(bool); v { + t.Errorf("verified=true, want false; out=%s", out) + } + if data["status"] != "not_verified" { + t.Errorf("status=%v, want not_verified", data["status"]) + } + if _, ok := data["how_to_verify"]; !ok { + t.Error("missing how_to_verify hint when unverified") + } +} + +// TestCmdVerifyStatusBadgePresentUnvalidated: registry serves a badge but the +// build's issuer keyring is the all-zero placeholder, so offline verification +// fails closed → status badge_present_unvalidated, verified=false, provider +// still surfaced. Proves we do NOT trust the registry's word for "verified". +func TestCmdVerifyStatusBadgePresentUnvalidated(t *testing.T) { + r := newFakeRegistry(t) + r.onOK("lookup", map[string]interface{}{ + "node_id": float64(99), + "badge": "pilotbadge:v1:99:github:1781827200:0:bdg-v1:", + "badge_sig": "ZmFrZQ==", + "verification_provider": "github", + "verified_at": "2026-06-19T00:00:00Z", + }) + useRegistry(t, r) + + out := captureStdout(t, func() { + withJSON(func() { cmdVerify([]string{"status", "--node", "99"}) }) + }) + var env map[string]interface{} + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("json: %v\n%s", err, out) + } + data, _ := env["data"].(map[string]interface{}) + if v, _ := data["verified"].(bool); v { + t.Errorf("verified=true with placeholder issuer key (should fail closed); out=%s", out) + } + if data["status"] != "badge_present_unvalidated" { + t.Errorf("status=%v, want badge_present_unvalidated", data["status"]) + } + if data["provider"] != "github" { + t.Errorf("provider=%v, want github", data["provider"]) + } +} + +// TestCmdVerifyStatusHumanHint: human (non-JSON) output for an unverified node +// shows a clear headline plus the verify how-to. +func TestCmdVerifyStatusHumanHint(t *testing.T) { + r := newFakeRegistry(t) + r.onOK("lookup", map[string]interface{}{"node_id": float64(99)}) + useRegistry(t, r) + + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = false + out := captureStdout(t, func() { cmdVerify([]string{"status", "--node", "99"}) }) + if !strings.Contains(out, "Not verified") || !strings.Contains(out, "pilot-verify verify") { + t.Errorf("human hint missing expected text:\n%s", out) + } +}