From c11b48810ac9c162f30a2a5077f3171259647ecb Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 21 Jun 2026 18:38:37 +0300 Subject: [PATCH] Add self-service verify device-flow via the verifier --- cmd/pilotctl/verify.go | 6 + cmd/pilotctl/verify_flow.go | 184 ++++++++++++++++++++++++++++ cmd/pilotctl/zz_verify_flow_test.go | 121 ++++++++++++++++++ go.mod | 2 +- go.sum | 2 + 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 cmd/pilotctl/verify_flow.go create mode 100644 cmd/pilotctl/zz_verify_flow_test.go diff --git a/cmd/pilotctl/verify.go b/cmd/pilotctl/verify.go index 866cb866..e2630b34 100644 --- a/cmd/pilotctl/verify.go +++ b/cmd/pilotctl/verify.go @@ -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-sig // pilotctl verify --from cred.json # {"badge":..,"badge_sig":..} func cmdVerify(args []string) { @@ -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 != "" { diff --git a/cmd/pilotctl/verify_flow.go b/cmd/pilotctl/verify_flow.go new file mode 100644 index 00000000..726dc671 --- /dev/null +++ b/cmd/pilotctl/verify_flow.go @@ -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 , 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 ] +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) +} diff --git a/cmd/pilotctl/zz_verify_flow_test.go b/cmd/pilotctl/zz_verify_flow_test.go new file mode 100644 index 00000000..760d2c88 --- /dev/null +++ b/cmd/pilotctl/zz_verify_flow_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index cc6b024e..88ecf29b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index fbdfeda9..33217fbd 100644 --- a/go.sum +++ b/go.sum @@ -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=