Skip to content

Commit e075061

Browse files
TeoSlayerteovl
andauthored
Add pilotctl verify status with offline check and how-to (#297)
Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
1 parent 636fb19 commit e075061

2 files changed

Lines changed: 194 additions & 2 deletions

File tree

cmd/pilotctl/verify.go

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ package main
44

55
import (
66
"encoding/json"
7+
"fmt"
78
"os"
89

10+
"github.com/pilot-protocol/common/badgeverify"
911
"github.com/pilot-protocol/common/crypto"
1012
"github.com/pilot-protocol/common/protocol"
1113
registry "github.com/pilot-protocol/common/registry/client"
@@ -36,9 +38,15 @@ func nodeArgToID(s string) uint32 {
3638
// command hands them to the daemon, which proves ownership with the node key
3739
// and submits to the registry.
3840
//
41+
// pilotctl verify # show your own verification status
42+
// pilotctl verify status # same
3943
// pilotctl verify --badge <badge> --badge-sig <sig>
4044
// pilotctl verify --from cred.json # {"badge":..,"badge_sig":..}
4145
func cmdVerify(args []string) {
46+
if len(args) >= 1 && args[0] == "status" {
47+
cmdVerifyStatus(args[1:])
48+
return
49+
}
4250
flags, _ := parseFlags(args)
4351
badge := flagString(flags, "badge", "")
4452
badgeSig := flagString(flags, "badge-sig", "")
@@ -55,9 +63,11 @@ func cmdVerify(args []string) {
5563
badgeSig = m.BadgeSig
5664
}
5765
}
66+
// Bare `pilotctl verify` with nothing to submit is a status check — show
67+
// the user whether they are verified and, if not, how to become verified.
5868
if badge == "" || badgeSig == "" {
59-
fatalCode("invalid_argument",
60-
"usage: pilotctl verify --badge <badge> --badge-sig <sig> (or --from cred.json)")
69+
cmdVerifyStatus(args)
70+
return
6171
}
6272
d := connectDriver()
6373
defer d.Close()
@@ -68,6 +78,103 @@ func cmdVerify(args []string) {
6878
output(resp)
6979
}
7080

81+
// cmdVerifyStatus shows whether THIS node carries a verified-address badge.
82+
// The badge is read from the registry (untrusted transport) and then verified
83+
// OFFLINE against the pinned issuer key — we never take the registry's word for
84+
// it. When unverified, it prints how to get verified.
85+
//
86+
// pilotctl verify status [--node <addr|id>]
87+
func cmdVerifyStatus(args []string) {
88+
flags, _ := parseFlags(args)
89+
90+
var nodeID uint32
91+
var address string
92+
if n := flagString(flags, "node", ""); n != "" {
93+
nodeID = nodeArgToID(n)
94+
} else {
95+
d := connectDriver()
96+
info, err := d.Info()
97+
d.Close()
98+
if err != nil {
99+
fatalCode("connection_failed",
100+
"verify status: cannot reach the daemon (is it running?): %v", err)
101+
}
102+
if v, ok := info["node_id"].(float64); ok {
103+
nodeID = uint32(v)
104+
}
105+
address, _ = info["address"].(string)
106+
}
107+
108+
rc := connectRegistry()
109+
defer rc.Close()
110+
resp, err := rc.Lookup(nodeID)
111+
if err != nil {
112+
fatalCode("connection_failed", "verify status: registry lookup: %v", err)
113+
}
114+
115+
badge, _ := resp["badge"].(string)
116+
badgeSig, _ := resp["badge_sig"].(string)
117+
provider, _ := resp["verification_provider"].(string)
118+
verifiedAt, _ := resp["verified_at"].(string)
119+
120+
out := map[string]interface{}{"node_id": nodeID}
121+
if address != "" {
122+
out["address"] = address
123+
}
124+
125+
status := "not_verified"
126+
verified := false
127+
var detail string
128+
if badge != "" {
129+
if _, verr := badgeverify.VerifyForNode(badge, badgeSig, nodeID); verr == nil {
130+
status, verified = "verified", true
131+
} else {
132+
status, detail = "badge_present_unvalidated", verr.Error()
133+
}
134+
}
135+
out["verified"] = verified
136+
out["status"] = status
137+
if provider != "" {
138+
out["provider"] = provider
139+
}
140+
if verifiedAt != "" {
141+
out["verified_at"] = verifiedAt
142+
}
143+
if status == "not_verified" {
144+
out["how_to_verify"] = []string{
145+
fmt.Sprintf("1. pilot-verify verify --provider github --node-id %d --github-client-id <client-id>", nodeID),
146+
" (authorize in your browser; it prints a badge + signature)",
147+
"2. pilotctl verify --badge <badge> --badge-sig <sig>",
148+
}
149+
}
150+
if detail != "" {
151+
out["detail"] = detail
152+
}
153+
154+
if jsonOutput {
155+
output(out)
156+
return
157+
}
158+
switch status {
159+
case "verified":
160+
line := fmt.Sprintf("✓ Verified via %s", provider)
161+
if verifiedAt != "" {
162+
line += fmt.Sprintf(" (since %s)", verifiedAt)
163+
}
164+
fmt.Println(line)
165+
case "badge_present_unvalidated":
166+
fmt.Printf("A %s badge is on file but could not be validated by this build.\n", provider)
167+
fmt.Println("(the issuer key may not be pinned yet)")
168+
default:
169+
fmt.Println("Not verified.")
170+
fmt.Println()
171+
fmt.Println("To get a verified badge:")
172+
fmt.Printf(" 1. pilot-verify verify --provider github --node-id %d --github-client-id <client-id>\n", nodeID)
173+
fmt.Println(" (authorize in your browser; it prints a badge + signature)")
174+
fmt.Println(" 2. pilotctl verify --badge <badge> --badge-sig <sig>")
175+
}
176+
}
177+
71178
// cmdRecovery dispatches the recovery subcommands.
72179
func cmdRecovery(args []string) {
73180
if len(args) < 1 {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
11+
// TestCmdVerifyStatusNotVerified: registry returns no badge for our node →
12+
// status not_verified with a how-to-verify hint.
13+
func TestCmdVerifyStatusNotVerified(t *testing.T) {
14+
r := newFakeRegistry(t)
15+
r.onOK("lookup", map[string]interface{}{"node_id": float64(99), "address": "0:0000.0000.0063"})
16+
useRegistry(t, r)
17+
18+
out := captureStdout(t, func() {
19+
withJSON(func() { cmdVerify([]string{"status", "--node", "99"}) })
20+
})
21+
var env map[string]interface{}
22+
if err := json.Unmarshal([]byte(out), &env); err != nil {
23+
t.Fatalf("json: %v\n%s", err, out)
24+
}
25+
data, _ := env["data"].(map[string]interface{})
26+
if v, _ := data["verified"].(bool); v {
27+
t.Errorf("verified=true, want false; out=%s", out)
28+
}
29+
if data["status"] != "not_verified" {
30+
t.Errorf("status=%v, want not_verified", data["status"])
31+
}
32+
if _, ok := data["how_to_verify"]; !ok {
33+
t.Error("missing how_to_verify hint when unverified")
34+
}
35+
}
36+
37+
// TestCmdVerifyStatusBadgePresentUnvalidated: registry serves a badge but the
38+
// build's issuer keyring is the all-zero placeholder, so offline verification
39+
// fails closed → status badge_present_unvalidated, verified=false, provider
40+
// still surfaced. Proves we do NOT trust the registry's word for "verified".
41+
func TestCmdVerifyStatusBadgePresentUnvalidated(t *testing.T) {
42+
r := newFakeRegistry(t)
43+
r.onOK("lookup", map[string]interface{}{
44+
"node_id": float64(99),
45+
"badge": "pilotbadge:v1:99:github:1781827200:0:bdg-v1:",
46+
"badge_sig": "ZmFrZQ==",
47+
"verification_provider": "github",
48+
"verified_at": "2026-06-19T00:00:00Z",
49+
})
50+
useRegistry(t, r)
51+
52+
out := captureStdout(t, func() {
53+
withJSON(func() { cmdVerify([]string{"status", "--node", "99"}) })
54+
})
55+
var env map[string]interface{}
56+
if err := json.Unmarshal([]byte(out), &env); err != nil {
57+
t.Fatalf("json: %v\n%s", err, out)
58+
}
59+
data, _ := env["data"].(map[string]interface{})
60+
if v, _ := data["verified"].(bool); v {
61+
t.Errorf("verified=true with placeholder issuer key (should fail closed); out=%s", out)
62+
}
63+
if data["status"] != "badge_present_unvalidated" {
64+
t.Errorf("status=%v, want badge_present_unvalidated", data["status"])
65+
}
66+
if data["provider"] != "github" {
67+
t.Errorf("provider=%v, want github", data["provider"])
68+
}
69+
}
70+
71+
// TestCmdVerifyStatusHumanHint: human (non-JSON) output for an unverified node
72+
// shows a clear headline plus the verify how-to.
73+
func TestCmdVerifyStatusHumanHint(t *testing.T) {
74+
r := newFakeRegistry(t)
75+
r.onOK("lookup", map[string]interface{}{"node_id": float64(99)})
76+
useRegistry(t, r)
77+
78+
prev := jsonOutput
79+
defer func() { jsonOutput = prev }()
80+
jsonOutput = false
81+
out := captureStdout(t, func() { cmdVerify([]string{"status", "--node", "99"}) })
82+
if !strings.Contains(out, "Not verified") || !strings.Contains(out, "pilot-verify verify") {
83+
t.Errorf("human hint missing expected text:\n%s", out)
84+
}
85+
}

0 commit comments

Comments
 (0)