Skip to content

Commit d01fa23

Browse files
AlexgodorojaAlex Godoroja
andauthored
Broker-side autonomous signup + io.pilot.didit one-call signup (#77)
* feat(otpmail,otpsignup,scaffold): broker-side autonomous signup 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. * submit: io.pilot.didit → one-call broker signup (no email) 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. * docs: broker-signup pattern (no-email autonomous provider signup) * test(otpsignup): use http status constants (staticcheck ST1013) --------- Co-authored-by: Alex Godoroja <alex@vulturelabs.io>
1 parent fd0e8c5 commit d01fa23

17 files changed

Lines changed: 1786 additions & 93 deletions

File tree

cmd/otpmail/main.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Command otpmail runs the receive-only OTP mail server (internal/otpmail):
2+
// a catch-all SMTP receiver for one domain plus a token-authed control API the
3+
// signup broker drives. All configuration comes from the environment — nothing
4+
// provider- or host-specific is compiled in.
5+
//
6+
// OTPMAIL_DOMAIN the mail domain this host is MX for (required)
7+
// OTPMAIL_TOKEN bearer token the control API requires (required)
8+
// OTPMAIL_MAILDIR message dir, point at tmpfs (default /var/otpmail)
9+
// OTPMAIL_SMTP_ADDR public SMTP listen addr (default :25)
10+
// OTPMAIL_CONTROL_ADDR private control-API listen addr (default 127.0.0.1:8025)
11+
package main
12+
13+
import (
14+
"log"
15+
"os"
16+
"strconv"
17+
18+
"github.com/pilot-protocol/app-template/internal/otpmail"
19+
)
20+
21+
func main() {
22+
maxBytes, _ := strconv.Atoi(os.Getenv("OTPMAIL_MAX_MSG_BYTES"))
23+
srv, err := otpmail.New(otpmail.Config{
24+
Domain: os.Getenv("OTPMAIL_DOMAIN"),
25+
Token: os.Getenv("OTPMAIL_TOKEN"),
26+
Maildir: os.Getenv("OTPMAIL_MAILDIR"),
27+
SMTPAddr: os.Getenv("OTPMAIL_SMTP_ADDR"),
28+
ControlAddr: os.Getenv("OTPMAIL_CONTROL_ADDR"),
29+
MaxMsgBytes: maxBytes,
30+
})
31+
if err != nil {
32+
log.Fatalf("otpmail: %v", err)
33+
}
34+
log.Fatal(srv.ListenAndServe())
35+
}

cmd/otpsignup-broker/main.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Command otpsignup-broker runs the signed OTP-signup broker
2+
// (internal/otpsignup): it mints a per-user provider API key with no email input
3+
// from the user, driving the receive-only mail server (internal/otpmail) for the
4+
// OTP. Configuration is entirely from the environment — provider- and
5+
// host-specifics live in the deployment, not the binary.
6+
//
7+
// OTPSIGNUP_LISTEN HTTP listen addr (default 127.0.0.1:8090)
8+
// OTPSIGNUP_MAIL_CONTROL_URL mail server control-API base (internal)
9+
// OTPSIGNUP_MAIL_TOKEN bearer token for the mail control API
10+
// OTPSIGNUP_MAIL_DOMAIN the mail domain addresses are minted under
11+
// OTPSIGNUP_ADDR_PREFIX localpart prefix (default pilot_)
12+
// OTPSIGNUP_REGISTER_URL provider register endpoint
13+
// OTPSIGNUP_VERIFY_URL provider verify-email endpoint
14+
// OTPSIGNUP_KEY_PATH dotted path to the key (default application.api_key)
15+
// OTPSIGNUP_DB sqlite ledger path
16+
// OTPSIGNUP_ENC_KEY 64-hex (32-byte) key sealing secrets at rest
17+
// OTPSIGNUP_MAX_IDS_PER_IP per-IP distinct-caller cap (0 = unlimited)
18+
package main
19+
20+
import (
21+
"log"
22+
"os"
23+
"strconv"
24+
25+
"github.com/pilot-protocol/app-template/internal/otpsignup"
26+
)
27+
28+
func main() {
29+
maxIP, _ := strconv.Atoi(os.Getenv("OTPSIGNUP_MAX_IDS_PER_IP"))
30+
b, err := otpsignup.New(otpsignup.Config{
31+
Listen: os.Getenv("OTPSIGNUP_LISTEN"),
32+
MailControlURL: os.Getenv("OTPSIGNUP_MAIL_CONTROL_URL"),
33+
MailToken: os.Getenv("OTPSIGNUP_MAIL_TOKEN"),
34+
MailDomain: os.Getenv("OTPSIGNUP_MAIL_DOMAIN"),
35+
AddrPrefix: os.Getenv("OTPSIGNUP_ADDR_PREFIX"),
36+
RegisterURL: os.Getenv("OTPSIGNUP_REGISTER_URL"),
37+
VerifyURL: os.Getenv("OTPSIGNUP_VERIFY_URL"),
38+
KeyPath: os.Getenv("OTPSIGNUP_KEY_PATH"),
39+
DBPath: os.Getenv("OTPSIGNUP_DB"),
40+
EncKeyHex: os.Getenv("OTPSIGNUP_ENC_KEY"),
41+
MaxIdentitiesPerIP: maxIP,
42+
})
43+
if err != nil {
44+
log.Fatalf("otpsignup-broker: %v", err)
45+
}
46+
b.SetLogger(log.New(os.Stderr, "otpsignup ", log.LstdFlags|log.LUTC))
47+
log.Printf("otpsignup-broker listening on %s", os.Getenv("OTPSIGNUP_LISTEN"))
48+
log.Fatal(b.ListenAndServe())
49+
}

docs/BROKER-SIGNUP.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Broker-side signup — a per-user key with no email, no code
2+
3+
Some providers gate their API key behind an **email one-time code**: you register
4+
with an email, they send a 6-character code, you confirm it, and only then do you
5+
get a key. That is fine for a human, but it breaks an autonomous agent — and
6+
providers routinely **suppress disposable-mail domains**, so a throwaway inbox
7+
never receives the code.
8+
9+
The **broker-signup** pattern makes this fully autonomous: `app.signup {}` — no
10+
arguments — returns a working, per-user key. It complements the no-broker
11+
`register` / `verify` flow (where the user brings their own email and pastes the
12+
code); pick whichever fits your provider and your users.
13+
14+
## Architecture
15+
16+
Two small services, plus a one-line adapter route:
17+
18+
```
19+
user (adapter, keyless) signup broker mail server provider
20+
─────────────────────── ───────────── ─────────── ────────
21+
app.signup {} ──signed──▶ 1. provision pilot_<rand>@<domain> ──▶ (start accepting)
22+
2. POST provider register {email,pw} ─────────────────────▶ 201, emails code
23+
3. provider MTA ── SMTP :25 ─────────▶ receive + store
24+
4. read the code (control API) ◀─────────
25+
5. POST provider verify {email,code} ───────────────────────▶ 200 {api_key}
26+
6. tear the mailbox down
27+
{email, api_key} ◀────── 7. return; adapter caches to secrets.json; ops stay byo
28+
```
29+
30+
- **Mail server** (`internal/otpmail`) — a **receive-only** SMTP catch-all for one
31+
domain plus a token-authed control API (`provision` / `otp` / `teardown`). We
32+
only ever *receive*, so there is **no SPF/DKIM/PTR/reputation** to manage — just
33+
an `MX` for the domain and inbound `:25`. It keeps mail only for currently
34+
provisioned addresses, stores it on tmpfs, returns the parsed code once, and
35+
never logs the code or the message body.
36+
- **Signup broker** (`internal/otpsignup`) — a signed HTTP service that runs the
37+
handshake above. It reuses the shared broker's ed25519 caller-identity
38+
verification, is **idempotent per caller** (at most one provider account per
39+
Pilot identity — a repeat call or a fresh install returns the same account),
40+
seals the account password + key **encrypted at rest**, and applies a per-IP
41+
cap so a caller can't farm accounts. It returns the key and does **not** stay in
42+
the data path — operational calls go direct to the provider with the cached key.
43+
- **Adapter** — a `signup: { step: broker }` route signs one keyless call to the
44+
broker and caches `{email, api_key}` to `$APP/secrets.json`; a
45+
`signup: { step: account }` route reads it back. All other methods stay byo
46+
(`x-api-key: ${...}` resolved per request from `secrets.json`).
47+
48+
## Authoring an app that uses it
49+
50+
In the submission (or `pilot.app.yaml`):
51+
52+
```yaml
53+
backend: { type: http, base_url: https://api.provider.example/v1, auth: byo,
54+
headers: { x-api-key: "${PROVIDER_API_KEY}" } }
55+
methods:
56+
- name: myapp.signup # one call, no arguments
57+
latency: slow
58+
signup: { step: broker, broker_url: https://broker.pilotprotocol.network/<app>/signup,
59+
secret_key: PROVIDER_API_KEY, email_key: PROVIDER_ACCOUNT_EMAIL }
60+
- name: myapp.account # retrieve the cached account
61+
latency: fast
62+
signup: { step: account, secret_key: PROVIDER_API_KEY, email_key: PROVIDER_ACCOUNT_EMAIL }
63+
# ... your byo operational methods, using ${PROVIDER_API_KEY} in the header ...
64+
```
65+
66+
The generator emits the signer + the broker-call runtime and grants `key.sign`
67+
and `net.dial` to the broker host; `fs.read`/`fs.write` on `secrets.json` come
68+
with any signup route. The key is minted at runtime, so the adapter re-resolves
69+
its `${...}` auth headers per request (no restart needed).
70+
71+
## Deploying the two services
72+
73+
Both are plain, config-driven binaries (`cmd/otpmail`, `cmd/otpsignup-broker`) —
74+
everything provider- and host-specific is environment configuration, nothing is
75+
compiled in. In broad strokes:
76+
77+
1. Run `otpmail` on a host with a public IP, and point a **dedicated subdomain's
78+
`MX`** (DNS-only, not proxied — a CDN proxy will not pass `:25`) at it. Do not
79+
touch a domain's apex mail. Its control API listens on a **private** interface,
80+
reachable only by the broker, behind a bearer token.
81+
2. Run `otpsignup-broker` next to your existing broker; point it at the mail
82+
server's control API and the provider's register/verify endpoints, give it a
83+
stable at-rest encryption key, and expose a signed `/<app>/signup` route
84+
through your reverse proxy (preserve the request URI so the signed path
85+
matches; forward the real client IP for the per-IP cap).
86+
87+
See each package's `Config` for the full environment contract.
88+
89+
## Security notes
90+
91+
- The mailbox holds a **live code for seconds** — treat it as a secret: tmpfs,
92+
`0600`, delete on read/teardown, never log the code or the body.
93+
- The broker seals the provider password + key **encrypted at rest** and hands
94+
the key to the adapter, which owns it in `secrets.json`.
95+
- Receive-only, relay-denied SMTP — no open relay, no outbound mail.
96+
- Mind the provider's own register rate limit (often per source IP): the broker's
97+
egress IP is the bucket, so throttle mints if volume is high.

0 commit comments

Comments
 (0)