Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions cmd/otpmail/main.go
Original file line number Diff line number Diff line change
@@ -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())
}
49 changes: 49 additions & 0 deletions cmd/otpsignup-broker/main.go
Original file line number Diff line number Diff line change
@@ -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())
}
10 changes: 10 additions & 0 deletions deploy/setup-broker-tls.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 48 additions & 2 deletions deploy/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <<UNIT
[Unit]
Description=Pilot OTP-signup broker
After=network-online.target
Wants=network-online.target

[Service]
User=pilot
EnvironmentFile=/opt/pilot/broker.env
# Non-secret default; everything else (mail control URL + token, provider URLs,
# domain, at-rest key, caps) comes from broker.env metadata.
Environment=OTPSIGNUP_LISTEN=127.0.0.1:8091
Environment=OTPSIGNUP_DB=/opt/pilot/otpsignup-data/accounts.db
WorkingDirectory=/opt/pilot
ExecStartPre=/usr/bin/install -d -o pilot -g pilot /opt/pilot/otpsignup-data
ExecStart=/opt/pilot/otpsignup-broker
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
UNIT
fi

# Operator-provided extra nginx location blocks (e.g. a signup broker's public
# route → 127.0.0.1:8091), from instance metadata. setup-broker-tls.sh injects
# this file before the default `location /`. Keeps app-specific routes out of the
# repo — the operator supplies them per deployment.
EXTRA_LOCATIONS="$(meta broker-extra-locations)"
if [ -n "$EXTRA_LOCATIONS" ]; then
printf '%s\n' "$EXTRA_LOCATIONS" >/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;
Expand Down
97 changes: 97 additions & 0 deletions docs/BROKER-SIGNUP.md
Original file line number Diff line number Diff line change
@@ -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_<rand>@<domain> ──▶ (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/<app>/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 `/<app>/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.
Loading
Loading