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
111 changes: 109 additions & 2 deletions cmd/pilotctl/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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> --badge-sig <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", "")
Expand All @@ -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> --badge-sig <sig> (or --from cred.json)")
cmdVerifyStatus(args)
return
}
d := connectDriver()
defer d.Close()
Expand All @@ -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 <addr|id>]
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 <client-id>", nodeID),
" (authorize in your browser; it prints a badge + signature)",
"2. pilotctl verify --badge <badge> --badge-sig <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 <client-id>\n", nodeID)
fmt.Println(" (authorize in your browser; it prints a badge + signature)")
fmt.Println(" 2. pilotctl verify --badge <badge> --badge-sig <sig>")
}
}

// cmdRecovery dispatches the recovery subcommands.
func cmdRecovery(args []string) {
if len(args) < 1 {
Expand Down
85 changes: 85 additions & 0 deletions cmd/pilotctl/zz_verify_status_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading