Skip to content

Commit c11b488

Browse files
committed
Add self-service verify device-flow via the verifier
1 parent 3ec61cf commit c11b488

5 files changed

Lines changed: 314 additions & 1 deletion

File tree

cmd/pilotctl/verify.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func nodeArgToID(s string) uint32 {
4040
//
4141
// pilotctl verify # show your own verification status
4242
// pilotctl verify status # same
43+
// pilotctl verify --provider github # self-service: device-flow via the verifier
4344
// pilotctl verify --badge <badge> --badge-sig <sig>
4445
// pilotctl verify --from cred.json # {"badge":..,"badge_sig":..}
4546
func cmdVerify(args []string) {
@@ -48,6 +49,11 @@ func cmdVerify(args []string) {
4849
return
4950
}
5051
flags, _ := parseFlags(args)
52+
// Self-service device-flow: dial the verifier, run the browser flow, submit.
53+
if provider := flagString(flags, "provider", ""); provider != "" {
54+
cmdVerifyProvider(flags, provider)
55+
return
56+
}
5157
badge := flagString(flags, "badge", "")
5258
badgeSig := flagString(flags, "badge-sig", "")
5359
if from := flagString(flags, "from", ""); from != "" {

cmd/pilotctl/verify_flow.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"encoding/binary"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"os"
11+
"strconv"
12+
"time"
13+
14+
"github.com/pilot-protocol/common/driver"
15+
"github.com/pilot-protocol/common/protocol"
16+
)
17+
18+
// verifierRequest / verifierResponse mirror pilot-verify's wire protocol:
19+
// length-prefixed JSON frames exchanged over a Pilot stream on PortVerify.
20+
// The verifier serves exactly one request/response per connection.
21+
type verifierRequest struct {
22+
Op string `json:"op"` // "begin" | "poll"
23+
Provider string `json:"provider,omitempty"` // begin: "github" | "google"
24+
NodeID uint32 `json:"node_id,omitempty"` // begin: address to bind the badge to
25+
FlowID string `json:"flow_id,omitempty"` // poll: handle from begin
26+
}
27+
28+
type verifierResponse struct {
29+
OK bool `json:"ok"`
30+
Error string `json:"error,omitempty"`
31+
FlowID string `json:"flow_id,omitempty"`
32+
UserCode string `json:"user_code,omitempty"`
33+
VerificationURI string `json:"verification_uri,omitempty"`
34+
Status string `json:"status,omitempty"` // poll: "pending" | "ready" | "error"
35+
Badge string `json:"badge,omitempty"`
36+
BadgeSig string `json:"badge_sig,omitempty"`
37+
}
38+
39+
const verifierMaxFrame = 64 << 10
40+
41+
func writeVerifierFrame(w io.Writer, v interface{}) error {
42+
payload, err := json.Marshal(v)
43+
if err != nil {
44+
return err
45+
}
46+
if len(payload) > verifierMaxFrame {
47+
return fmt.Errorf("frame too large: %d", len(payload))
48+
}
49+
var hdr [4]byte
50+
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
51+
if _, err := w.Write(hdr[:]); err != nil {
52+
return err
53+
}
54+
_, err = w.Write(payload)
55+
return err
56+
}
57+
58+
func readVerifierFrame(r io.Reader, v interface{}) error {
59+
var hdr [4]byte
60+
if _, err := io.ReadFull(r, hdr[:]); err != nil {
61+
return err
62+
}
63+
n := binary.BigEndian.Uint32(hdr[:])
64+
if n > verifierMaxFrame {
65+
return fmt.Errorf("frame too large: %d", n)
66+
}
67+
buf := make([]byte, n)
68+
if _, err := io.ReadFull(r, buf); err != nil {
69+
return err
70+
}
71+
return json.Unmarshal(buf, v)
72+
}
73+
74+
// verifierRoundtrip dials the verifier on PortVerify, sends one request,
75+
// reads one response, and closes — the verifier serves one op per conn.
76+
func verifierRoundtrip(d *driver.Driver, vaddr string, req verifierRequest) (verifierResponse, error) {
77+
conn, err := d.Dial(vaddr + ":" + strconv.Itoa(int(protocol.PortVerify)))
78+
if err != nil {
79+
return verifierResponse{}, fmt.Errorf("dial verifier %s: %w", vaddr, err)
80+
}
81+
defer conn.Close()
82+
_ = conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
83+
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
84+
return verifierExchange(conn, req)
85+
}
86+
87+
// verifierExchange writes one request frame and reads one response frame on an
88+
// already-open stream (separated from dialing so it is unit-testable).
89+
func verifierExchange(conn io.ReadWriter, req verifierRequest) (verifierResponse, error) {
90+
if err := writeVerifierFrame(conn, req); err != nil {
91+
return verifierResponse{}, err
92+
}
93+
var resp verifierResponse
94+
if err := readVerifierFrame(conn, &resp); err != nil {
95+
return verifierResponse{}, err
96+
}
97+
if !resp.OK {
98+
return resp, fmt.Errorf("verifier: %s", resp.Error)
99+
}
100+
return resp, nil
101+
}
102+
103+
// resolveVerifierAddr accepts either a literal Pilot address or a hostname to
104+
// resolve (default "verify").
105+
func resolveVerifierAddr(d *driver.Driver, ref string) string {
106+
if _, err := protocol.ParseAddr(ref); err == nil {
107+
return ref
108+
}
109+
res, err := d.ResolveHostname(ref)
110+
if err != nil {
111+
fatalHint("not_found",
112+
"pass --verifier <address|hostname>, or check the verifier service is registered",
113+
"cannot resolve verifier %q: %v", ref, err)
114+
}
115+
addr, _ := res["address"].(string)
116+
if addr == "" {
117+
fatalCode("not_found", "verifier %q resolved with no address", ref)
118+
}
119+
return addr
120+
}
121+
122+
// cmdVerifyProvider runs the self-service device-flow end to end: dial the
123+
// verifier, start a verification bound to our own address, show the user the
124+
// device code, poll until they authorize in their browser, then submit the
125+
// minted badge to the registry through the daemon (proving key ownership).
126+
//
127+
// pilotctl verify --provider github [--verifier <address|hostname>]
128+
func cmdVerifyProvider(flags map[string]string, provider string) {
129+
verifierRef := flagString(flags, "verifier", "verify")
130+
131+
d := connectDriver()
132+
defer d.Close()
133+
info, err := d.Info()
134+
if err != nil {
135+
fatalCode("connection_failed", "verify: cannot reach the daemon (is it running?): %v", err)
136+
}
137+
nodeF, _ := info["node_id"].(float64)
138+
if nodeF == 0 {
139+
fatalCode("internal_error", "verify: daemon has no node id yet (not registered?)")
140+
}
141+
142+
vaddr := resolveVerifierAddr(d, verifierRef)
143+
144+
begin, err := verifierRoundtrip(d, vaddr, verifierRequest{Op: "begin", Provider: provider, NodeID: uint32(nodeF)})
145+
if err != nil {
146+
fatalCode("connection_failed", "verify begin: %v", err)
147+
}
148+
if begin.UserCode == "" || begin.VerificationURI == "" {
149+
fatalCode("internal_error", "verifier returned no device code")
150+
}
151+
152+
// Device-code instructions go to stderr so JSON stdout stays clean.
153+
fmt.Fprintf(os.Stderr, "\nTo verify your address via %s:\n 1. open %s\n 2. enter code: %s\n\nWaiting for authorization (Ctrl-C to cancel)...\n",
154+
provider, begin.VerificationURI, begin.UserCode)
155+
156+
deadline := time.Now().Add(10 * time.Minute)
157+
var badge, badgeSig string
158+
for time.Now().Before(deadline) {
159+
time.Sleep(5 * time.Second)
160+
poll, err := verifierRoundtrip(d, vaddr, verifierRequest{Op: "poll", FlowID: begin.FlowID})
161+
if err != nil {
162+
fatalCode("connection_failed", "verify poll: %v", err)
163+
}
164+
switch poll.Status {
165+
case "ready":
166+
badge, badgeSig = poll.Badge, poll.BadgeSig
167+
case "error":
168+
fatalCode("permission_denied", "verification failed: %s", poll.Error)
169+
}
170+
if badge != "" {
171+
break
172+
}
173+
}
174+
if badge == "" {
175+
fatalCode("deadline_exceeded", "verification timed out; no authorization received")
176+
}
177+
178+
resp, err := d.SubmitBadge(badge, badgeSig)
179+
if err != nil {
180+
fatalCode("connection_failed", "submit badge: %v", err)
181+
}
182+
fmt.Fprintf(os.Stderr, "\n✓ Verified via %s — badge submitted.\n", provider)
183+
output(resp)
184+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package main
4+
5+
import (
6+
"encoding/json"
7+
"net"
8+
"testing"
9+
"time"
10+
)
11+
12+
// TestVerifierFrameRoundtrip pins the length-prefixed JSON framing against
13+
// itself.
14+
func TestVerifierFrameRoundtrip(t *testing.T) {
15+
c1, c2 := net.Pipe()
16+
defer c1.Close()
17+
defer c2.Close()
18+
19+
want := verifierRequest{Op: "begin", Provider: "github", NodeID: 109517}
20+
go func() { _ = writeVerifierFrame(c1, want) }()
21+
22+
_ = c2.SetReadDeadline(time.Now().Add(2 * time.Second))
23+
var got verifierRequest
24+
if err := readVerifierFrame(c2, &got); err != nil {
25+
t.Fatalf("readVerifierFrame: %v", err)
26+
}
27+
if got != want {
28+
t.Fatalf("roundtrip mismatch: got %+v want %+v", got, want)
29+
}
30+
}
31+
32+
// TestVerifierWireKeys pins the JSON wire keys so they stay compatible with
33+
// pilot-verify's protocol.go (op/provider/node_id/flow_id).
34+
func TestVerifierWireKeys(t *testing.T) {
35+
b, _ := json.Marshal(verifierRequest{Op: "poll", FlowID: "abc"})
36+
var m map[string]interface{}
37+
_ = json.Unmarshal(b, &m)
38+
if m["op"] != "poll" || m["flow_id"] != "abc" {
39+
t.Fatalf("unexpected request wire shape: %s", b)
40+
}
41+
// provider/node_id must be omitted when empty (poll carries only flow_id).
42+
if _, ok := m["provider"]; ok {
43+
t.Errorf("poll request should omit provider: %s", b)
44+
}
45+
}
46+
47+
// TestVerifierExchangeBeginPoll drives the real begin→poll→ready protocol
48+
// against a simulated verifier that mirrors pilot-verify's behavior (one
49+
// request/response per connection).
50+
func TestVerifierExchangeBeginPoll(t *testing.T) {
51+
// --- begin ---
52+
srv, cli := net.Pipe()
53+
defer cli.Close()
54+
go func() {
55+
defer srv.Close()
56+
var req verifierRequest
57+
if err := readVerifierFrame(srv, &req); err != nil {
58+
return
59+
}
60+
if req.Op != "begin" || req.Provider != "github" || req.NodeID != 42 {
61+
_ = writeVerifierFrame(srv, verifierResponse{OK: false, Error: "bad begin"})
62+
return
63+
}
64+
_ = writeVerifierFrame(srv, verifierResponse{
65+
OK: true, FlowID: "flow-1", UserCode: "WXYZ-1234",
66+
VerificationURI: "https://github.com/login/device",
67+
})
68+
}()
69+
_ = cli.SetReadDeadline(time.Now().Add(2 * time.Second))
70+
begin, err := verifierExchange(cli, verifierRequest{Op: "begin", Provider: "github", NodeID: 42})
71+
if err != nil {
72+
t.Fatalf("begin exchange: %v", err)
73+
}
74+
if begin.FlowID != "flow-1" || begin.UserCode != "WXYZ-1234" || begin.VerificationURI == "" {
75+
t.Fatalf("begin response wrong: %+v", begin)
76+
}
77+
78+
// --- poll -> ready (new connection, as the verifier serves one op/conn) ---
79+
srv2, cli2 := net.Pipe()
80+
defer cli2.Close()
81+
go func() {
82+
defer srv2.Close()
83+
var req verifierRequest
84+
if err := readVerifierFrame(srv2, &req); err != nil {
85+
return
86+
}
87+
if req.Op != "poll" || req.FlowID != "flow-1" {
88+
_ = writeVerifierFrame(srv2, verifierResponse{OK: false, Error: "bad poll"})
89+
return
90+
}
91+
_ = writeVerifierFrame(srv2, verifierResponse{
92+
OK: true, Status: "ready",
93+
Badge: "pilotbadge:v1:42:github:1781827200:0:bdg-v1:",
94+
BadgeSig: "c2ln",
95+
})
96+
}()
97+
_ = cli2.SetReadDeadline(time.Now().Add(2 * time.Second))
98+
poll, err := verifierExchange(cli2, verifierRequest{Op: "poll", FlowID: begin.FlowID})
99+
if err != nil {
100+
t.Fatalf("poll exchange: %v", err)
101+
}
102+
if poll.Status != "ready" || poll.Badge == "" || poll.BadgeSig != "c2ln" {
103+
t.Fatalf("poll response wrong: %+v", poll)
104+
}
105+
}
106+
107+
// TestVerifierExchangeError surfaces a verifier error frame as a Go error.
108+
func TestVerifierExchangeError(t *testing.T) {
109+
srv, cli := net.Pipe()
110+
defer cli.Close()
111+
go func() {
112+
defer srv.Close()
113+
var req verifierRequest
114+
_ = readVerifierFrame(srv, &req)
115+
_ = writeVerifierFrame(srv, verifierResponse{OK: false, Error: "provider not configured"})
116+
}()
117+
_ = cli.SetReadDeadline(time.Now().Add(2 * time.Second))
118+
if _, err := verifierExchange(cli, verifierRequest{Op: "begin", Provider: "google"}); err == nil {
119+
t.Fatal("expected error from !ok verifier response")
120+
}
121+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/coder/websocket v1.8.15
77
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72
88
github.com/pilot-protocol/beacon v0.2.6
9-
github.com/pilot-protocol/common v0.5.3
9+
github.com/pilot-protocol/common v0.5.5
1010
github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98
1111
github.com/pilot-protocol/eventstream v0.2.2
1212
github.com/pilot-protocol/handshake v0.2.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S
1010
github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4=
1111
github.com/pilot-protocol/common v0.5.3 h1:CsBBmzuQn75G1MKVvKdLp77G9nf6fC7YGLZh8DVeZEI=
1212
github.com/pilot-protocol/common v0.5.3/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4=
13+
github.com/pilot-protocol/common v0.5.5 h1:mnv3q84alVaotGD+Qxfo4ECFEquqsUwrI3mjKIGUKFY=
14+
github.com/pilot-protocol/common v0.5.5/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4=
1315
github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98 h1:Bqgnf4CZC7aZJyDzz/E7agwXotArJg2FvFlNDqouhLo=
1416
github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98/go.mod h1:tM9eyyruBdnxhhUtViasUjnAElwF/r5PQvCYKLdlTLY=
1517
github.com/pilot-protocol/eventstream v0.2.2 h1:E0IjveK7K+dsIbE/5hD3N821FkHzxVsx1tiAORMzt8k=

0 commit comments

Comments
 (0)