From 439973b69c359d8758e723fdd51269d81f3ddb08 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Tue, 7 Jul 2026 16:39:51 -0700 Subject: [PATCH 1/5] feat(otpmail,otpsignup,scaffold): broker-side autonomous signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the broker-signup pattern: obtain a per-user provider API key that a provider gates behind an email OTP, with NO email input from the user and no key held on our side after handoff. - internal/otpmail: a receive-only mail server (catch-all SMTP for one domain + token-authed control API for provision/read-OTP/teardown). Keeps mail only for provisioned addresses, tmpfs maildir, never logs the code or body. - internal/otpsignup: a signed HTTP broker that provisions a mailbox, drives the provider register→OTP→verify handshake, mints the key, and returns it. Reuses the shared broker's ed25519 caller-identity verify; idempotent per caller with an AES-GCM encrypted-at-rest ledger; per-IP Sybil cap. - scaffold: a new signup `step: broker` (the adapter signs one keyless call to the broker and caches {email,api_key} to secrets.json; ops stay byo) plus a `step: account` local reader to retrieve the cached account. Emits the signer + a broker_signup.go runtime; grants key.sign + net.dial-broker. All provider- and host-specifics are configuration; nothing is compiled in. Tests cover SMTP delivery, OTP parse, control API, the full signed mint, idempotency, encryption at rest, the per-IP cap, and generated-project compile. --- cmd/otpmail/main.go | 35 ++ cmd/otpsignup-broker/main.go | 49 +++ internal/otpmail/server.go | 395 ++++++++++++++++++ internal/otpmail/server_test.go | 179 ++++++++ internal/otpsignup/broker.go | 353 ++++++++++++++++ internal/otpsignup/broker_test.go | 190 +++++++++ internal/otpsignup/store.go | 132 ++++++ internal/publish/submission.go | 50 ++- internal/scaffold/config.go | 86 +++- internal/scaffold/scaffold.go | 11 +- internal/scaffold/signup_test.go | 87 ++++ .../scaffold/templates/broker_signup.go.tmpl | 99 +++++ internal/scaffold/templates/main.go.tmpl | 23 +- .../scaffold/templates/manifest.json.tmpl | 2 +- internal/scaffold/templates/signup.go.tmpl | 22 + 15 files changed, 1669 insertions(+), 44 deletions(-) create mode 100644 cmd/otpmail/main.go create mode 100644 cmd/otpsignup-broker/main.go create mode 100644 internal/otpmail/server.go create mode 100644 internal/otpmail/server_test.go create mode 100644 internal/otpsignup/broker.go create mode 100644 internal/otpsignup/broker_test.go create mode 100644 internal/otpsignup/store.go create mode 100644 internal/scaffold/templates/broker_signup.go.tmpl diff --git a/cmd/otpmail/main.go b/cmd/otpmail/main.go new file mode 100644 index 0000000..b4b97e1 --- /dev/null +++ b/cmd/otpmail/main.go @@ -0,0 +1,35 @@ +// Command otpmail runs the receive-only OTP mail server (internal/otpmail): +// a catch-all SMTP receiver for one domain plus a token-authed control API the +// signup broker drives. All configuration comes from the environment — nothing +// provider- or host-specific is compiled in. +// +// OTPMAIL_DOMAIN the mail domain this host is MX for (required) +// OTPMAIL_TOKEN bearer token the control API requires (required) +// OTPMAIL_MAILDIR message dir, point at tmpfs (default /var/otpmail) +// OTPMAIL_SMTP_ADDR public SMTP listen addr (default :25) +// OTPMAIL_CONTROL_ADDR private control-API listen addr (default 127.0.0.1:8025) +package main + +import ( + "log" + "os" + "strconv" + + "github.com/pilot-protocol/app-template/internal/otpmail" +) + +func main() { + maxBytes, _ := strconv.Atoi(os.Getenv("OTPMAIL_MAX_MSG_BYTES")) + srv, err := otpmail.New(otpmail.Config{ + Domain: os.Getenv("OTPMAIL_DOMAIN"), + Token: os.Getenv("OTPMAIL_TOKEN"), + Maildir: os.Getenv("OTPMAIL_MAILDIR"), + SMTPAddr: os.Getenv("OTPMAIL_SMTP_ADDR"), + ControlAddr: os.Getenv("OTPMAIL_CONTROL_ADDR"), + MaxMsgBytes: maxBytes, + }) + if err != nil { + log.Fatalf("otpmail: %v", err) + } + log.Fatal(srv.ListenAndServe()) +} diff --git a/cmd/otpsignup-broker/main.go b/cmd/otpsignup-broker/main.go new file mode 100644 index 0000000..e06f0a2 --- /dev/null +++ b/cmd/otpsignup-broker/main.go @@ -0,0 +1,49 @@ +// Command otpsignup-broker runs the signed OTP-signup broker +// (internal/otpsignup): it mints a per-user provider API key with no email input +// from the user, driving the receive-only mail server (internal/otpmail) for the +// OTP. Configuration is entirely from the environment — provider- and +// host-specifics live in the deployment, not the binary. +// +// OTPSIGNUP_LISTEN HTTP listen addr (default 127.0.0.1:8090) +// OTPSIGNUP_MAIL_CONTROL_URL mail server control-API base (internal) +// OTPSIGNUP_MAIL_TOKEN bearer token for the mail control API +// OTPSIGNUP_MAIL_DOMAIN the mail domain addresses are minted under +// OTPSIGNUP_ADDR_PREFIX localpart prefix (default pilot_) +// OTPSIGNUP_REGISTER_URL provider register endpoint +// OTPSIGNUP_VERIFY_URL provider verify-email endpoint +// OTPSIGNUP_KEY_PATH dotted path to the key (default application.api_key) +// OTPSIGNUP_DB sqlite ledger path +// OTPSIGNUP_ENC_KEY 64-hex (32-byte) key sealing secrets at rest +// OTPSIGNUP_MAX_IDS_PER_IP per-IP distinct-caller cap (0 = unlimited) +package main + +import ( + "log" + "os" + "strconv" + + "github.com/pilot-protocol/app-template/internal/otpsignup" +) + +func main() { + maxIP, _ := strconv.Atoi(os.Getenv("OTPSIGNUP_MAX_IDS_PER_IP")) + b, err := otpsignup.New(otpsignup.Config{ + Listen: os.Getenv("OTPSIGNUP_LISTEN"), + MailControlURL: os.Getenv("OTPSIGNUP_MAIL_CONTROL_URL"), + MailToken: os.Getenv("OTPSIGNUP_MAIL_TOKEN"), + MailDomain: os.Getenv("OTPSIGNUP_MAIL_DOMAIN"), + AddrPrefix: os.Getenv("OTPSIGNUP_ADDR_PREFIX"), + RegisterURL: os.Getenv("OTPSIGNUP_REGISTER_URL"), + VerifyURL: os.Getenv("OTPSIGNUP_VERIFY_URL"), + KeyPath: os.Getenv("OTPSIGNUP_KEY_PATH"), + DBPath: os.Getenv("OTPSIGNUP_DB"), + EncKeyHex: os.Getenv("OTPSIGNUP_ENC_KEY"), + MaxIdentitiesPerIP: maxIP, + }) + if err != nil { + log.Fatalf("otpsignup-broker: %v", err) + } + b.SetLogger(log.New(os.Stderr, "otpsignup ", log.LstdFlags|log.LUTC)) + log.Printf("otpsignup-broker listening on %s", os.Getenv("OTPSIGNUP_LISTEN")) + log.Fatal(b.ListenAndServe()) +} diff --git a/internal/otpmail/server.go b/internal/otpmail/server.go new file mode 100644 index 0000000..b6d58a6 --- /dev/null +++ b/internal/otpmail/server.go @@ -0,0 +1,395 @@ +// Package otpmail is a receive-only mail server for collecting one-time-code +// (OTP) emails during a provider signup, plus a small token-authenticated +// control API a signup broker drives. +// +// It exists for the broker-signup pattern: to obtain an API key from a provider +// that gates it behind an email OTP, the broker provisions a mailbox on a domain +// we control, registers it with the provider, and reads the OTP the provider +// emails. This package is the mailbox side — it ONLY receives (never sends, never +// relays), so there is no SPF/DKIM/PTR/reputation to manage; it just needs the +// domain's MX pointed at it and TCP :25 reachable. +// +// Design points: +// - Catch-all for one configured domain; RCPT for any other domain is refused. +// - A message is kept ONLY if its recipient localpart is currently provisioned +// (an active signup window). Mail to unknown/torn-down addresses is accepted +// at the SMTP layer then discarded, so the server is not a spam sink. +// - The OTP is a live secret for seconds: store under a private maildir +// (point it at tmpfs), return the parsed code once, and delete on read or +// teardown. The code and message body are NEVER logged. +// - The control API (provision / otp / teardown) is bearer-token authed and is +// meant to listen on a private interface reachable only by the broker. +package otpmail + +import ( + "bufio" + "bytes" + "crypto/subtle" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +// Config configures a Server. All fields come from the deployment environment; +// nothing provider- or host-specific is baked in. +type Config struct { + Domain string // the mail domain we are MX for, e.g. "sub.example.net" + Maildir string // dir for per-recipient message files (point at tmpfs) + SMTPAddr string // public SMTP listen address, default ":25" + ControlAddr string // private control-API listen address, default "127.0.0.1:8025" + Token string // bearer token the control API requires (required) + MaxMsgBytes int // per-message cap (default 256 KiB) +} + +// Server is a receive-only SMTP catch-all plus a control API. +type Server struct { + cfg Config + mu sync.Mutex + active map[string]time.Time // provisioned localpart@domain -> provisioned-at + log *log.Logger +} + +// New validates cfg and returns a Server. +func New(cfg Config) (*Server, error) { + if cfg.Domain == "" { + return nil, fmt.Errorf("otpmail: Domain is required") + } + if cfg.Token == "" { + return nil, fmt.Errorf("otpmail: control Token is required") + } + if cfg.Maildir == "" { + cfg.Maildir = "/var/otpmail" + } + if cfg.SMTPAddr == "" { + cfg.SMTPAddr = ":25" + } + if cfg.ControlAddr == "" { + cfg.ControlAddr = "127.0.0.1:8025" + } + if cfg.MaxMsgBytes <= 0 { + cfg.MaxMsgBytes = 256 << 10 + } + cfg.Domain = strings.ToLower(cfg.Domain) + if err := os.MkdirAll(cfg.Maildir, 0o700); err != nil { + return nil, fmt.Errorf("otpmail: maildir: %w", err) + } + return &Server{cfg: cfg, active: map[string]time.Time{}, log: log.New(os.Stderr, "otpmail ", log.LstdFlags|log.LUTC)}, nil +} + +// ListenAndServe starts the SMTP + control listeners and blocks until one fails. +func (s *Server) ListenAndServe() error { + errc := make(chan error, 2) + go func() { errc <- s.serveSMTP() }() + go func() { errc <- s.serveControl() }() + return <-errc +} + +// ── provisioning registry ─────────────────────────────────────────────────── + +func (s *Server) provision(addr string) { + addr = strings.ToLower(addr) + s.mu.Lock() + s.active[addr] = time.Now() + s.mu.Unlock() + s.log.Printf("provision %s", addr) +} + +func (s *Server) isActive(addr string) bool { + s.mu.Lock() + _, ok := s.active[strings.ToLower(addr)] + s.mu.Unlock() + return ok +} + +func (s *Server) teardown(addr string) { + addr = strings.ToLower(addr) + s.mu.Lock() + delete(s.active, addr) + s.mu.Unlock() + _ = os.RemoveAll(s.mailboxDir(addr)) + s.log.Printf("teardown %s", addr) +} + +func (s *Server) mailboxDir(addr string) string { + return filepath.Join(s.cfg.Maildir, safeLocal(strings.ToLower(addr))) +} + +// safeLocal maps an address to a filesystem-safe directory name (no traversal). +func safeLocal(addr string) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '@', r == '.', r == '_', r == '-', r == '+': + return r + default: + return '_' + } + }, addr) +} + +// ── SMTP receiver ─────────────────────────────────────────────────────────── + +func (s *Server) serveSMTP() error { + ln, err := net.Listen("tcp", s.cfg.SMTPAddr) + if err != nil { + return fmt.Errorf("otpmail: smtp listen %s: %w", s.cfg.SMTPAddr, err) + } + s.log.Printf("smtp receive-only catch-all for @%s on %s", s.cfg.Domain, s.cfg.SMTPAddr) + for { + c, err := ln.Accept() + if err != nil { + continue + } + go s.handleSMTP(c) + } +} + +func (s *Server) handleSMTP(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(2 * time.Minute)) + br := bufio.NewReader(c) + w := func(code string) { fmt.Fprintf(c, "%s\r\n", code) } + w("220 " + s.cfg.Domain + " ESMTP") + + var rcpts []string + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + verb := strings.ToUpper(strings.SplitN(line, " ", 2)[0]) + switch verb { + case "HELO", "EHLO": + w("250 " + s.cfg.Domain) + case "MAIL": + w("250 2.1.0 OK") + case "RCPT": + addr := strings.ToLower(extractAngle(line)) + if strings.HasSuffix(addr, "@"+s.cfg.Domain) { + rcpts = append(rcpts, addr) // accept for our domain; kept only if active + w("250 2.1.5 OK") + } else { + w("550 5.7.1 relaying denied") + } + case "DATA": + if len(rcpts) == 0 { + w("503 5.5.1 need RCPT") + continue + } + w("354 End data with .") + data, err := s.readData(br) + if err != nil { + return + } + for _, r := range rcpts { + s.deliver(r, data) + } + w("250 2.0.0 OK") + rcpts = nil + case "RSET": + rcpts = nil + w("250 2.0.0 OK") + case "NOOP": + w("250 2.0.0 OK") + case "QUIT": + w("221 2.0.0 Bye") + return + default: + w("250 2.0.0 OK") + } + } +} + +func (s *Server) readData(br *bufio.Reader) ([]byte, error) { + var buf bytes.Buffer + for { + line, err := br.ReadString('\n') + if err != nil { + return nil, err + } + if line == ".\r\n" || line == ".\n" { + return buf.Bytes(), nil + } + if strings.HasPrefix(line, "..") { + line = line[1:] // undo dot-stuffing + } + if buf.Len() < s.cfg.MaxMsgBytes { + buf.WriteString(line) + } + } +} + +// deliver stores a message ONLY if its recipient is currently provisioned; mail +// to unknown/torn-down addresses is dropped so we are not a spam sink. The body +// is never logged. +func (s *Server) deliver(rcpt string, data []byte) { + if !s.isActive(rcpt) { + s.log.Printf("drop %d bytes for unprovisioned %s", len(data), rcpt) + return + } + dir := s.mailboxDir(rcpt) + if err := os.MkdirAll(dir, 0o700); err != nil { + return + } + name := filepath.Join(dir, fmt.Sprintf("%d.eml", time.Now().UnixNano())) + _ = os.WriteFile(name, data, 0o600) + s.log.Printf("delivered %d bytes for %s", len(data), rcpt) // bytes only, never the body/code +} + +func extractAngle(s string) string { + if i, j := strings.IndexByte(s, '<'), strings.IndexByte(s, '>'); i >= 0 && j > i { + return s[i+1 : j] + } + if k := strings.LastIndexByte(s, ':'); k >= 0 { + return strings.TrimSpace(s[k+1:]) + } + return strings.TrimSpace(s) +} + +// ── OTP extraction ────────────────────────────────────────────────────────── + +var otpKeyword = regexp.MustCompile(`(?is)(?:code|otp|verification|pin)\b.{0,40}?\b([0-9A-Za-z]{6})\b`) +var otpAny = regexp.MustCompile(`\b([0-9A-Za-z]{6})\b`) + +// ReadOTP returns the OTP for a provisioned address if one has been delivered, +// deleting the message(s) once read. Empty string means "not yet". The code is +// never logged. +func (s *Server) ReadOTP(addr string) string { + dir := s.mailboxDir(addr) + files, _ := filepath.Glob(filepath.Join(dir, "*.eml")) + for _, f := range files { + b, _ := os.ReadFile(f) + if code := ParseOTP(string(b)); code != "" { + _ = os.Remove(f) + return code + } + } + return "" +} + +// ParseOTP extracts a 6-char code, preferring one near a keyword and mixing +// letters+digits (provider codes look like A3K9F2). +func ParseOTP(body string) string { + if m := otpKeyword.FindStringSubmatch(body); len(m) == 2 && mixed(m[1]) { + return strings.ToUpper(m[1]) + } + for _, m := range otpAny.FindAllStringSubmatch(body, -1) { + if mixed(m[1]) { + return strings.ToUpper(m[1]) + } + } + if m := otpKeyword.FindStringSubmatch(body); len(m) == 2 { + return strings.ToUpper(m[1]) + } + return "" +} + +func mixed(s string) bool { + var l, d bool + for _, r := range s { + switch { + case r >= '0' && r <= '9': + d = true + case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'): + l = true + } + } + return l && d +} + +// ── control API ───────────────────────────────────────────────────────────── + +func (s *Server) serveControl() error { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) + mux.HandleFunc("/provision", s.auth(s.hProvision)) + mux.HandleFunc("/otp", s.auth(s.hOTP)) + mux.HandleFunc("/teardown", s.auth(s.hTeardown)) + s.log.Printf("control api on %s", s.cfg.ControlAddr) + return http.ListenAndServe(s.cfg.ControlAddr, mux) +} + +// auth requires a constant-time-compared bearer token. +func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc { + want := "Bearer " + s.cfg.Token + return func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("Authorization") + if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + h(w, r) + } +} + +func (s *Server) addrFromReq(r *http.Request) string { + if a := r.URL.Query().Get("addr"); a != "" { + return a + } + var body struct { + Addr string `json:"addr"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + return body.Addr +} + +// validAddr guards against injection: localpart@domain, our domain only. +func (s *Server) validAddr(addr string) bool { + addr = strings.ToLower(addr) + if !strings.HasSuffix(addr, "@"+s.cfg.Domain) { + return false + } + local := strings.TrimSuffix(addr, "@"+s.cfg.Domain) + if local == "" || len(local) > 64 { + return false + } + for _, r := range local { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' || r == '.' || r == '-' || r == '+') { + return false + } + } + return true +} + +func (s *Server) hProvision(w http.ResponseWriter, r *http.Request) { + addr := s.addrFromReq(r) + if !s.validAddr(addr) { + http.Error(w, "bad addr", http.StatusBadRequest) + return + } + s.provision(addr) + writeJSON(w, map[string]any{"ok": true, "addr": strings.ToLower(addr)}) +} + +func (s *Server) hOTP(w http.ResponseWriter, r *http.Request) { + addr := s.addrFromReq(r) + if !s.validAddr(addr) || !s.isActive(addr) { + http.Error(w, "not provisioned", http.StatusBadRequest) + return + } + code := s.ReadOTP(addr) + writeJSON(w, map[string]any{"ok": true, "ready": code != "", "code": code}) +} + +func (s *Server) hTeardown(w http.ResponseWriter, r *http.Request) { + addr := s.addrFromReq(r) + if !s.validAddr(addr) { + http.Error(w, "bad addr", http.StatusBadRequest) + return + } + s.teardown(addr) + writeJSON(w, map[string]any{"ok": true}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/otpmail/server_test.go b/internal/otpmail/server_test.go new file mode 100644 index 0000000..5f86d50 --- /dev/null +++ b/internal/otpmail/server_test.go @@ -0,0 +1,179 @@ +package otpmail + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newTest(t *testing.T) *Server { + t.Helper() + s, err := New(Config{Domain: "mx.example.net", Token: "secret-tok", Maildir: t.TempDir()}) + if err != nil { + t.Fatalf("New: %v", err) + } + return s +} + +func TestParseOTP(t *testing.T) { + cases := map[string]string{ + "Your verification code is A3K9F2. It expires soon.": "A3K9F2", + "Code: BPQM3W": "BPQM3W", + "no code here, just words and stuff": "", // no mixed 6-char token + "login now at HELLOWORLD or use 7F2K9A": "7F2K9A", + } + for body, want := range cases { + if got := ParseOTP(body); got != want { + t.Errorf("ParseOTP(%q)=%q want %q", body, got, want) + } + } +} + +func TestControlAPIRequiresToken(t *testing.T) { + s := newTest(t) + ts := httptest.NewServer(http.HandlerFunc(s.auth(s.hProvision))) + defer ts.Close() + // no token → 401 + resp, _ := http.Post(ts.URL, "application/json", strings.NewReader(`{"addr":"a@mx.example.net"}`)) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no-token status=%d want 401", resp.StatusCode) + } +} + +func TestProvisionDeliverReadTeardown(t *testing.T) { + s := newTest(t) + addr := "pilot_abc@mx.example.net" + + // unprovisioned mail is dropped + s.deliver(addr, []byte("Subject: x\r\n\r\nyour code is A3K9F2\r\n")) + if got := s.ReadOTP(addr); got != "" { + t.Fatalf("expected drop before provision, got %q", got) + } + + // provision → deliver → read parses + deletes + s.provision(addr) + s.deliver(addr, []byte("Subject: Verify\r\n\r\nYour verification code is A3K9F2.\r\n")) + if got := s.ReadOTP(addr); got != "A3K9F2" { + t.Fatalf("ReadOTP=%q want A3K9F2", got) + } + if got := s.ReadOTP(addr); got != "" { + t.Fatalf("expected message consumed after read, got %q", got) + } + + // teardown removes state + s.provision(addr) + s.deliver(addr, []byte("Subject: x\r\n\r\ncode BPQM3W\r\n")) + s.teardown(addr) + if s.isActive(addr) { + t.Fatal("expected inactive after teardown") + } + if got := s.ReadOTP(addr); got != "" { + t.Fatalf("expected mailbox gone after teardown, got %q", got) + } +} + +func TestValidAddrRejectsForeignDomainAndInjection(t *testing.T) { + s := newTest(t) + bad := []string{"a@evil.com", "a b@mx.example.net", "../etc@mx.example.net", "@mx.example.net"} + for _, a := range bad { + if s.validAddr(a) { + t.Errorf("validAddr(%q) should be false", a) + } + } + if !s.validAddr("pilot_x1@mx.example.net") { + t.Error("validAddr should accept a normal provisioned addr") + } +} + +// TestSMTPEndToEnd drives a real SMTP session over a socket and confirms the +// message lands (and only for a provisioned recipient) + the OTP reads back. +func TestSMTPEndToEnd(t *testing.T) { + s := newTest(t) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go s.handleSMTP(c) + } + }() + addr := "pilot_smtp@mx.example.net" + s.provision(addr) + + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + br := bufio.NewReader(c) + read := func() string { l, _ := br.ReadString('\n'); return l } + send := func(l string) { fmt.Fprintf(c, "%s\r\n", l) } + read() // 220 greeting + send("EHLO test") + read() + send("MAIL FROM:") + read() + send(fmt.Sprintf("RCPT TO:<%s>", addr)) + if r := read(); !strings.HasPrefix(r, "250") { + t.Fatalf("RCPT not accepted: %q", r) + } + send("DATA") + read() + send("Subject: Your Didit code") + send("") + send("Your verification code is Z8K3Q1") + send(".") + if r := read(); !strings.HasPrefix(r, "250") { + t.Fatalf("DATA not accepted: %q", r) + } + send("QUIT") + read() + + if got := s.ReadOTP(addr); got != "Z8K3Q1" { + t.Fatalf("OTP via SMTP=%q want Z8K3Q1", got) + } +} + +func TestControlOTPRoundTrip(t *testing.T) { + s := newTest(t) + provision := httptest.NewServer(http.HandlerFunc(s.auth(s.hProvision))) + otp := httptest.NewServer(http.HandlerFunc(s.auth(s.hOTP))) + defer provision.Close() + defer otp.Close() + addr := "pilot_ctl@mx.example.net" + + do := func(url, body string) *http.Response { + req, _ := http.NewRequest("POST", url, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer secret-tok") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + do(provision.URL, `{"addr":"`+addr+`"}`) + s.deliver(addr, []byte("Subject: v\r\n\r\ncode: Q1W2E9\r\n")) + + resp := do(otp.URL, `{"addr":"`+addr+`"}`) + var out struct { + Ready bool `json:"ready"` + Code string `json:"code"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + if !out.Ready || out.Code != "Q1W2E9" { + t.Fatalf("control /otp got ready=%v code=%q", out.Ready, out.Code) + } +} diff --git a/internal/otpsignup/broker.go b/internal/otpsignup/broker.go new file mode 100644 index 0000000..7fbf59c --- /dev/null +++ b/internal/otpsignup/broker.go @@ -0,0 +1,353 @@ +// Package otpsignup is a signed HTTP broker that mints a per-user provider API +// key WITHOUT any email input from the user. It is the orchestration half of the +// broker-signup pattern (the mailbox half is internal/otpmail): +// +// adapter ──POST /signup (ed25519-signed)──▶ broker +// broker: provision pilot_@ on the mail server +// → POST provider register {email,password} +// → poll the mail server for the OTP the provider emailed +// → POST provider verify {email,code} → api_key +// → persist {email,api_key} for this caller, tear the mailbox down +// broker ──{email, api_key}──▶ adapter (caches to secrets.json; ops stay byo) +// +// It is provider-agnostic: the register/verify URLs, the JSON path to the key, +// and the mail domain are all configuration. It reuses the shared broker's +// ed25519 caller-identity verification, and it is idempotent per caller (a +// repeat /signup returns the same persisted account, so the key is retrievable). +package otpsignup + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/pilot-protocol/app-template/internal/broker" +) + +// Config is the broker's deployment configuration (all from env; no provider or +// host specifics compiled in). +type Config struct { + Listen string // HTTP listen address (default 127.0.0.1:8090) + MailControlURL string // the mail server's control-API base (internal), e.g. http://10.x:8025 + MailToken string // bearer token for the mail control API + MailDomain string // the mail domain addresses are minted under + AddrPrefix string // localpart prefix, default "pilot_" + RegisterURL string // provider register endpoint + VerifyURL string // provider verify-email endpoint + KeyPath string // dotted path to the key in the verify response + DBPath string // sqlite path for the per-caller ledger + EncKeyHex string // 64-hex (32-byte) key encrypting secrets at rest + MaxIdentitiesPerIP int // per-IP distinct-caller cap (0 = unlimited) + OTPTimeout time.Duration // how long to wait for the OTP (default 90s) + MintCooldown time.Duration // min gap between mints from one IP (0 = none) +} + +// Broker orchestrates signups and owns the ledger. +type Broker struct { + cfg Config + store *store + verify broker.VerifyConfig + http *http.Client + log *log.Logger + + mu sync.Mutex + ipSeen map[string]map[string]bool // ip -> set of caller ids + ipLast map[string]time.Time // ip -> last mint time +} + +// New validates cfg, opens the ledger, and returns a Broker. +func New(cfg Config) (*Broker, error) { + if cfg.Listen == "" { + cfg.Listen = "127.0.0.1:8090" + } + if cfg.AddrPrefix == "" { + cfg.AddrPrefix = "pilot_" + } + if cfg.KeyPath == "" { + cfg.KeyPath = "application.api_key" + } + if cfg.OTPTimeout <= 0 { + cfg.OTPTimeout = 90 * time.Second + } + for name, v := range map[string]string{"MailControlURL": cfg.MailControlURL, "MailToken": cfg.MailToken, "MailDomain": cfg.MailDomain, "RegisterURL": cfg.RegisterURL, "VerifyURL": cfg.VerifyURL} { + if strings.TrimSpace(v) == "" { + return nil, fmt.Errorf("otpsignup: %s is required", name) + } + } + st, err := openStore(cfg.DBPath, cfg.EncKeyHex) + if err != nil { + return nil, err + } + return &Broker{ + cfg: cfg, + store: st, + http: &http.Client{Timeout: 30 * time.Second}, + log: log.New(io.Discard, "", 0), + ipSeen: map[string]map[string]bool{}, + ipLast: map[string]time.Time{}, + }, nil +} + +// SetLogger installs a logger (signup events only — never the key/password/code). +func (b *Broker) SetLogger(l *log.Logger) { b.log = l } + +// ListenAndServe serves the broker HTTP API. +func (b *Broker) ListenAndServe() error { + mux := http.NewServeMux() + mux.HandleFunc("/gw/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) + mux.HandleFunc("/signup", b.handleSignup) + return http.ListenAndServe(b.cfg.Listen, mux) +} + +// Account is what the broker returns to the adapter. +type Account struct { + Email string `json:"email"` + APIKey string `json:"api_key"` + Cached bool `json:"cached"` // true when returned from the ledger (idempotent repeat) +} + +func (b *Broker) handleSignup(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + caller, err := b.verify.Verify(r.Header.Get, r.Method, r.URL.Path, body) + if err != nil { + http.Error(w, "identity: "+err.Error(), http.StatusUnauthorized) + return + } + ip := clientIP(r) + + acct, err := b.Signup(r.Context(), string(caller), ip) + if err != nil { + code := http.StatusBadGateway + if errors.Is(err, errRateLimited) { + code = http.StatusTooManyRequests + } + b.log.Printf("signup caller=%s ip=%s FAIL: %v", short(string(caller)), ip, err) + http.Error(w, err.Error(), code) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(acct) +} + +var errRateLimited = errors.New("per-IP identity cap reached (anti-abuse)") + +// Signup is the core flow, separated from HTTP so it is directly testable. +func (b *Broker) Signup(ctx context.Context, caller, ip string) (*Account, error) { + // Idempotent: a caller that already has an account gets it back (retrievable), + // no second provider account minted. + if rec, ok, _ := b.store.get(caller); ok { + b.log.Printf("signup caller=%s ip=%s cached email=%s", short(caller), ip, rec.Email) + return &Account{Email: rec.Email, APIKey: rec.APIKey, Cached: true}, nil + } + if err := b.gate(caller, ip); err != nil { + return nil, err + } + + addr := b.cfg.AddrPrefix + randToken(10) + "@" + b.cfg.MailDomain + password := randToken(16) + "Aa1!" + start := time.Now() + + if err := b.mailControl(ctx, "/provision", addr); err != nil { + return nil, fmt.Errorf("provision: %w", err) + } + defer b.mailControl(context.Background(), "/teardown", addr) // always tear down + + if err := b.register(ctx, addr, password); err != nil { + return nil, err + } + code, err := b.pollOTP(ctx, addr) + if err != nil { + return nil, err + } + key, err := b.verifyEmail(ctx, addr, code) + if err != nil { + return nil, err + } + + if err := b.store.put(caller, record{Email: addr, Password: password, APIKey: key, CreatedAt: time.Now().UTC()}); err != nil { + return nil, fmt.Errorf("persist: %w", err) + } + b.markMinted(caller, ip) + b.log.Printf("signup caller=%s ip=%s minted email=%s in %s", short(caller), ip, addr, time.Since(start).Round(time.Millisecond)) + return &Account{Email: addr, APIKey: key}, nil +} + +// gate enforces the per-IP identity cap + mint cooldown. +func (b *Broker) gate(caller, ip string) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.cfg.MintCooldown > 0 { + if last, ok := b.ipLast[ip]; ok && time.Since(last) < b.cfg.MintCooldown { + return errRateLimited + } + } + if b.cfg.MaxIdentitiesPerIP > 0 { + seen := b.ipSeen[ip] + if seen != nil && !seen[caller] && len(seen) >= b.cfg.MaxIdentitiesPerIP { + return errRateLimited + } + } + return nil +} + +func (b *Broker) markMinted(caller, ip string) { + b.mu.Lock() + defer b.mu.Unlock() + if b.ipSeen[ip] == nil { + b.ipSeen[ip] = map[string]bool{} + } + b.ipSeen[ip][caller] = true + b.ipLast[ip] = time.Now() +} + +// ── provider + mail calls ─────────────────────────────────────────────────── + +func (b *Broker) register(ctx context.Context, addr, password string) error { + st, body := b.postJSON(ctx, b.cfg.RegisterURL, map[string]string{"email": addr, "password": password}) + if st == 200 || st == 201 || st == 409 { + return nil + } + if (st == 400 || st == 422) && strings.Contains(strings.ToLower(string(body)), "already") { + return nil + } + if st == 429 { + return fmt.Errorf("provider register rate-limited (429)") + } + return fmt.Errorf("provider register HTTP %d", st) +} + +func (b *Broker) verifyEmail(ctx context.Context, addr, code string) (string, error) { + st, body := b.postJSON(ctx, b.cfg.VerifyURL, map[string]string{"email": addr, "code": code}) + if st != 200 && st != 201 { + return "", fmt.Errorf("provider verify HTTP %d", st) + } + key := digString(body, strings.Split(b.cfg.KeyPath, ".")...) + if key == "" { + return "", fmt.Errorf("provider verify returned no key at %q", b.cfg.KeyPath) + } + return key, nil +} + +func (b *Broker) pollOTP(ctx context.Context, addr string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, b.cfg.OTPTimeout) + defer cancel() + for { + st, body := b.getJSON(ctx, b.cfg.MailControlURL+"/otp?addr="+addr) + if st == 200 { + var out struct { + Ready bool `json:"ready"` + Code string `json:"code"` + } + if json.Unmarshal(body, &out) == nil && out.Ready && out.Code != "" { + return out.Code, nil + } + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("timed out waiting for the OTP") + case <-time.After(3 * time.Second): + } + } +} + +func (b *Broker) mailControl(ctx context.Context, path, addr string) error { + st, _ := b.postJSONAuth(ctx, b.cfg.MailControlURL+path, map[string]string{"addr": addr}, b.cfg.MailToken) + if st != 200 { + return fmt.Errorf("mail control %s HTTP %d", path, st) + } + return nil +} + +// ── HTTP helpers ──────────────────────────────────────────────────────────── + +func (b *Broker) postJSON(ctx context.Context, url string, m map[string]string) (int, []byte) { + return b.postJSONAuth(ctx, url, m, "") +} + +func (b *Broker) postJSONAuth(ctx context.Context, url string, m map[string]string, token string) (int, []byte) { + raw, _ := json.Marshal(m) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := b.http.Do(req) + if err != nil { + return 0, []byte(err.Error()) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode, body +} + +func (b *Broker) getJSON(ctx context.Context, url string) (int, []byte) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req.Header.Set("Accept", "application/json") + if b.cfg.MailToken != "" { + req.Header.Set("Authorization", "Bearer "+b.cfg.MailToken) + } + resp, err := b.http.Do(req) + if err != nil { + return 0, nil + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode, body +} + +func clientIP(r *http.Request) string { + if xf := r.Header.Get("X-Real-IP"); xf != "" { + return xf + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + host, _, _ := net.SplitHostPort(r.RemoteAddr) + return host +} + +func digString(raw []byte, path ...string) string { + cur := raw + for _, p := range path { + var m map[string]json.RawMessage + if json.Unmarshal(cur, &m) != nil { + return "" + } + nx, ok := m[p] + if !ok { + return "" + } + cur = nx + } + var s string + _ = json.Unmarshal(cur, &s) + return s +} + +func randToken(n int) string { + const a = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + _, _ = rand.Read(b) + for i := range b { + b[i] = a[int(b[i])%len(a)] + } + return string(b) +} + +func short(id string) string { + if len(id) > 12 { + return id[:12] + "…" + } + return id +} diff --git a/internal/otpsignup/broker_test.go b/internal/otpsignup/broker_test.go new file mode 100644 index 0000000..5b092b1 --- /dev/null +++ b/internal/otpsignup/broker_test.go @@ -0,0 +1,190 @@ +package otpsignup + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/pilot-protocol/app-template/internal/broker" +) + +// mockProvider stands in for the identity provider: register → 201, verify → +// {application:{api_key}}. mockMail stands in for the otpmail control API. +func mockProvider(t *testing.T, key string) *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(201); w.Write([]byte(`{"message":"ok"}`)) }) + mux.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"application":{"api_key":"` + key + `"}}`)) + }) + return httptest.NewServer(mux) +} + +func mockMail(t *testing.T, code string) *httptest.Server { + var mu sync.Mutex + provisioned := map[string]bool{} + mux := http.NewServeMux() + auth := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer mail-tok" { + http.Error(w, "unauth", 401) + return + } + h(w, r) + } + } + mux.HandleFunc("/provision", auth(func(w http.ResponseWriter, r *http.Request) { + var b struct { + Addr string `json:"addr"` + } + json.NewDecoder(r.Body).Decode(&b) + mu.Lock() + provisioned[b.Addr] = true + mu.Unlock() + w.Write([]byte(`{"ok":true}`)) + })) + mux.HandleFunc("/otp", auth(func(w http.ResponseWriter, r *http.Request) { + addr := r.URL.Query().Get("addr") + mu.Lock() + ok := provisioned[addr] + mu.Unlock() + if !ok { + w.Write([]byte(`{"ready":false}`)) + return + } + w.Write([]byte(`{"ready":true,"code":"` + code + `"}`)) // simulate the provider delivered the OTP + })) + mux.HandleFunc("/teardown", auth(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"ok":true}`)) })) + return httptest.NewServer(mux) +} + +func newBroker(t *testing.T, provURL, mailURL string) *Broker { + t.Helper() + b, err := New(Config{ + MailControlURL: mailURL, MailToken: "mail-tok", MailDomain: "mx.example.net", + RegisterURL: provURL + "/register", VerifyURL: provURL + "/verify", + KeyPath: "application.api_key", OTPTimeout: 5 * time.Second, MaxIdentitiesPerIP: 2, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return b +} + +func TestSignupMintsAndIsIdempotent(t *testing.T) { + prov := mockProvider(t, "KEY-ABC123") + mail := mockMail(t, "A3K9F2") + defer prov.Close() + defer mail.Close() + b := newBroker(t, prov.URL, mail.URL) + + acct, err := b.Signup(context.Background(), "caller-1", "1.2.3.4") + if err != nil { + t.Fatalf("Signup: %v", err) + } + if acct.APIKey != "KEY-ABC123" { + t.Fatalf("api_key=%q want KEY-ABC123", acct.APIKey) + } + if !strings.HasPrefix(acct.Email, "pilot_") || !strings.HasSuffix(acct.Email, "@mx.example.net") { + t.Fatalf("email=%q not pilot_*@domain", acct.Email) + } + if acct.Cached { + t.Fatal("first mint should not be cached") + } + // idempotent repeat → same account, cached + acct2, err := b.Signup(context.Background(), "caller-1", "1.2.3.4") + if err != nil { + t.Fatal(err) + } + if !acct2.Cached || acct2.Email != acct.Email || acct2.APIKey != acct.APIKey { + t.Fatalf("repeat not idempotent: %+v vs %+v", acct2, acct) + } +} + +func TestSignupEncryptsAtRestAndRetrieves(t *testing.T) { + prov := mockProvider(t, "KEY-XYZ") + mail := mockMail(t, "B4L8M1") + defer prov.Close() + defer mail.Close() + b := newBroker(t, prov.URL, mail.URL) + if _, err := b.Signup(context.Background(), "caller-2", "9.9.9.9"); err != nil { + t.Fatal(err) + } + // stored ciphertext must not contain the plaintext key + var akEnc string + if err := b.store.db.QueryRow(`SELECT apikey_enc FROM accounts WHERE caller=?`, "caller-2").Scan(&akEnc); err != nil { + t.Fatal(err) + } + if strings.Contains(akEnc, "KEY-XYZ") { + t.Fatal("api_key stored in plaintext") + } + rec, ok, err := b.store.get("caller-2") + if err != nil || !ok || rec.APIKey != "KEY-XYZ" { + t.Fatalf("retrieve after decrypt failed: ok=%v key=%q err=%v", ok, rec.APIKey, err) + } +} + +func TestPerIPCapBlocksSybil(t *testing.T) { + prov := mockProvider(t, "K") + mail := mockMail(t, "C1D2E3") + defer prov.Close() + defer mail.Close() + b := newBroker(t, prov.URL, mail.URL) // cap = 2 per IP + ip := "5.5.5.5" + if _, err := b.Signup(context.Background(), "c-a", ip); err != nil { + t.Fatal(err) + } + if _, err := b.Signup(context.Background(), "c-b", ip); err != nil { + t.Fatal(err) + } + if _, err := b.Signup(context.Background(), "c-c", ip); err == nil { + t.Fatal("expected 3rd distinct identity from one IP to be rate-limited") + } + // a previously-minted identity still works (idempotent, not a new mint) + if _, err := b.Signup(context.Background(), "c-a", ip); err != nil { + t.Fatalf("cached caller should still succeed: %v", err) + } +} + +// TestSignedHTTPFlow drives the real signed HTTP endpoint end to end. +func TestSignedHTTPFlow(t *testing.T) { + prov := mockProvider(t, "KEY-HTTP") + mail := mockMail(t, "F5G6H7") + defer prov.Close() + defer mail.Close() + b := newBroker(t, prov.URL, mail.URL) + ts := httptest.NewServer(http.HandlerFunc(b.handleSignup)) + defer ts.Close() + + pub, priv, _ := ed25519.GenerateKey(nil) + _ = pub + hdrs := broker.Sign(priv, http.MethodPost, "/signup", []byte("{}"), time.Now()) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/signup", strings.NewReader("{}")) + for k, v := range hdrs { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status=%d want 200", resp.StatusCode) + } + var acct Account + json.NewDecoder(resp.Body).Decode(&acct) + if acct.APIKey != "KEY-HTTP" { + t.Fatalf("api_key=%q want KEY-HTTP", acct.APIKey) + } + + // unsigned request → 401 + bad, _ := http.NewRequest(http.MethodPost, ts.URL+"/signup", strings.NewReader("{}")) + r2, _ := http.DefaultClient.Do(bad) + if r2.StatusCode != 401 { + t.Fatalf("unsigned status=%d want 401", r2.StatusCode) + } +} diff --git a/internal/otpsignup/store.go b/internal/otpsignup/store.go new file mode 100644 index 0000000..1a45fe4 --- /dev/null +++ b/internal/otpsignup/store.go @@ -0,0 +1,132 @@ +package otpsignup + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "time" + + _ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo) +) + +// record is one caller's minted provider account. Password + APIKey are +// encrypted at rest; Email is stored in the clear (it is a synthetic address we +// minted, not sensitive on its own). +type record struct { + Email string + Password string + APIKey string + CreatedAt time.Time +} + +// store is the per-caller ledger. Secrets are sealed with AES-256-GCM so a DB +// leak does not expose keys/passwords without the separate encryption key. +type store struct { + db *sql.DB + aead cipher.AEAD +} + +func openStore(dbPath, encKeyHex string) (*store, error) { + if dbPath == "" { + dbPath = ":memory:" + } + // A 32-byte key seals secrets at rest. Prod MUST set a stable key (else a + // restart can't decrypt prior rows); tests fall back to an ephemeral one. + var key []byte + if encKeyHex != "" { + k, err := hex.DecodeString(encKeyHex) + if err != nil || len(k) != 32 { + return nil, fmt.Errorf("otpsignup: EncKeyHex must be 64 hex chars (32 bytes)") + } + key = k + } else { + key = make([]byte, 32) + _, _ = rand.Read(key) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // sqlite: serialize writers + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS accounts ( + caller TEXT PRIMARY KEY, + email TEXT NOT NULL, + password_enc TEXT NOT NULL, + apikey_enc TEXT NOT NULL, + created_at INTEGER NOT NULL + )`); err != nil { + return nil, err + } + return &store{db: db, aead: aead}, nil +} + +func (s *store) seal(plain string) (string, error) { + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ct := s.aead.Seal(nonce, nonce, []byte(plain), nil) + return base64.StdEncoding.EncodeToString(ct), nil +} + +func (s *store) open(enc string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil || len(raw) < s.aead.NonceSize() { + return "", fmt.Errorf("bad ciphertext") + } + nonce, ct := raw[:s.aead.NonceSize()], raw[s.aead.NonceSize():] + pt, err := s.aead.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(pt), nil +} + +func (s *store) put(caller string, r record) error { + pw, err := s.seal(r.Password) + if err != nil { + return err + } + ak, err := s.seal(r.APIKey) + if err != nil { + return err + } + _, err = s.db.Exec( + `INSERT OR IGNORE INTO accounts (caller,email,password_enc,apikey_enc,created_at) VALUES (?,?,?,?,?)`, + caller, r.Email, pw, ak, r.CreatedAt.Unix()) + return err +} + +func (s *store) get(caller string) (record, bool, error) { + var email, pwEnc, akEnc string + var ts int64 + err := s.db.QueryRow(`SELECT email,password_enc,apikey_enc,created_at FROM accounts WHERE caller=?`, caller). + Scan(&email, &pwEnc, &akEnc, &ts) + if err == sql.ErrNoRows { + return record{}, false, nil + } + if err != nil { + return record{}, false, err + } + pw, err := s.open(pwEnc) + if err != nil { + return record{}, false, err + } + ak, err := s.open(akEnc) + if err != nil { + return record{}, false, err + } + return record{Email: email, Password: pw, APIKey: ak, CreatedAt: time.Unix(ts, 0).UTC()}, true, nil +} diff --git a/internal/publish/submission.go b/internal/publish/submission.go index affe46d..226a73d 100644 --- a/internal/publish/submission.go +++ b/internal/publish/submission.go @@ -156,17 +156,18 @@ type SubMethod struct { // $APP/secrets.json under SecretKey, from which the byo ${TOKEN} headers resolve // it). One SubSignup describes one leg, selected by Step. type SubSignup struct { - Step string `json:"step"` // "register" (send OTP) | "verify" (redeem OTP → mint key) - URL string `json:"url"` // the endpoint POSTed for this step - KeyPath string `json:"key_path"` // verify: dotted path to the key in the response (default application.api_key) - SecretKey string `json:"secret_key"` // verify: secrets.json key the minted key is stored under + Step string `json:"step"` // "register" | "verify" | "broker" | "account" + URL string `json:"url"` // register/verify: the provider endpoint POSTed + BrokerURL string `json:"broker_url"` // broker: the Pilot broker /signup endpoint (signed) + KeyPath string `json:"key_path"` // verify: dotted path to the key (default application.api_key) + SecretKey string `json:"secret_key"` // secrets.json key the minted key is stored under EmailKey string `json:"email_key"` // secrets.json key for the account email (optional) PasswordKey string `json:"password_key"` // secrets.json key for the account password (optional) } -// HasSignup reports whether this method is a no-broker self-signup route. +// HasSignup reports whether this method is a self-signup route (any step). func (m SubMethod) HasSignup() bool { - return m.Signup != nil && strings.TrimSpace(m.Signup.URL) != "" + return m.Signup != nil && strings.TrimSpace(m.Signup.Step) != "" } // SubLocal makes a method read a local JSON metadata file (no backend call) — @@ -382,18 +383,36 @@ func validateSubLocalMethod(n string, m SubMethod) []string { // verify URLs, a secrets key, and no conflicting http/cli/local route. func validateSubSignupMethod(n string, m SubMethod) []string { var e []string + https := func(field, raw string) { + if u, err := url.Parse(raw); err != nil || u.Scheme != "https" || u.Host == "" { + e = append(e, fmt.Sprintf("Method %q: signup.%s must be an https URL", n, field)) + } + } switch m.Signup.Step { case "register", "verify": + if strings.TrimSpace(m.Signup.URL) == "" { + e = append(e, fmt.Sprintf("Method %q: signup.url is required", n)) + } else { + https("url", m.Signup.URL) + } + if m.Signup.Step == "verify" && strings.TrimSpace(m.Signup.SecretKey) == "" { + e = append(e, fmt.Sprintf("Method %q: a verify signup step needs signup.secret_key", n)) + } + case "broker": + if strings.TrimSpace(m.Signup.BrokerURL) == "" { + e = append(e, fmt.Sprintf("Method %q: a broker signup step needs signup.broker_url", n)) + } else { + https("broker_url", m.Signup.BrokerURL) + } + if strings.TrimSpace(m.Signup.SecretKey) == "" { + e = append(e, fmt.Sprintf("Method %q: a broker signup step needs signup.secret_key", n)) + } + case "account": + if strings.TrimSpace(m.Signup.SecretKey) == "" { + e = append(e, fmt.Sprintf("Method %q: an account step needs signup.secret_key", n)) + } default: - e = append(e, fmt.Sprintf("Method %q: signup.step must be register|verify", n)) - } - if strings.TrimSpace(m.Signup.URL) == "" { - e = append(e, fmt.Sprintf("Method %q: signup.url is required", n)) - } else if u, err := url.Parse(m.Signup.URL); err != nil || u.Scheme != "https" || u.Host == "" { - e = append(e, fmt.Sprintf("Method %q: signup.url must be an https URL", n)) - } - if m.Signup.Step == "verify" && strings.TrimSpace(m.Signup.SecretKey) == "" { - e = append(e, fmt.Sprintf("Method %q: a verify signup step needs signup.secret_key", n)) + e = append(e, fmt.Sprintf("Method %q: signup.step must be register|verify|broker|account", n)) } if m.HasHTTP() || m.HasCLI() || m.HasLocal() { e = append(e, fmt.Sprintf("Method %q: a signup method must not also declare an http/cli/local route", n)) @@ -613,6 +632,7 @@ func (s Submission) ToConfig() *scaffold.Config { method.Signup = &scaffold.SignupRoute{ Step: m.Signup.Step, URL: m.Signup.URL, + BrokerURL: m.Signup.BrokerURL, KeyPath: m.Signup.KeyPath, SecretKey: m.Signup.SecretKey, EmailKey: m.Signup.EmailKey, diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index 32b993e..e8caaa3 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -288,6 +288,18 @@ func (c *Config) HasSignup() bool { return false } +// HasBrokerSignup reports whether any signup route is the broker step — the +// adapter then needs an ed25519 signer + identity (to sign the keyless broker +// call) and key.sign + net.dial-broker grants, even though its ops stay byo. +func (c *Config) HasBrokerSignup() bool { + for _, m := range c.Methods { + if m.Signup != nil && m.Signup.IsBroker() { + return true + } + } + return false +} + // SignupHosts returns the extra hosts a no-broker signup adapter must dial on // top of the backend base_url: the register/verify auth endpoints. Deduped, // excluding the base_url host (already granted via HasHTTPMethods). @@ -295,14 +307,18 @@ func (c *Config) SignupHosts() []string { base := c.AdapterBackendHost() seen := map[string]bool{base: true} var out []string + add := func(raw string) { + if u, err := url.Parse(raw); err == nil && u.Hostname() != "" && !seen[u.Hostname()] { + seen[u.Hostname()] = true + out = append(out, u.Hostname()) + } + } for _, m := range c.Methods { if m.Signup == nil { continue } - if u, err := url.Parse(m.Signup.URL); err == nil && u.Hostname() != "" && !seen[u.Hostname()] { - seen[u.Hostname()] = true - out = append(out, u.Hostname()) - } + add(m.Signup.URL) + add(m.Signup.BrokerURL) } sort.Strings(out) return out @@ -505,19 +521,30 @@ type Method struct { // // Both steps hit the backend's auth host (URL), which is typically distinct from // the byo base_url — SignupHosts adds the net.dial grant for it. +// A signup route has one of three steps: +// - "register" / "verify": the no-broker, user-email flow (two calls) — the +// adapter drives the provider's register/verify itself. +// - "broker": the fully-autonomous flow — the adapter signs ONE keyless call +// to a Pilot broker (BrokerURL) that provisions a mailbox, drives the +// provider register→OTP→verify on our infrastructure, and returns +// {email, api_key}. The adapter caches BOTH to secrets.json; ops stay byo. +// No user email, no code, one call. type SignupRoute struct { - Step string `yaml:"step"` // "register" (send OTP) | "verify" (redeem OTP → mint key) - URL string `yaml:"url"` // the endpoint POSTed for this step + Step string `yaml:"step"` // "register" | "verify" | "broker" + URL string `yaml:"url"` // register/verify: the provider endpoint POSTed + BrokerURL string `yaml:"broker_url"` // broker: the Pilot broker /signup endpoint (signed) KeyPath string `yaml:"key_path"` // verify: dotted path to the key in the response (default application.api_key) - SecretKey string `yaml:"secret_key"` // verify: secrets.json key the minted key is stored under (e.g. DIDIT_API_KEY) - EmailKey string `yaml:"email_key"` // secrets.json key for the account email (default DIDIT_ACCOUNT_EMAIL) - PasswordKey string `yaml:"password_key"` // secrets.json key for the account password (default DIDIT_ACCOUNT_PASSWORD) + SecretKey string `yaml:"secret_key"` // secrets.json key the minted key is stored under (e.g. DIDIT_API_KEY) + EmailKey string `yaml:"email_key"` // secrets.json key for the account email (default ACCOUNT_EMAIL) + PasswordKey string `yaml:"password_key"` // secrets.json key for the account password (default ACCOUNT_PASSWORD) } -// SignupStep classifies a signup route. IsVerify is the leg that mints + caches -// the key; the other leg only starts registration. +// IsVerify is the no-broker leg that redeems the OTP + caches the key. func (s *SignupRoute) IsVerify() bool { return strings.EqualFold(s.Step, "verify") } +// IsBroker is the fully-autonomous leg that signs a call to the Pilot broker. +func (s *SignupRoute) IsBroker() bool { return strings.EqualFold(s.Step, "broker") } + // LocalRoute makes a method run entirely on the host with NO backend call: it // reads a local JSON metadata file (see HTTPRoute.CaptureTo, which writes it) and // returns its contents. Used for host-local state an agent should recall without @@ -953,18 +980,37 @@ func (c *Config) validateLocalMethod(i int, m Method) []error { // headers then resolve). func (c *Config) validateSignupMethod(i int, m Method) []error { var errs []error + httpsURL := func(field, raw string) { + if u, err := url.Parse(raw); err != nil || u.Scheme != "https" || u.Host == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.%s %q must be an https URL", i, m.Name, field, raw)) + } + } switch m.Signup.Step { case "register", "verify": + if strings.TrimSpace(m.Signup.URL) == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.url is required", i, m.Name)) + } else { + httpsURL("url", m.Signup.URL) + } + if m.Signup.IsVerify() && strings.TrimSpace(m.Signup.SecretKey) == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): a verify signup step needs signup.secret_key", i, m.Name)) + } + case "broker": + if strings.TrimSpace(m.Signup.BrokerURL) == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): a broker signup step needs signup.broker_url", i, m.Name)) + } else { + httpsURL("broker_url", m.Signup.BrokerURL) + } + if strings.TrimSpace(m.Signup.SecretKey) == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): a broker signup step needs signup.secret_key (the key the minted secret is cached under)", i, m.Name)) + } + case "account": + // A local reader of the cached account — needs only the secrets keys. + if strings.TrimSpace(m.Signup.SecretKey) == "" { + errs = append(errs, fmt.Errorf("methods[%d] (%s): an account step needs signup.secret_key", i, m.Name)) + } default: - errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.step %q must be register|verify", i, m.Name, m.Signup.Step)) - } - if strings.TrimSpace(m.Signup.URL) == "" { - errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.url is required", i, m.Name)) - } else if u, err := url.Parse(m.Signup.URL); err != nil || u.Scheme != "https" || u.Host == "" { - errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.url %q must be an https URL", i, m.Name, m.Signup.URL)) - } - if m.Signup.IsVerify() && strings.TrimSpace(m.Signup.SecretKey) == "" { - errs = append(errs, fmt.Errorf("methods[%d] (%s): a verify signup step needs signup.secret_key (the secrets.json key the minted key is stored under)", i, m.Name)) + errs = append(errs, fmt.Errorf("methods[%d] (%s): signup.step %q must be register|verify|broker|account", i, m.Name, m.Signup.Step)) } if c.Managed() { errs = append(errs, fmt.Errorf("methods[%d] (%s): a signup (no-broker) route cannot be combined with managed/provisioned auth", i, m.Name)) diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index a39f3d3..a47a108 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -38,14 +38,21 @@ func Generate(cfg *Config, outDir string) ([]string, error) { if cfg.Backend.X402 != nil { files = append(files, file{filepath.Join("internal", "backend", "x402.go"), "x402.go.tmpl"}) } - if cfg.Managed() { + // A byo app with a broker-signup route also needs the ed25519 signer to + // sign its keyless broker call (Managed apps already get it). + if cfg.Managed() || cfg.HasBrokerSignup() { files = append(files, file{filepath.Join("internal", "backend", "signer.go"), "signer.go.tmpl"}) } - // No-broker self-signup: emit the local register→OTP→verify→cache runtime + // Self-signup runtime (register→OTP→verify→cache) + the account reader, // next to main.go (package main) when any method declares a signup route. if cfg.HasSignup() { files = append(files, file{filepath.Join("cmd", cfg.BinaryName, "signup.go"), "signup.go.tmpl"}) } + // The broker-call runtime is a separate file emitted only for broker apps + // (it imports the signer, absent from a plain register/verify app). + if cfg.HasBrokerSignup() { + files = append(files, file{filepath.Join("cmd", cfg.BinaryName, "broker_signup.go"), "broker_signup.go.tmpl"}) + } case "cli": files = append(files, file{filepath.Join("internal", "backend", "exec.go"), "client_cli.go.tmpl"}) // Native-binary delivery: emit the staging runtime only when the app diff --git a/internal/scaffold/signup_test.go b/internal/scaffold/signup_test.go index b745122..9e7c421 100644 --- a/internal/scaffold/signup_test.go +++ b/internal/scaffold/signup_test.go @@ -42,6 +42,93 @@ methods: http: {verb: GET, path: /billing/balance/} ` +// brokerSignupSpec is a byo http app whose signup is the fully-autonomous broker +// step (one call, no email) plus an account reader. +const brokerSignupSpec = ` +id: io.pilot.didit +app_version: 1.0.0 +description: "Identity verification with broker-side autonomous signup." +backend: + type: http + base_url: https://verification.didit.me/v3 + auth: byo + headers: + x-api-key: "${DIDIT_API_KEY}" +methods: + - name: didit.signup + summary: "Mint a Didit key via the broker — one call, no email." + duration: slow + signup: + step: broker + broker_url: https://broker.pilotprotocol.network/didit/signup + secret_key: DIDIT_API_KEY + email_key: DIDIT_ACCOUNT_EMAIL + - name: didit.account + summary: "Retrieve the cached account (email + key)." + duration: fast + signup: + step: account + secret_key: DIDIT_API_KEY + email_key: DIDIT_ACCOUNT_EMAIL + - name: didit.billing_balance + summary: "Check remaining credit balance." + duration: fast + http: {verb: GET, path: /billing/balance/} +` + +func TestBrokerSignupGrantsAndCompiles(t *testing.T) { + cfg := parseSpec(t, brokerSignupSpec) + if !cfg.HasBrokerSignup() { + t.Fatal("HasBrokerSignup() should be true") + } + dir := t.TempDir() + if _, err := Generate(cfg, dir); err != nil { + t.Fatalf("generate: %v", err) + } + mf, _ := os.ReadFile(filepath.Join(dir, "manifest.json")) + for _, want := range []string{ + `"cap": "key.sign", "target": "self"`, + `"cap": "fs.write", "target": "$APP/secrets.json"`, + `"target": "broker.pilotprotocol.network"`, + `"didit.signup"`, `"didit.account"`, + } { + if !strings.Contains(string(mf), want) { + t.Errorf("manifest missing %q", want) + } + } + for _, f := range []string{"broker_signup.go", "signup.go"} { + if _, err := os.Stat(filepath.Join(dir, "cmd", cfg.BinaryName, f)); err != nil { + t.Errorf("expected generated %s: %v", f, err) + } + } + // the signer.go (from the backend package) must be emitted for the broker call + if _, err := os.Stat(filepath.Join(dir, "internal", "backend", "signer.go")); err != nil { + t.Errorf("expected generated signer.go: %v", err) + } + if testing.Short() { + return + } + compileGenerated(t, dir) +} + +// compileGenerated seeds go.sum and `go build ./...` the generated project. +func compileGenerated(t *testing.T, dir string) { + t.Helper() + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go toolchain not available") + } + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + _ = os.WriteFile(filepath.Join(dir, "go.sum"), sum, 0o644) + } + cmd := exec.Command(goBin, "build", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated broker-signup project failed to compile: %v\n%s", err, out) + } +} + func TestSignupConfigResolvesDefaults(t *testing.T) { cfg := parseSpec(t, signupSpec) if !cfg.HasSignup() { diff --git a/internal/scaffold/templates/broker_signup.go.tmpl b/internal/scaffold/templates/broker_signup.go.tmpl new file mode 100644 index 0000000..3c91567 --- /dev/null +++ b/internal/scaffold/templates/broker_signup.go.tmpl @@ -0,0 +1,99 @@ +// Broker self-signup: mint a per-user provider key via a signed, keyless call to +// a Pilot broker — one call, no email, no code. +// +// GENERATED by pilot-app for apps with a `signup: {step: broker}` route. The +// broker provisions a mailbox on infrastructure we control, drives the +// provider's register→email-OTP→verify handshake, and returns {email, api_key}; +// this handler signs the call with the app's Pilot identity and caches BOTH to +// $APP/secrets.json, from which the byo ${TOKEN} headers resolve the key on +// every subsequent (direct, un-brokered) call. Reuses helpers from signup.go. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" + "{{.GoModule}}/internal/backend" +) + +type brokerSignupConfig struct { + brokerURL string // the Pilot broker /signup endpoint (signed) + secretKey string // secrets.json key the minted key is cached under + emailKey string // secrets.json key the minted email is cached under +} + +// brokerSignupHandler signs a call to the broker, then caches {email, api_key}. +// Idempotent: if a key is already cached it returns immediately; the broker is +// also idempotent per identity, so a fresh install re-fetches the same account. +func brokerSignupHandler(bc brokerSignupConfig, signer backend.Signer, manifestPath string, timeout time.Duration) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + dir := signupAppDir(manifestPath) + if dir == "" { + return nil, errors.New("signup: cannot locate the app directory to cache the key") + } + secretsFile := filepath.Join(dir, "secrets.json") + if existing := signupReadSecrets(secretsFile); existing[bc.secretKey] != "" { + return signupResult(map[string]any{ + "ok": true, "already": true, "email": existing[bc.emailKey], + "key_field": bc.secretKey, + "message": "already signed up; the cached key is used on every call", + }) + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + u, err := url.Parse(bc.brokerURL) + if err != nil { + return nil, fmt.Errorf("signup: bad broker url: %w", err) + } + body := []byte("{}") + reqHTTP, err := http.NewRequestWithContext(ctx, http.MethodPost, bc.brokerURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + reqHTTP.Header.Set("Content-Type", "application/json") + reqHTTP.Header.Set("Accept", "application/json") + // Sign with the app's Pilot identity so the broker can meter/attribute us. + for k, v := range signer(http.MethodPost, u.Path, body) { + reqHTTP.Header.Set(k, v) + } + resp, err := (&http.Client{Timeout: timeout}).Do(reqHTTP) + if err != nil { + return nil, fmt.Errorf("signup: broker: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, fmt.Errorf("signup: broker HTTP %d: %s", resp.StatusCode, signupSnippet(raw)) + } + + var out struct { + Email string `json:"email"` + APIKey string `json:"api_key"` + } + if err := json.Unmarshal(raw, &out); err != nil || out.APIKey == "" { + return nil, fmt.Errorf("signup: broker returned no api_key") + } + add := map[string]string{bc.secretKey: out.APIKey} + if bc.emailKey != "" { + add[bc.emailKey] = out.Email + } + if err := signupMergeSecrets(secretsFile, add); err != nil { + return nil, fmt.Errorf("signup: cache key: %w", err) + } + return signupResult(map[string]any{ + "ok": true, "email": out.Email, "key_field": bc.secretKey, "api_key": "saved", + "message": "account minted by the broker; key cached to secrets.json and used on every call", + }) + } +} diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index 923f90c..cf7e789 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -49,8 +49,8 @@ func main() { _ = flag.String("addr", "", "daemon address (unused by this app)") {{- end}} _ = flag.String("db", "", "per-app sqlite path (unused: this app is stateless)") -{{- if .Managed}} - identity := flag.String("identity", "", "per-app ed25519 identity path (used to sign managed-broker requests)") +{{- if or .Managed .HasBrokerSignup}} + identity := flag.String("identity", "", "per-app ed25519 identity path (used to sign broker requests)") {{- else}} _ = flag.String("identity", "", "per-app identity path (unused by this app)") {{- end}} @@ -62,7 +62,7 @@ func main() { {{- if eq .Backend.Type "http"}} cfg := resolveConfig(*manifestPath) -{{- if .Managed}} +{{- if or .Managed .HasBrokerSignup}} signer, err := backend.NewSigner(*identity) if err != nil { log.Fatalf("{{.BinaryName}}: %v", err) @@ -122,7 +122,7 @@ func main() { d := ipc.NewDispatcher() {{- if eq .Backend.Type "http"}} - registerHandlers(d, client, readAppVersion(*manifestPath), cfg.BackendURL{{if .HasSignup}}, *manifestPath{{end}}) + registerHandlers(d, client, readAppVersion(*manifestPath), cfg.BackendURL{{if .HasSignup}}, *manifestPath{{end}}{{if .HasBrokerSignup}}, signer{{end}}) {{- else if eq .Backend.Type "hybrid"}} registerHandlers(d, runner, cloudCli, readAppVersion(*manifestPath), cfg.BackendURL) {{- else}} @@ -175,12 +175,23 @@ func serve(ctx context.Context, socketPath string, d *ipc.Dispatcher) error { // registerHandlers wires each IPC method to its backend call. Method names MUST // match the manifest's `exposes` list, or the daemon won't broker them. {{- if eq .Backend.Type "http"}} -func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL{{if .HasSignup}}, manifestPath{{end}} string) { +func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL string{{if .HasSignup}}, manifestPath string{{end}}{{if .HasBrokerSignup}}, signer backend.Signer{{end}}) { {{- range .Methods}} {{- if .Local}} d.Register("{{.Name}}", localRead(expandHome("{{.Local.Store}}"))) // {{.Duration}} (local metadata) {{- else if .Signup}} -{{- if .Signup.IsVerify}} +{{- if eq .Signup.Step "account"}} + d.Register("{{.Name}}", accountHandler(accountConfig{ + secretKey: {{printf "%q" .Signup.SecretKey}}, + emailKey: {{printf "%q" .Signup.EmailKey}}, + }, manifestPath)) // {{.Duration}} (local: retrieve the cached account) +{{- else if .Signup.IsBroker}} + d.Register("{{.Name}}", brokerSignupHandler(brokerSignupConfig{ + brokerURL: {{printf "%q" .Signup.BrokerURL}}, + secretKey: {{printf "%q" .Signup.SecretKey}}, + emailKey: {{printf "%q" .Signup.EmailKey}}, + }, signer, manifestPath, dur("{{.TimeoutFor}}"))) // {{.Duration}} (broker self-signup: one call, no email) +{{- else if .Signup.IsVerify}} d.Register("{{.Name}}", signupVerifyHandler(signupVerifyConfig{ url: {{printf "%q" .Signup.URL}}, keyPath: {{printf "%q" .Signup.KeyPath}}, diff --git a/internal/scaffold/templates/manifest.json.tmpl b/internal/scaffold/templates/manifest.json.tmpl index 9d47238..c20d25d 100644 --- a/internal/scaffold/templates/manifest.json.tmpl +++ b/internal/scaffold/templates/manifest.json.tmpl @@ -21,7 +21,7 @@ {{- if or .Provisioned .HasSignup}} {"cap": "fs.write", "target": "$APP/secrets.json"}, {{- end}} -{{- if .Managed}} +{{- if or .Managed .HasBrokerSignup}} {"cap": "key.sign", "target": "self"}, {{- end}} {{- if .HasHTTPMethods}} diff --git a/internal/scaffold/templates/signup.go.tmpl b/internal/scaffold/templates/signup.go.tmpl index cee6d83..3122271 100644 --- a/internal/scaffold/templates/signup.go.tmpl +++ b/internal/scaffold/templates/signup.go.tmpl @@ -175,6 +175,28 @@ func signupVerifyHandler(sc signupVerifyConfig, manifestPath string, timeout tim } } +// accountConfig / accountHandler expose the cached account for retrieval: the +// minted email + key are persistent in $APP/secrets.json, and this returns them +// (the user owns their own key) plus a signed_up flag. Local, no backend call. +type accountConfig struct { + secretKey string + emailKey string +} + +func accountHandler(ac accountConfig, manifestPath string) ipc.Handler { + return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { + m := map[string]string{} + if dir := signupAppDir(manifestPath); dir != "" { + m = signupReadSecrets(filepath.Join(dir, "secrets.json")) + } + return signupResult(map[string]any{ + "signed_up": m[ac.secretKey] != "", + "email": m[ac.emailKey], + "api_key": m[ac.secretKey], + }) + } +} + // ── shared helpers ────────────────────────────────────────────────────────── func signupResult(m map[string]any) (json.RawMessage, error) { From af67cf7bc8b3ba91f7de5e52637393a9497a1d0b Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Tue, 7 Jul 2026 17:02:35 -0700 Subject: [PATCH 2/5] =?UTF-8?q?submit:=20io.pilot.didit=20=E2=86=92=20one-?= =?UTF-8?q?call=20broker=20signup=20(no=20email)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit didit.signup {} now mints the per-user Didit key via the Pilot broker in one call (no email, no code); adds didit.account to retrieve the cached account; drops the two-step register/verify. Help + long description updated. --- submissions/io.pilot.didit/submission.json | 66 ++++++---------------- 1 file changed, 17 insertions(+), 49 deletions(-) diff --git a/submissions/io.pilot.didit/submission.json b/submissions/io.pilot.didit/submission.json index 9129a1e..54e8eca 100644 --- a/submissions/io.pilot.didit/submission.json +++ b/submissions/io.pilot.didit/submission.json @@ -2,7 +2,7 @@ "id": "io.pilot.didit", "version": "1.0.0", "namespace": "didit", - "description": "Full Didit identity-verification platform over one HTTPS app \u2014 KYC/ID, liveness, face match, AML, proof-of-address, database validation, email/phone OTP, plus hosted sessions, workflows, users, billing, blocklists, questionnaires and webhooks. No broker: didit.signup {email} + didit.verify {code} create your own Didit account and cache its API key locally, then every method authenticates as you and bills to your own Didit balance (500 free full-KYC checks/month).", + "description": "Full Didit identity-verification platform over one HTTPS app \u2014 KYC/ID, liveness, face match, AML, proof-of-address, database validation, email/phone OTP, plus hosted sessions, workflows, users, billing, blocklists, questionnaires and webhooks. didit.signup mints your own Didit API key in ONE call with no email and no code (Pilot's broker handles the whole signup), caches it locally, and then every method authenticates as you and bills to your own Didit balance (500 free full-KYC checks/month).", "email": "alex@vulturelabs.io", "backend": { "type": "http", @@ -18,58 +18,26 @@ "methods": [ { "name": "didit.signup", - "description": "STEP 1 of 2 \u2014 create a Didit account. Pass YOUR email; Didit emails it a 6-character one-time code. No broker: this hits Didit's programmatic register endpoint (POST /programmatic/register/) directly and caches the account email + password to $APP/secrets.json so the account stays recoverable. It does NOT return an API key \u2014 Didit only issues the key after you confirm the code. Then call didit.verify with that code. Why your own email (not a throwaway): Didit suppresses disposable mailboxes, so the code must land in an inbox you control. FREE \u2014 account creation costs nothing; you pay only per verification you run, and each new account includes Didit's 500 free full-KYC checks/month. Register is rate-limited to 5 attempts per IP per hour. Password is optional (a strong one is generated and cached if omitted).", - "latency": "med", + "description": "Get your own Didit API key in ONE call \u2014 no email, no code, no human step. This signs a keyless request to Pilot's Didit broker, which provisions a mailbox on Pilot infrastructure, registers a Didit account, reads the emailed one-time code server-side, verifies it, and returns your account's api_key. The adapter caches {email, api_key} to $APP/secrets.json, and from then on EVERY other didit.* method authenticates automatically (x-api-key) \u2014 you never handle the key or an inbox. Idempotent: the broker mints at most one account per Pilot identity, so a repeat call (or a fresh install) returns the SAME account. Run this ONCE before any other method. FREE \u2014 account creation costs nothing; you pay only per verification you run, and each account includes Didit's 500 free full-KYC checks/month. The account (email + key) is retrievable any time via didit.account. Takes no arguments.", + "latency": "slow", "signup": { - "step": "register", - "url": "https://apx.didit.me/auth/v2/programmatic/register/", - "email_key": "DIDIT_ACCOUNT_EMAIL", - "password_key": "DIDIT_ACCOUNT_PASSWORD" + "step": "broker", + "broker_url": "https://broker.pilotprotocol.network/didit/signup", + "secret_key": "DIDIT_API_KEY", + "email_key": "DIDIT_ACCOUNT_EMAIL" }, - "params": [ - { - "name": "email", - "type": "string", - "required": true, - "description": "The email address to register. Didit sends the 6-character verification code here, so use a real inbox you can read (disposable domains are rejected). Cached for didit.verify to default.", - "in": "body" - }, - { - "name": "password", - "type": "string", - "required": false, - "description": "Optional account password (min 8 chars, 1 upper/lower/digit/special). Omit to auto-generate + cache one; you only need it later for the Didit console or POST /programmatic/login/.", - "in": "body" - } - ] + "params": [] }, { - "name": "didit.verify", - "description": "STEP 2 of 2 \u2014 confirm the code from didit.signup and mint your API key. Pass the 6-character code Didit emailed you; this POSTs to /programmatic/verify-email/, extracts application.api_key from the response, and writes it to $APP/secrets.json as DIDIT_API_KEY. From then on EVERY other didit.* method authenticates automatically (x-api-key) \u2014 you never handle the key yourself. Idempotent: if a key is already cached it returns {already:true} without re-verifying. email defaults to the address you registered with, so usually you only need to pass {code}. FREE.", - "latency": "med", + "name": "didit.account", + "description": "Retrieve your cached Didit account \u2014 the email the broker provisioned for you and your api_key \u2014 plus a signed_up flag. Local, instant, FREE (reads $APP/secrets.json; no backend call). Use it to confirm you're signed up or to read your key. If signed_up is false, call didit.signup first.", + "latency": "fast", "signup": { - "step": "verify", - "url": "https://apx.didit.me/auth/v2/programmatic/verify-email/", - "key_path": "application.api_key", + "step": "account", "secret_key": "DIDIT_API_KEY", "email_key": "DIDIT_ACCOUNT_EMAIL" }, - "params": [ - { - "name": "code", - "type": "string", - "required": true, - "description": "The 6-character one-time code Didit emailed you after didit.signup.", - "in": "body" - }, - { - "name": "email", - "type": "string", - "required": false, - "description": "The email you registered with. Optional \u2014 defaults to the address didit.signup cached.", - "in": "body" - } - ] + "params": [] }, { "name": "didit.billing_balance", @@ -1248,7 +1216,7 @@ "listing": { "display_name": "Didit", "tagline": "One API for identity and fraud \u2014 KYC, liveness, face match, AML, and more, with a no-broker key you mint in one call.", - "app_description": "**Didit is one API for identity and fraud** \u2014 KYC/ID verification, liveness, face match, AML screening, proof of address, database validation, and email/phone OTP, wrapped as a single Pilot app. It fronts Didit's full platform: **hosted verification sessions**, reusable **workflows**, **users**, **billing**, **blocklists**, **questionnaires**, and **webhooks** \u2014 40 methods in all.\n\n## No broker \u2014 your own key, minted in two calls\n\nUnlike managed apps, this app holds **no shared key** and routes through **no Pilot broker**. Instead it self-provisions a Didit account that is entirely *yours*, keyed by *your* email:\n\n1. **`didit.signup` `{email}`** registers you with Didit; Didit emails your inbox a 6-character code. (Use a real inbox \u2014 Didit rejects disposable mailboxes. The account email + password are cached locally so the account stays recoverable.)\n2. **`didit.verify` `{code}`** confirms the code and writes the returned `api_key` to `$APP/secrets.json`.\n\nAfter that, **every other method sends your key as `x-api-key` automatically** \u2014 you never handle it. Verifications bill to **your** Didit balance (top up with `didit.billing_topup`); Pilot adds no markup and sees no credentials. Each new account includes Didit's **500 free full-KYC checks/month**, and account creation, management, sessions CRUD, users, billing, blocklists, questionnaires and webhooks are all **free** \u2014 you only pay per verification you actually run.\n\n## The fast path\n\n1. `didit.signup` `{email:\"you@example.com\"}` \u2192 check that inbox for the code.\n2. `didit.verify` `{code:\"A3K9F2\"}` \u2192 your key is cached.\n3. `didit.create_workflow` `{workflow_label:\"KYC\", features:[{feature:\"OCR\"},{feature:\"LIVENESS\"},{feature:\"FACE_MATCH\"}]}` \u2192 get `uuid`.\n4. `didit.create_session` `{workflow_id, vendor_data:\"user-123\"}` \u2192 send the user to the returned `url`.\n5. `didit.get_decision` `{session_id}` (or a webhook) \u2192 read the Approved/Declined result and extracted data.\n\n## What each area does\n\n- **Sessions** \u2014 hosted flows where the user completes verification at a Didit URL, so you never handle document images: `create_session`, `get_decision`, `list_sessions`, `update_session_status` (approve/decline/resubmit), `delete_session`, `batch_delete_sessions`, `share_session` / `import_session` (B2B KYC reuse), `list_reviews`, `create_review`.\n- **Workflows** \u2014 templates built from an ordered `features` array (`OCR`, `LIVENESS`, `FACE_MATCH`, `AML`, `PROOF_OF_ADDRESS`, `PHONE_VERIFICATION`, `EMAIL_VERIFICATION`, `DATABASE_VALIDATION`, `IP_ANALYSIS`, `AGE_ESTIMATION`, `NFC`, `QUESTIONNAIRE`, `KYB_*`), each with an optional per-feature `config`: `create_workflow`, `list_workflows`, `get_workflow`, `update_workflow`, `delete_workflow`.\n- **Standalone checks (JSON, no session)** \u2014 `aml` (sanctions/PEP/adverse-media, $0.20), `database_validation` (gov sources, from $0.05).\n- **Contact** \u2014 `email_send`/`email_check` ($0.03) and `phone_send`/`phone_check` (from $0.03) OTP verification.\n- **Billing** \u2014 `billing_balance`, `billing_topup` (Stripe checkout URL).\n- **Governance** \u2014 `blocklist_*` (auto-flag repeat faces/docs/phones/emails), `questionnaire_*` (custom forms), `users_*` (people grouped by your `vendor_data`), `get_webhook`/`update_webhook` (set + rotate the HMAC secret programmatically).\n\n## Pricing\n\nPay-per-check on **your** Didit balance \u2014 no Pilot markup. See the full rate card in `didit.help`. Highlights: full KYC bundle **$0.33/check** (first **500/month free**), ID verification $0.15, passive liveness $0.10, face match $0.05, **face search free**, AML $0.20, PoA $0.20, email/phone from $0.03. Image-upload APIs (direct ID scan, liveness, face match, face search, age estimation, PoA) run through the **hosted session** flow rather than as direct methods.\n\n## Notes\n\n- Two hosts, one app: signup uses `apx.didit.me`; everything else uses `verification.didit.me`. The adapter only ever dials Didit and the temp-mail provider used for the one-time OTP.\n- Plain request/response REST \u2014 no websockets, no async jobs. Rate limits: ~600 session-creates/min, 300/min per other method; the account OTP register is 5/IP/hour.\n- Errors surface verbatim: `401` (run `didit.signup` first), `403` (top up credits), `429` (back off).\n", + "app_description": "**Didit is one API for identity and fraud** \u2014 KYC/ID verification, liveness, face match, AML screening, proof of address, database validation, and email/phone OTP, wrapped as a single Pilot app. It fronts Didit's full platform: **hosted verification sessions**, reusable **workflows**, **users**, **billing**, **blocklists**, **questionnaires**, and **webhooks** \u2014 40 methods in all.\n\n## Your own key, minted in one call \u2014 no email, no code\n\nThe hard part of using an identity provider is usually onboarding: signing up, confirming an email code, and wiring the key. This app removes all of it. **`didit.signup` takes no arguments** and returns a working key:\n\n- It signs a keyless request (your Pilot identity) to Pilot's Didit broker. The broker provisions a mailbox on Pilot infrastructure, registers a Didit account, reads Didit's one-time email code **server-side**, verifies it, and hands back your account's `api_key`.\n- The adapter caches `{email, api_key}` to `$APP/secrets.json`. From then on **every other method sends your key as `x-api-key` automatically** \u2014 you never see an inbox, a code, or the key unless you ask (`didit.account`).\n- **Idempotent:** the broker mints at most one Didit account per Pilot identity, so a repeat call \u2014 or a fresh install on another machine \u2014 returns the *same* account. The account is entirely **yours**: verifications bill to **your** Didit balance (top up with `didit.billing_topup`), and Pilot adds no markup. Each account includes Didit's **500 free full-KYC checks/month**; account creation, management, sessions CRUD, users, billing, blocklists, questionnaires and webhooks are all **free** \u2014 you pay only per verification you run.\n\n## The fast path\n\n1. `didit.signup {}` \u2192 your key is cached (one call, ~5s, no email).\n2. `didit.create_workflow` `{workflow_label:\"KYC\", features:[{feature:\"OCR\"},{feature:\"LIVENESS\"},{feature:\"FACE_MATCH\"}]}` \u2192 get `uuid`.\n3. `didit.create_session` `{workflow_id, vendor_data:\"user-123\"}` \u2192 send the user to the returned `url`.\n4. `didit.get_decision` `{session_id}` (or a webhook) \u2192 read the Approved/Declined result and extracted data.\n\n`didit.account` returns your provisioned email + key any time.\n\n## What each area does\n\n- **Sessions** \u2014 hosted flows where the user completes verification at a Didit URL, so you never handle document images: `create_session`, `get_decision`, `list_sessions`, `update_session_status` (approve/decline/resubmit), `delete_session`, `batch_delete_sessions`, `share_session` / `import_session` (B2B KYC reuse), `list_reviews`, `create_review`.\n- **Workflows** \u2014 templates built from an ordered `features` array (`OCR`, `LIVENESS`, `FACE_MATCH`, `AML`, `PROOF_OF_ADDRESS`, `PHONE_VERIFICATION`, `EMAIL_VERIFICATION`, `DATABASE_VALIDATION`, `IP_ANALYSIS`, `AGE_ESTIMATION`, `NFC`, `QUESTIONNAIRE`, `KYB_*`), each with an optional per-feature `config`: `create_workflow`, `list_workflows`, `get_workflow`, `update_workflow`, `delete_workflow`.\n- **Standalone checks (JSON, no session)** \u2014 `aml` (sanctions/PEP/adverse-media, $0.20), `database_validation` (gov sources, from $0.05).\n- **Contact** \u2014 `email_send`/`email_check` ($0.03) and `phone_send`/`phone_check` (from $0.03) OTP verification.\n- **Billing** \u2014 `billing_balance`, `billing_topup` (Stripe checkout URL).\n- **Governance** \u2014 `blocklist_*` (auto-flag repeat faces/docs/phones/emails), `questionnaire_*` (custom forms), `users_*` (people grouped by your `vendor_data`), `get_webhook`/`update_webhook` (set + rotate the HMAC secret programmatically).\n\n## Pricing\n\nPay-per-check on **your** Didit balance \u2014 no Pilot markup. See the full rate card in `didit.help`. Highlights: full KYC bundle **$0.33/check** (first **500/month free**), ID verification $0.15, passive liveness $0.10, face match $0.05, **face search free**, AML $0.20, PoA $0.20, email/phone from $0.03. Image-upload APIs (direct ID scan, liveness, face match, face search, age estimation, PoA) run through the **hosted session** flow rather than as direct methods.\n\n## Notes\n\n- The adapter dials exactly two hosts: Pilot's broker (`broker.pilotprotocol.network`) for the one-call `didit.signup`, and Didit (`verification.didit.me`) for every operational call with your cached key. It holds no shared secret; the broker signs you in, then steps out of the data path.\n- Plain request/response REST \u2014 no websockets, no async jobs. Rate limits: ~600 session-creates/min, 300/min per other method; the account OTP register is 5/IP/hour.\n- Errors surface verbatim: `401` (run `didit.signup` first), `403` (top up credits), `429` (back off).\n", "license": "Proprietary", "homepage": "https://didit.me", "source_url": "https://github.com/didit-protocol/skills", @@ -1281,11 +1249,11 @@ "name": "Didit", "url": "https://didit.me", "contact": "hello@didit.me", - "agent_usage": "An agent onboards once with two calls \u2014 didit.signup {email} then didit.verify {code} (the code Didit emails the user) \u2014 which mints and caches its own Didit API key locally (no broker, no shared key). It then creates a workflow and a hosted session, hands the user the returned URL, and polls didit.get_decision (or receives a webhook) for the Approved/Declined result \u2014 running KYC/ID, liveness, face match, AML, proof-of-address, and email/phone OTP without ever handling document images. Standalone JSON checks (aml, database_validation, email/phone OTP) run directly. Verifications bill to the agent's own Didit balance; account/session/management calls are free.", - "capabilities": "signup + verify (no-broker two-step self-provision of a Didit key with your own email); workflows CRUD; sessions (create/get-decision/list/update-status/delete/batch-delete/share/import/reviews); standalone aml + database_validation; email + phone OTP verification; billing balance + top-up; blocklist add/remove/list; questionnaires CRUD; users list/get/update/batch-delete; webhook get/update. Documents in help: ID verification, NFC, liveness, face match, face search, age estimation, proof of address, KYB, transaction monitoring, wallet screening \u2014 with per-endpoint pricing." + "agent_usage": "An agent onboards with a single argument-less call \u2014 didit.signup {} \u2014 which mints and caches its own Didit API key with no email and no code (Pilot's broker provisions a mailbox, runs Didit's signup, and reads the OTP server-side). It then creates a workflow and a hosted session, hands the user the returned URL, and polls didit.get_decision (or receives a webhook) for the Approved/Declined result \u2014 running KYC/ID, liveness, face match, AML, proof-of-address, and email/phone OTP without ever handling document images. Standalone JSON checks (aml, database_validation, email/phone OTP) run directly. The signup is idempotent per Pilot identity, so a fresh install re-fetches the same account. Verifications bill to the agent's own Didit balance; account/session/management calls are free.", + "capabilities": "signup (one-call broker-minted Didit key \u2014 no email, no code) + account (retrieve the cached email + key); workflows CRUD; sessions (create/get-decision/list/update-status/delete/batch-delete/share/import/reviews); standalone aml + database_validation; email + phone OTP verification; billing balance + top-up; blocklist add/remove/list; questionnaires CRUD; users list/get/update/batch-delete; webhook get/update. Documents in help: ID verification, NFC, liveness, face match, face search, age estimation, proof of address, KYB, transaction monitoring, wallet screening \u2014 with per-endpoint pricing." }, "pricing": { - "model": "Pay-per-check on YOUR Didit balance (BYO key minted by didit.signup + didit.verify \u2014 no Pilot broker, no markup). Account creation, management, sessions/workflows/users/billing/blocklist/questionnaires/webhooks are FREE; you pay only for verifications you run. Every new account includes Didit's 500 free full-KYC checks/month; face search is always free.", + "model": "Pay-per-check on YOUR Didit balance (the key is minted for you by didit.signup in one call \u2014 no markup; Pilot's broker only handles the signup, it does not sit in the data path). Account creation, management, sessions/workflows/users/billing/blocklist/questionnaires/webhooks are FREE; you pay only for verifications you run. Every new account includes Didit's 500 free full-KYC checks/month; face search is always free.", "credit_unit": "USD charged to your Didit balance (top up via didit.billing_topup, min $50)", "free_credits": 0, "cloud_rate_card": { From 0a256b009c5f4acc91c89779feabf4e95dd7b7e1 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Tue, 7 Jul 2026 17:04:29 -0700 Subject: [PATCH 3/5] docs: broker-signup pattern (no-email autonomous provider signup) --- docs/BROKER-SIGNUP.md | 97 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/BROKER-SIGNUP.md diff --git a/docs/BROKER-SIGNUP.md b/docs/BROKER-SIGNUP.md new file mode 100644 index 0000000..d7bfe87 --- /dev/null +++ b/docs/BROKER-SIGNUP.md @@ -0,0 +1,97 @@ +# Broker-side signup — a per-user key with no email, no code + +Some providers gate their API key behind an **email one-time code**: you register +with an email, they send a 6-character code, you confirm it, and only then do you +get a key. That is fine for a human, but it breaks an autonomous agent — and +providers routinely **suppress disposable-mail domains**, so a throwaway inbox +never receives the code. + +The **broker-signup** pattern makes this fully autonomous: `app.signup {}` — no +arguments — returns a working, per-user key. It complements the no-broker +`register` / `verify` flow (where the user brings their own email and pastes the +code); pick whichever fits your provider and your users. + +## Architecture + +Two small services, plus a one-line adapter route: + +``` + user (adapter, keyless) signup broker mail server provider + ─────────────────────── ───────────── ─────────── ──────── + app.signup {} ──signed──▶ 1. provision pilot_@ ──▶ (start accepting) + 2. POST provider register {email,pw} ─────────────────────▶ 201, emails code + 3. provider MTA ── SMTP :25 ─────────▶ receive + store + 4. read the code (control API) ◀───────── + 5. POST provider verify {email,code} ───────────────────────▶ 200 {api_key} + 6. tear the mailbox down + {email, api_key} ◀────── 7. return; adapter caches to secrets.json; ops stay byo +``` + +- **Mail server** (`internal/otpmail`) — a **receive-only** SMTP catch-all for one + domain plus a token-authed control API (`provision` / `otp` / `teardown`). We + only ever *receive*, so there is **no SPF/DKIM/PTR/reputation** to manage — just + an `MX` for the domain and inbound `:25`. It keeps mail only for currently + provisioned addresses, stores it on tmpfs, returns the parsed code once, and + never logs the code or the message body. +- **Signup broker** (`internal/otpsignup`) — a signed HTTP service that runs the + handshake above. It reuses the shared broker's ed25519 caller-identity + verification, is **idempotent per caller** (at most one provider account per + Pilot identity — a repeat call or a fresh install returns the same account), + seals the account password + key **encrypted at rest**, and applies a per-IP + cap so a caller can't farm accounts. It returns the key and does **not** stay in + the data path — operational calls go direct to the provider with the cached key. +- **Adapter** — a `signup: { step: broker }` route signs one keyless call to the + broker and caches `{email, api_key}` to `$APP/secrets.json`; a + `signup: { step: account }` route reads it back. All other methods stay byo + (`x-api-key: ${...}` resolved per request from `secrets.json`). + +## Authoring an app that uses it + +In the submission (or `pilot.app.yaml`): + +```yaml +backend: { type: http, base_url: https://api.provider.example/v1, auth: byo, + headers: { x-api-key: "${PROVIDER_API_KEY}" } } +methods: + - name: myapp.signup # one call, no arguments + latency: slow + signup: { step: broker, broker_url: https://broker.pilotprotocol.network//signup, + secret_key: PROVIDER_API_KEY, email_key: PROVIDER_ACCOUNT_EMAIL } + - name: myapp.account # retrieve the cached account + latency: fast + signup: { step: account, secret_key: PROVIDER_API_KEY, email_key: PROVIDER_ACCOUNT_EMAIL } + # ... your byo operational methods, using ${PROVIDER_API_KEY} in the header ... +``` + +The generator emits the signer + the broker-call runtime and grants `key.sign` +and `net.dial` to the broker host; `fs.read`/`fs.write` on `secrets.json` come +with any signup route. The key is minted at runtime, so the adapter re-resolves +its `${...}` auth headers per request (no restart needed). + +## Deploying the two services + +Both are plain, config-driven binaries (`cmd/otpmail`, `cmd/otpsignup-broker`) — +everything provider- and host-specific is environment configuration, nothing is +compiled in. In broad strokes: + +1. Run `otpmail` on a host with a public IP, and point a **dedicated subdomain's + `MX`** (DNS-only, not proxied — a CDN proxy will not pass `:25`) at it. Do not + touch a domain's apex mail. Its control API listens on a **private** interface, + reachable only by the broker, behind a bearer token. +2. Run `otpsignup-broker` next to your existing broker; point it at the mail + server's control API and the provider's register/verify endpoints, give it a + stable at-rest encryption key, and expose a signed `//signup` route + through your reverse proxy (preserve the request URI so the signed path + matches; forward the real client IP for the per-IP cap). + +See each package's `Config` for the full environment contract. + +## Security notes + +- The mailbox holds a **live code for seconds** — treat it as a secret: tmpfs, + `0600`, delete on read/teardown, never log the code or the body. +- The broker seals the provider password + key **encrypted at rest** and hands + the key to the adapter, which owns it in `secrets.json`. +- Receive-only, relay-denied SMTP — no open relay, no outbound mail. +- Mind the provider's own register rate limit (often per source IP): the broker's + egress IP is the bucket, so throttle mints if volume is high. From 6c1b205d951fe30b47c471e95670f0ae43993ad3 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Tue, 7 Jul 2026 17:11:46 -0700 Subject: [PATCH 4/5] test(otpsignup): use http status constants (staticcheck ST1013) --- internal/otpsignup/broker_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/otpsignup/broker_test.go b/internal/otpsignup/broker_test.go index 5b092b1..dc4febe 100644 --- a/internal/otpsignup/broker_test.go +++ b/internal/otpsignup/broker_test.go @@ -18,7 +18,10 @@ import ( // {application:{api_key}}. mockMail stands in for the otpmail control API. func mockProvider(t *testing.T, key string) *httptest.Server { mux := http.NewServeMux() - mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(201); w.Write([]byte(`{"message":"ok"}`)) }) + mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"message":"ok"}`)) + }) mux.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"application":{"api_key":"` + key + `"}}`)) }) @@ -32,7 +35,7 @@ func mockMail(t *testing.T, code string) *httptest.Server { auth := func(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer mail-tok" { - http.Error(w, "unauth", 401) + http.Error(w, "unauth", http.StatusUnauthorized) return } h(w, r) From 4a5365d236157bc28c16f7e796c44862e29acccd Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Tue, 7 Jul 2026 17:34:54 -0700 Subject: [PATCH 5/5] deploy: run the OTP-signup broker + its nginx route from the managed deploy The signup broker (cmd/otpsignup-broker) and its public nginx route were set up by hand, so a broker redeploy (which regenerates broker.conf) wiped the route. Fold both into startup.sh + setup-broker-tls.sh so redeploys and fresh VMs recreate them: - Build cmd/otpsignup-broker; add a systemd unit that runs ONLY when broker-env metadata carries an OTPSIGNUP_* block (a broker VM without signup skips it). All app/provider/host specifics + secrets come from broker-env metadata; only a non-secret listen/DB default is in the unit. - setup-broker-tls.sh injects operator-provided nginx location blocks (from broker-extra-locations metadata) before the default `location /`, so the signup route survives every config regeneration. No app-specific route is baked into the repo. Validated on the live VM: regenerating broker.conf from scratch re-injects the route and the endpoint answers from the signup broker. --- deploy/setup-broker-tls.sh | 10 ++++++++ deploy/startup.sh | 50 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/deploy/setup-broker-tls.sh b/deploy/setup-broker-tls.sh index 40c745a..4f844af 100755 --- a/deploy/setup-broker-tls.sh +++ b/deploy/setup-broker-tls.sh @@ -64,6 +64,16 @@ server { } } NGINX + # Inject operator-provided extra location blocks (e.g. a signup broker's public + # route → 127.0.0.1:8091) before the default `location /`. Written by startup.sh + # from instance metadata (broker-extra-locations); keeps app-specific routes out + # of the repo. The blocks are inserted verbatim (they should use \$host etc.). + EXTRA="${BROKER_EXTRA_LOCATIONS:-/opt/pilot/broker-extra-locations.conf}" + if [ -s "$EXTRA" ]; then + echo "→ injecting extra nginx locations from $EXTRA" + awk -v f="$EXTRA" '/location \/ \{/ && !d {while ((getline l < f) > 0) print " " l; print ""; d=1} {print}' \ + /etc/nginx/sites-available/broker.conf > /tmp/broker.conf.new && mv /tmp/broker.conf.new /etc/nginx/sites-available/broker.conf + fi ln -sf /etc/nginx/sites-available/broker.conf /etc/nginx/sites-enabled/broker.conf # Remove nginx's default site — it binds :80, which publish-server owns, so # nginx would fail to start. The broker vhost is :443-only. diff --git a/deploy/startup.sh b/deploy/startup.sh index f1d5a56..7b5ca27 100755 --- a/deploy/startup.sh +++ b/deploy/startup.sh @@ -52,8 +52,9 @@ sudo -u pilot HOME=/opt/pilot bash -c ' cd /opt/pilot if [ -d app-template/.git ]; then (cd app-template && git pull --ff-only); else git clone --depth 1 https://github.com/pilot-protocol/app-template; fi cd app-template - go build -o /opt/pilot/publish-server ./cmd/publish-server - go build -o /opt/pilot/broker ./cmd/broker + go build -o /opt/pilot/publish-server ./cmd/publish-server + go build -o /opt/pilot/broker ./cmd/broker + go build -o /opt/pilot/otpsignup-broker ./cmd/otpsignup-broker ' install -d -o pilot -g pilot /opt/pilot/registry # shared: publish-server writes apps.json, broker reads it @@ -127,13 +128,58 @@ RestartSec=3 WantedBy=multi-user.target UNIT +# ── OTP-signup broker (optional) ──────────────────────────────────────────── +# A signed broker that mints a per-user provider key with no email/code (see +# docs/BROKER-SIGNUP.md). It runs only when the operator supplies its config in +# broker-env metadata (an OTPSIGNUP_* block) — so a broker VM that doesn't offer +# signup simply doesn't run it. All app/provider/host specifics live in metadata, +# never here. Its public route is added to nginx via broker-extra-locations +# (below), so nothing app-specific is baked into this script. +if grep -q '^OTPSIGNUP_' /opt/pilot/broker.env 2>/dev/null; then + cat >/etc/systemd/system/otpsignup-broker.service </opt/pilot/broker-extra-locations.conf +else + rm -f /opt/pilot/broker-extra-locations.conf +fi + systemctl daemon-reload systemctl enable pilot-publish pilot-broker +[ -f /etc/systemd/system/otpsignup-broker.service ] && systemctl enable otpsignup-broker # RESTART (not just enable --now): on a reboot/reset systemd auto-starts the # previously-enabled service with the OLD on-disk binary BEFORE this script # rebuilds it. enable --now is then a no-op and the stale binary keeps serving. # An explicit restart loads the freshly-built binary every deploy. systemctl restart pilot-publish pilot-broker +[ -f /etc/systemd/system/otpsignup-broker.service ] && systemctl restart otpsignup-broker echo "pilot-publish + pilot-broker (re)started on freshly built binaries" # Expose the broker over HTTPS via nginx + a Let's Encrypt cert (idempotent;