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
6 changes: 6 additions & 0 deletions cmd/pilotctl/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func nodeArgToID(s string) uint32 {
//
// pilotctl verify # show your own verification status
// pilotctl verify status # same
// pilotctl verify --provider github # self-service: device-flow via the verifier
// pilotctl verify --badge <badge> --badge-sig <sig>
// pilotctl verify --from cred.json # {"badge":..,"badge_sig":..}
func cmdVerify(args []string) {
Expand All @@ -48,6 +49,11 @@ func cmdVerify(args []string) {
return
}
flags, _ := parseFlags(args)
// Self-service device-flow: dial the verifier, run the browser flow, submit.
if provider := flagString(flags, "provider", ""); provider != "" {
cmdVerifyProvider(flags, provider)
return
}
badge := flagString(flags, "badge", "")
badgeSig := flagString(flags, "badge-sig", "")
if from := flagString(flags, "from", ""); from != "" {
Expand Down
184 changes: 184 additions & 0 deletions cmd/pilotctl/verify_flow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"time"

"github.com/pilot-protocol/common/driver"
"github.com/pilot-protocol/common/protocol"
)

// verifierRequest / verifierResponse mirror pilot-verify's wire protocol:
// length-prefixed JSON frames exchanged over a Pilot stream on PortVerify.
// The verifier serves exactly one request/response per connection.
type verifierRequest struct {
Op string `json:"op"` // "begin" | "poll"
Provider string `json:"provider,omitempty"` // begin: "github" | "google"
NodeID uint32 `json:"node_id,omitempty"` // begin: address to bind the badge to
FlowID string `json:"flow_id,omitempty"` // poll: handle from begin
}

type verifierResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
FlowID string `json:"flow_id,omitempty"`
UserCode string `json:"user_code,omitempty"`
VerificationURI string `json:"verification_uri,omitempty"`
Status string `json:"status,omitempty"` // poll: "pending" | "ready" | "error"
Badge string `json:"badge,omitempty"`
BadgeSig string `json:"badge_sig,omitempty"`
}

const verifierMaxFrame = 64 << 10

func writeVerifierFrame(w io.Writer, v interface{}) error {
payload, err := json.Marshal(v)
if err != nil {
return err
}
if len(payload) > verifierMaxFrame {
return fmt.Errorf("frame too large: %d", len(payload))
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
_, err = w.Write(payload)
return err
}

func readVerifierFrame(r io.Reader, v interface{}) error {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return err
}
n := binary.BigEndian.Uint32(hdr[:])
if n > verifierMaxFrame {
return fmt.Errorf("frame too large: %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return err
}
return json.Unmarshal(buf, v)
}

// verifierRoundtrip dials the verifier on PortVerify, sends one request,
// reads one response, and closes — the verifier serves one op per conn.
func verifierRoundtrip(d *driver.Driver, vaddr string, req verifierRequest) (verifierResponse, error) {
conn, err := d.Dial(vaddr + ":" + strconv.Itoa(int(protocol.PortVerify)))
if err != nil {
return verifierResponse{}, fmt.Errorf("dial verifier %s: %w", vaddr, err)
}
defer conn.Close()
_ = conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
return verifierExchange(conn, req)
}

// verifierExchange writes one request frame and reads one response frame on an
// already-open stream (separated from dialing so it is unit-testable).
func verifierExchange(conn io.ReadWriter, req verifierRequest) (verifierResponse, error) {
if err := writeVerifierFrame(conn, req); err != nil {
return verifierResponse{}, err
}
var resp verifierResponse
if err := readVerifierFrame(conn, &resp); err != nil {
return verifierResponse{}, err
}
if !resp.OK {
return resp, fmt.Errorf("verifier: %s", resp.Error)
}
return resp, nil
}

// resolveVerifierAddr accepts either a literal Pilot address or a hostname to
// resolve (default "verify").
func resolveVerifierAddr(d *driver.Driver, ref string) string {
if _, err := protocol.ParseAddr(ref); err == nil {
return ref
}
res, err := d.ResolveHostname(ref)
if err != nil {
fatalHint("not_found",
"pass --verifier <address|hostname>, or check the verifier service is registered",
"cannot resolve verifier %q: %v", ref, err)
}
addr, _ := res["address"].(string)
if addr == "" {
fatalCode("not_found", "verifier %q resolved with no address", ref)
}
return addr
}

// cmdVerifyProvider runs the self-service device-flow end to end: dial the
// verifier, start a verification bound to our own address, show the user the
// device code, poll until they authorize in their browser, then submit the
// minted badge to the registry through the daemon (proving key ownership).
//
// pilotctl verify --provider github [--verifier <address|hostname>]
func cmdVerifyProvider(flags map[string]string, provider string) {
verifierRef := flagString(flags, "verifier", "verify")

d := connectDriver()
defer d.Close()
info, err := d.Info()
if err != nil {
fatalCode("connection_failed", "verify: cannot reach the daemon (is it running?): %v", err)
}
nodeF, _ := info["node_id"].(float64)
if nodeF == 0 {
fatalCode("internal_error", "verify: daemon has no node id yet (not registered?)")
}

vaddr := resolveVerifierAddr(d, verifierRef)

begin, err := verifierRoundtrip(d, vaddr, verifierRequest{Op: "begin", Provider: provider, NodeID: uint32(nodeF)})
if err != nil {
fatalCode("connection_failed", "verify begin: %v", err)
}
if begin.UserCode == "" || begin.VerificationURI == "" {
fatalCode("internal_error", "verifier returned no device code")
}

// Device-code instructions go to stderr so JSON stdout stays clean.
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",
provider, begin.VerificationURI, begin.UserCode)

deadline := time.Now().Add(10 * time.Minute)
var badge, badgeSig string
for time.Now().Before(deadline) {
time.Sleep(5 * time.Second)
poll, err := verifierRoundtrip(d, vaddr, verifierRequest{Op: "poll", FlowID: begin.FlowID})
if err != nil {
fatalCode("connection_failed", "verify poll: %v", err)
}
switch poll.Status {
case "ready":
badge, badgeSig = poll.Badge, poll.BadgeSig
case "error":
fatalCode("permission_denied", "verification failed: %s", poll.Error)
}
if badge != "" {
break
}
}
if badge == "" {
fatalCode("deadline_exceeded", "verification timed out; no authorization received")
}

resp, err := d.SubmitBadge(badge, badgeSig)
if err != nil {
fatalCode("connection_failed", "submit badge: %v", err)
}
fmt.Fprintf(os.Stderr, "\n✓ Verified via %s — badge submitted.\n", provider)
output(resp)
}
121 changes: 121 additions & 0 deletions cmd/pilotctl/zz_verify_flow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"encoding/json"
"net"
"testing"
"time"
)

// TestVerifierFrameRoundtrip pins the length-prefixed JSON framing against
// itself.
func TestVerifierFrameRoundtrip(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()

want := verifierRequest{Op: "begin", Provider: "github", NodeID: 109517}
go func() { _ = writeVerifierFrame(c1, want) }()

_ = c2.SetReadDeadline(time.Now().Add(2 * time.Second))
var got verifierRequest
if err := readVerifierFrame(c2, &got); err != nil {
t.Fatalf("readVerifierFrame: %v", err)
}
if got != want {
t.Fatalf("roundtrip mismatch: got %+v want %+v", got, want)
}
}

// TestVerifierWireKeys pins the JSON wire keys so they stay compatible with
// pilot-verify's protocol.go (op/provider/node_id/flow_id).
func TestVerifierWireKeys(t *testing.T) {
b, _ := json.Marshal(verifierRequest{Op: "poll", FlowID: "abc"})
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
if m["op"] != "poll" || m["flow_id"] != "abc" {
t.Fatalf("unexpected request wire shape: %s", b)
}
// provider/node_id must be omitted when empty (poll carries only flow_id).
if _, ok := m["provider"]; ok {
t.Errorf("poll request should omit provider: %s", b)
}
}

// TestVerifierExchangeBeginPoll drives the real begin→poll→ready protocol
// against a simulated verifier that mirrors pilot-verify's behavior (one
// request/response per connection).
func TestVerifierExchangeBeginPoll(t *testing.T) {
// --- begin ---
srv, cli := net.Pipe()
defer cli.Close()
go func() {
defer srv.Close()
var req verifierRequest
if err := readVerifierFrame(srv, &req); err != nil {
return
}
if req.Op != "begin" || req.Provider != "github" || req.NodeID != 42 {
_ = writeVerifierFrame(srv, verifierResponse{OK: false, Error: "bad begin"})
return
}
_ = writeVerifierFrame(srv, verifierResponse{
OK: true, FlowID: "flow-1", UserCode: "WXYZ-1234",
VerificationURI: "https://github.com/login/device",
})
}()
_ = cli.SetReadDeadline(time.Now().Add(2 * time.Second))
begin, err := verifierExchange(cli, verifierRequest{Op: "begin", Provider: "github", NodeID: 42})
if err != nil {
t.Fatalf("begin exchange: %v", err)
}
if begin.FlowID != "flow-1" || begin.UserCode != "WXYZ-1234" || begin.VerificationURI == "" {
t.Fatalf("begin response wrong: %+v", begin)
}

// --- poll -> ready (new connection, as the verifier serves one op/conn) ---
srv2, cli2 := net.Pipe()
defer cli2.Close()
go func() {
defer srv2.Close()
var req verifierRequest
if err := readVerifierFrame(srv2, &req); err != nil {
return
}
if req.Op != "poll" || req.FlowID != "flow-1" {
_ = writeVerifierFrame(srv2, verifierResponse{OK: false, Error: "bad poll"})
return
}
_ = writeVerifierFrame(srv2, verifierResponse{
OK: true, Status: "ready",
Badge: "pilotbadge:v1:42:github:1781827200:0:bdg-v1:",
BadgeSig: "c2ln",
})
}()
_ = cli2.SetReadDeadline(time.Now().Add(2 * time.Second))
poll, err := verifierExchange(cli2, verifierRequest{Op: "poll", FlowID: begin.FlowID})
if err != nil {
t.Fatalf("poll exchange: %v", err)
}
if poll.Status != "ready" || poll.Badge == "" || poll.BadgeSig != "c2ln" {
t.Fatalf("poll response wrong: %+v", poll)
}
}

// TestVerifierExchangeError surfaces a verifier error frame as a Go error.
func TestVerifierExchangeError(t *testing.T) {
srv, cli := net.Pipe()
defer cli.Close()
go func() {
defer srv.Close()
var req verifierRequest
_ = readVerifierFrame(srv, &req)
_ = writeVerifierFrame(srv, verifierResponse{OK: false, Error: "provider not configured"})
}()
_ = cli.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := verifierExchange(cli, verifierRequest{Op: "begin", Provider: "google"}); err == nil {
t.Fatal("expected error from !ok verifier response")
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/coder/websocket v1.8.15
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.5.3
github.com/pilot-protocol/common v0.5.5
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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S
github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4=
github.com/pilot-protocol/common v0.5.3 h1:CsBBmzuQn75G1MKVvKdLp77G9nf6fC7YGLZh8DVeZEI=
github.com/pilot-protocol/common v0.5.3/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4=
github.com/pilot-protocol/common v0.5.5 h1:mnv3q84alVaotGD+Qxfo4ECFEquqsUwrI3mjKIGUKFY=
github.com/pilot-protocol/common v0.5.5/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=
Expand Down
Loading