# RFC-0007 — `POST /v1/devices/enroll` — Public Self-Service Device Onboarding

- **Current revision:** **RATIFIED v1.1 — 2026-04-23** (see §V11 below). v1.0 body preserved verbatim below §V11 as historical baseline.

---

## §V11. v1.1 — Client-side enroll-token handling, enrollment-response pubkey persistence, `E_INVALID_CLIENT_ASSERTION`

- **Status:** **RATIFIED v1.1 — 2026-04-23.** CHAIR-APPROVED.
- **Author:** 🧠 Agentic Architect.
- **Sprint:** pre-Sprint-3 (synthesis of pre-S3 audit, paired with RFC-0011 v1.0).
- **Amends:** v1.0 §3 (request schema — adds normative client-side handling rules), §4 (response schema — adds gateway-side pubkey persistence obligation), §8 (error surface — adds `E_INVALID_CLIENT_ASSERTION`), §9 (audit — extends `decision_class` enum).
- **Does not touch:** v1.0 §1 motivation, §2 endpoint, §5 enroll-token semantics, §6 idempotency, §7 rate limit, §10 fixtures, §11 out-of-scope, §12 cross-refs (additive only via §V11.4).

### §V11.1 Client-side `enroll_token` handling (NORMATIVE)

The `enroll_token` is a Secret-class asset (RFC-0011 §3). The client (installer / `aiot-edge`) MUST handle it under the following rules:

- **Source:** `enroll_token` MUST be ingested via **environment variable** (`AIOT_ENROLL_TOKEN`) **or stdin only**. It MUST NOT be accepted as a CLI positional argument or `--flag` value. Rationale: positional/flag args appear in `/proc/$PID/cmdline` (world-readable on default Linux), in shell history, and in process-listing output for any local user.
- **In-memory storage:** the token MUST be held in a **zeroizing** memory buffer (Rust: `secrecy::SecretString` or equivalent `Zeroize`-on-drop type) for its entire lifetime.
- **Disk persistence:** the token MUST NOT be written to disk. The client SHOULD set `prctl(PR_SET_DUMPABLE, 0)` on Linux and run under `umask 0o077`.
- **Lifetime:** the token MUST be dropped from memory immediately after the `POST /v1/devices/enroll` round-trip completes. Retry logic MUST re-read from the original env-var/stdin source.
- **Logging:** the token value MUST NOT appear in any log at any level. Audit/log surfaces MAY record `sha256(enroll_token)[:16]` only.

### §V11.2 Enrollment response — gateway-side pubkey persistence (NORMATIVE)

On a successful `/v1/devices/enroll` call, the gateway MUST, **atomically with `node_id` minting**, persist the device-supplied `public_key_pem` to a new D1 column:

```sql
ALTER TABLE devices ADD COLUMN enroll_pubkey_pem TEXT NOT NULL;
```

- **Column:** `devices.enroll_pubkey_pem` — TEXT, NOT NULL, byte-identical to the request `public_key_pem`.
- **Use:** every subsequent `client-assertion` JWT presented by this `node_id` MUST be EdDSA-signature-verified against this stored pubkey.
- **No upgrade-path-from-leaf-cert:** verifiers MUST NOT re-derive the verification key from the runtime-presented leaf certificate; the persisted `enroll_pubkey_pem` is canonical (TOFU at enroll).
- **Failure mode:** verification failure → `E_INVALID_CLIENT_ASSERTION` (§V11.3), HTTP 401, audit-logged.

### §V11.3 Error surface extension

v1.0 §8 closed enum is extended by ONE code; no v1.0 codes change:

| Code                            | HTTP | Trigger                                                                         |
|---------------------------------|-----:|---------------------------------------------------------------------------------|
| `E_INVALID_CLIENT_ASSERTION`    |  401 | EdDSA verify of `client-assertion` JWT against `devices.enroll_pubkey_pem` failed; OR `node_id` unknown; OR `client-assertion` malformed. |

Body: `{"error":{"code":"E_INVALID_CLIENT_ASSERTION","message":"enrollment denied"}}` (no-reason-leak per RFC-0003 §V13.4).

### §V11.4 §5 (JWT-vs-opaque) clarification

v1.0 §5 ratification stands: the `enroll_token` is opaque on the wire. v1.1 adds: clients MUST NOT base64url-decode or otherwise inspect the token.

### §V11.5 Cross-references (additive)

- RFC-0011 v1.0 §2.3, §3, §5.1, §7 (defense map → `E_INVALID_CLIENT_ASSERTION`).
- RFC-0003 v1.4 §V13.1 (`client-assertion` token class — verifier rule clarified by §V11.2).
- RFC-0003 §V13.10 footnote (audit IP-hashing) applies to v1.0 §9 `source_ip` column.

### §V11.6 Handoff (v1.1)

- **Next persona:** ☁️ cloudflare-native-edge for the D1 migration (`0003_enroll_pubkey.sql`) and gateway verifier wiring; 🛡️ devex-protocol-sec for client-side enforcement of §V11.1.
- **Artifact:** D1 migration file + Vitest fixtures for `E_INVALID_CLIENT_ASSERTION` (forged client-assertion → 401, audit row written).
- **Gate:** COMPLETED — ratified 2026-04-23.

---

## v1.0 — historical baseline (preserved verbatim)

# RFC-0007 — `POST /v1/devices/enroll` — Public Self-Service Device Onboarding

- **Status:** **DRAFT — pending CTO ratification.** Version `v1.0`.
- **Author:** 🧠 Agentic Architect
- **Sprint:** 2 (KO 2026-04-22; supports 🛡️ Sprint-2 Item 1 — `curl | sh` installer).
- **Audience:** Cloud Agents (LLMs); 🛡️ devex-protocol-sec (installer + endpoint impl); ☁️ cloudflare-native-edge (Worker route + D1 persistence).
- **Depends on:** RFC-0001 v1.3 (`node_id` ULID format; `node_attestation` shape), RFC-0003 v1.3 (§V13.1 agent/device-runtime token shape; §V13.2 JWKS; §V13.4 no-reason-leak doctrine).
- **Does not modify:** RFC-0001 v1.3, RFC-0003 v1.3. Per CEO Q1, no served-RFC body rewrites this dispatch.
- **Scope:** Wire contract for the public, unauthenticated-bearer-but-token-gated endpoint a fresh device hits exactly once at boot to mint its first `(node_id, leaf_cert, runtime_token)` triple.

---

## §1. Motivation

The Sprint-2 `curl | sh` installer needs ONE network round-trip to take a device from "blank disk + enroll-token in environment" to "WSS-connected, RFC-0002 envelope-speaking node." That round-trip is `POST /v1/devices/enroll`. Every other onboarding path (operator console, fleet bulk-import) reduces to minting an enroll-token and shipping it to the device — the device-side wire format is identical and is fully specified here.

---

## §2. Endpoint

- **Method:** `POST`
- **Path:** `/v1/devices/enroll` (gateway origin: `https://gateway-{env}.aethermesh.app`)
- **Request `Content-Type`:** `application/json; charset=utf-8`
- **Response `Content-Type`:** `application/json; charset=utf-8`
- **Auth:** none at TLS / HTTP layer. The bearer of authority is the `enroll_token` carried in the request body (see §3, §5).
- **Idempotency:** see §6.

---

## §3. Request Schema (JSON Schema, Draft 2020-12)

`schema_ref`: `mcp://schemas/enroll.request@1.0.0`

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "mcp://schemas/enroll.request@1.0.0",
  "type": "object",
  "additionalProperties": false,
  "required": ["enroll_token", "hardware_attestation", "public_key_pem"],
  "properties": {
    "enroll_token": {
      "type": "string",
      "minLength": 32,
      "maxLength": 512,
      "pattern": "^[A-Za-z0-9._\\-]+$",
      "description": "Opaque short-lived single-use credential. Format is issuer-internal (NOT a JWT on the wire); verifier treats as opaque lookup key. See §5."
    },
    "hardware_attestation": {
      "type": "object",
      "additionalProperties": false,
      "required": ["cpu_arch", "os_kernel", "sysinfo_summary"],
      "properties": {
        "cpu_arch": {
          "type": "string",
          "enum": ["x86_64", "aarch64", "armv7l", "riscv64"]
        },
        "os_kernel": {
          "type": "string",
          "maxLength": 128,
          "pattern": "^[\\x20-\\x7E]+$",
          "description": "Uname-style kernel id, e.g. 'Linux 6.6.0-aiot1'. ASCII clamp per RFC-0001 v1.1 locked decision #7."
        },
        "sysinfo_summary": {
          "type": "object",
          "additionalProperties": false,
          "required": ["cpu_count", "mem_total_bytes"],
          "properties": {
            "cpu_count":       { "type": "integer", "minimum": 1, "maximum": 4096 },
            "mem_total_bytes": { "type": "integer", "minimum": 33554432 }
          }
        }
      }
    },
    "public_key_pem": {
      "type": "string",
      "minLength": 80,
      "maxLength": 1024,
      "pattern": "^-----BEGIN PUBLIC KEY-----\\n[A-Za-z0-9+/=\\n]+-----END PUBLIC KEY-----\\n?$",
      "description": "Device-generated Ed25519 public key (PKIX SubjectPublicKeyInfo, PEM). Private key never leaves the device."
    }
  }
}
```

`additionalProperties: false` at every level; LLM-generated installer scripts that invent fields are rejected at the boundary.

---

## §4. Response Schema (JSON Schema, Draft 2020-12)

`schema_ref`: `mcp://schemas/enroll.response@1.0.0`

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "mcp://schemas/enroll.response@1.0.0",
  "type": "object",
  "additionalProperties": false,
  "required": ["node_id", "leaf_cert_pem", "runtime_token", "runtime_token_exp_ms", "gateway_uri", "jwks_uri"],
  "properties": {
    "node_id": {
      "type": "string",
      "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$",
      "description": "Crockford-ULID, uppercase per RFC-0001 v1.3 §2 node_id constraint."
    },
    "leaf_cert_pem": {
      "type": "string",
      "minLength": 256,
      "maxLength": 8192,
      "pattern": "^-----BEGIN CERTIFICATE-----\\n[A-Za-z0-9+/=\\n]+-----END CERTIFICATE-----\\n?$",
      "description": "Test-CA-signed Ed25519 leaf cert per RFC-0001 v1.1 locked decision #1. SHA-256 thumbprint becomes the device's stable kid."
    },
    "runtime_token": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+$",
      "description": "JWT, EdDSA-signed via gateway JWKS, class=device-runtime per RFC-0003 v1.3 §V13.1. Carries scopes [device:connect] only."
    },
    "runtime_token_exp_ms": {
      "type": "integer",
      "minimum": 1700000000000,
      "description": "Absolute expiry (Unix ms). Mirrors JWT `exp` × 1000. Provided so the device need not parse the JWT to schedule rotation."
    },
    "gateway_uri": {
      "type": "string",
      "pattern": "^wss://[A-Za-z0-9.\\-]+(:[0-9]+)?/v1/wss$",
      "description": "WSS endpoint per RFC-0002 v2.2 §1."
    },
    "jwks_uri": {
      "type": "string",
      "pattern": "^https://[A-Za-z0-9.\\-]+(:[0-9]+)?/\\.well-known/jwks\\.json$",
      "description": "Per RFC-0003 v1.3 §V13.2."
    }
  }
}
```

---

## §5. Enroll-Token Semantics (NORMATIVE constraints; issuance flow OUT OF SCOPE)

- **TTL:** ≤ 24h from mint. Server MUST reject with `E_ENROLL_TOKEN_INVALID` if expired.
- **Single-use:** first successful `/v1/devices/enroll` consumes the token. Subsequent uses → `E_ENROLL_TOKEN_EXHAUSTED` (UNLESS the request is idempotent per §6).
- **Scope:** internal scope `device:enroll` (already in RFC-0003 v1.3 §4 closed enum). MUST NOT carry any other scope.
- **Tenant binding:** the token internally encodes the target tenant; the minted `node_id` is bound to that tenant in D1 atomically with consumption.
- **Issuance flow** (operator console → enroll-token mint API): **Sprint 3.** This RFC fixes only the device-facing wire shape.

---

## §6. Idempotency

- **Dedup key:** `sha256(enroll_token || public_key_pem)`.
- **Window:** 24h.
- A repeat request with the same dedup key MUST return the same response body byte-for-byte (same `node_id`, same `leaf_cert_pem`, same `runtime_token` if not yet expired; if `runtime_token` is expired, a fresh runtime_token MAY be minted but `node_id` and `leaf_cert_pem` MUST be identical).
- A request with the same `enroll_token` but a different `public_key_pem` MUST be rejected with `E_ENROLL_TOKEN_EXHAUSTED` (the token was already bound to a different key).

---

## §7. Rate Limiting (advisory; 🛡️ enforces in impl)

- **Limit:** ≤ 5 enrollments per minute per source IP (CF `request.headers["cf-connecting-ip"]`).
- **Window:** sliding 60 s.
- **Excess:** `E_ENROLL_RATE_LIMITED`, HTTP 429, `Retry-After` header REQUIRED, integer seconds, in [1, 300].
- **Scope:** per-IP only at v1.0; per-tenant rate limiting deferred to Sprint 3.

---

## §8. Error Surface (closed enum; uniform per RFC-0003 v1.3 §V13.4)

| Code                            | HTTP | Trigger                                                                         |
|---------------------------------|-----:|---------------------------------------------------------------------------------|
| `E_ENROLL_TOKEN_INVALID`        |  401 | Token unknown, malformed, expired, or signature/lookup fails.                   |
| `E_ENROLL_TOKEN_EXHAUSTED`      |  409 | Token already consumed by a non-idempotent prior request.                       |
| `E_ENROLL_ATTESTATION_REJECTED` |  422 | `hardware_attestation` fails policy (e.g. cpu_arch not on tenant allow-list).   |
| `E_ENROLL_RATE_LIMITED`         |  429 | §7 limit exceeded. `Retry-After` header REQUIRED.                               |
| `E_INTERNAL`                    |  500 | Any other failure. No detail leaked beyond this code.                           |

Response body shape on error (matches RFC-0001 v1.3 §4 envelope):

```json
{ "error": { "code": "E_ENROLL_TOKEN_INVALID", "message": "enrollment denied" } }
```

`message` is byte-exact `"enrollment denied"` for codes 401/409/422; `"rate limited"` for 429; `"internal"` for 500. **No reason leak beyond these classes.** ASCII-only per RFC-0001 v1.1 locked decision #7.

---

## §9. Audit

Every attempt — success and failure — MUST write one row to `audit_log` (D1 / migration `0002_audit.sql`). Required columns (closed):

- `ts_ms` (int)
- `source_ip` (string, ipv4 or ipv6 textual)
- `enroll_token_sha256` (hex, 64 chars — NEVER the raw token)
- `public_key_sha256` (hex, 64 chars)
- `decision` (enum: `allow`, `deny`)
- `decision_class` (enum: `ok`, `E_ENROLL_TOKEN_INVALID`, `E_ENROLL_TOKEN_EXHAUSTED`, `E_ENROLL_ATTESTATION_REJECTED`, `E_ENROLL_RATE_LIMITED`, `E_INTERNAL`)
- `node_id` (string, nullable — present on `allow` only)

---

## §10. Test Fixtures

| # | request                                                                                            | expected_response_shape                                                                                  |
|---|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
| 1 | Valid `enroll_token` (fresh, ≤24h), well-formed `public_key_pem`, attestation OK.                  | HTTP 200, body matches §4 schema; audit `decision=allow, decision_class=ok, node_id=<ULID>`.             |
| 2 | `enroll_token` expired (>24h since mint).                                                          | HTTP 401, `{"error":{"code":"E_ENROLL_TOKEN_INVALID","message":"enrollment denied"}}`; audit `deny`.     |
| 3 | Replay: same `(enroll_token, public_key_pem)` as fixture 1, within 24h.                            | HTTP 200, response body **byte-identical** to fixture 1 (idempotency, §6); audit `allow` (second row).   |
| 4 | Same `enroll_token` as fixture 1, **different** `public_key_pem`.                                  | HTTP 409, `{"error":{"code":"E_ENROLL_TOKEN_EXHAUSTED","message":"enrollment denied"}}`; audit `deny`.   |
| 5 | 6th request from same source IP within 60 s window.                                                | HTTP 429, `Retry-After: <1..300>`, body `{"error":{"code":"E_ENROLL_RATE_LIMITED","message":"rate limited"}}`. |

---

## §11. Out of Scope (Explicit)

- **Enroll-token issuance flow** (operator-side mint API). Sprint 3.
- **Leaf-cert revocation** / CRL / OCSP-stapled response. Sprint 3.
- **Device de-enrollment** (`DELETE /v1/devices/{node_id}`). Backlog.
- **Per-tenant rate limiting.** Sprint 3 (§7 is per-IP only at v1.0).
- **Hardware attestation cryptographic verification** (TPM quote, PSA attestation token). v1.0 accepts self-reported `hardware_attestation` and audits it; cryptographic attestation is a future RFC.
- **Bulk enrollment** (single token → N devices). Out of scope; would require dropping single-use semantic.

---

## §12. Cross-References

- RFC-0001 v1.3 §1 (discovery; `node_id` format), §4 (error envelope shape), v1.1 locked decision #1 (test CA & leaf-cert thumbprint = kid), locked decision #7 (ASCII clamp).
- RFC-0003 v1.3 §V13.1 (`device-runtime` token class), §V13.2 (`jwks_uri`), §V13.4 (no-reason-leak), §4 (`device:enroll` and `device:connect` scopes).
- RFC-0002 v2.2 §1 (WSS endpoint shape).

---

## §13. Handoff

- **Next persona:** 🛡️ devex-protocol-sec.
- **Artifact:** Implementation plan (`plan-installer-wss.md` revision) reflecting §3/§4 schemas verbatim, §6 idempotency dedup-key derivation in D1, §7 per-IP rate limiter (CF Workers KV or DO counter), §8 error surface, §9 audit columns, §10 fixtures wired into Vitest under `workers/mcp-gateway/tests/`.

## v2 Cross-Reference (additive 2026-05-03)

RFC-0012, RFC-0013, RFC-0014 (v1.0, 2026-05-03): supersede portions of this RFC. v3.0 amendment scheduled end-of-sprint-3-extended (2026-05-30).
