# M401 — Machine Identity Protocol

**Version:** 1.0-draft · **Chain:** ThaiFi 17 · **Status:** draft, implemented by wallet.thaifi.com (IdP), thaifi-wallet-cli, m401/demo-rp

M401 lets a web/API/MCP server (**RP — Relying Party**) ask an AI agent *“who do you act for?”* and get a cryptographically verifiable, **on-chain anchored** answer — the NDID (IdP/RP/AS) model with a blockchain as the platform, and **no Certificate Authority**: the trust anchor is the ThaiFi AccountKeychain precompile (`0xAAAA…0000`, TIP-1011) that already backs agent login.

It is the identity sibling of MPP/x402: **HTTP 402 = payment** (`WWW-Authenticate: Payment …`), **HTTP 401 = identity** (`WWW-Authenticate: M401 …`). Same shape, same muscle memory.

> Not affiliated with Proof's `x401` protocol (W3C VC / OID4VP / Digital Credentials API). M401 is on-chain-key-native and headless-agent-first; see `NDID_MAPPING.md` for the comparison.

---

## 1. Roles

| Role | Who | Responsibility |
|---|---|---|
| **RP** | any API/MCP server the agent calls | issues the 401 challenge, verifies the credential, (recommended) records the prove event on the registry |
| **Agent** | CLI / bot holding a TIP-1011 access key | signs the RP challenge; requests claims from the IdP when the scope needs them |
| **IdP** | wallet.thaifi.com (ThaiFi Wallet) | authenticates the owner (passkey/PIN → AAL 2.1/2.2), obtains consent, issues signed assertions |
| **AS** | phase 1: the IdP itself (wallet D1: email, LINE, aal). Later: third parties | holds the underlying data behind claims |
| **Platform** | ThaiFi chain 17 | AccountKeychain precompile = key↔account binding + revocation; **M401Registry** = append-only audit log |

## 2. Levels

| Level | Name | IdP round-trip | User interaction | RP learns |
|---|---|---|---|---|
| **L0** | direct | no | none | `{account, keyId}` — agent key is active on-chain for that wallet |
| **L1** | attested | yes (automatic) | none | L0 + signed assertion with `aal` claim (from the pairing audit trail) |
| **L2** | consented | yes | **user approves in wallet UI** (passkey/PIN) | L1 + PII claims (`email`, `display_name`) + on-chain `ConsentGranted` |

L0 is self-contained — an RP never needs to talk to the wallet. L1/L2 add IdP-signed assertions for RPs that don't want to run their own RPC or need claims.

## 3. Wire format

### 3.1 Challenge (RP → agent)

Unauthenticated request → RP answers:

```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: M401 realm="api.example.com", challenge="<b64u>", scope="aal email", registry="0x…"
```

| Param | Req | Meaning |
|---|---|---|
| `realm` | yes | RP identifier; MUST be the RP's canonical origin hostname. Bound into the signature hash. |
| `challenge` | yes | b64u of ≥ 32 random bytes, unique per challenge, RP-side TTL ≤ 300 s |
| `scope` | no | space-separated scope tokens the RP wants (§5). Absent = identity only (L0). |
| `registry` | no | M401Registry address the RP records to (informational; RP uses its own) |

### 3.2 Credential (agent → RP)

```
Authorization: M401 <b64u(JSON)>
```

```json
{
  "v": 1,
  "ts": 1762000000,
  "nonce": "<b64u — byte-identical echo of the challenge>",
  "scope": ["aal", "email"],
  "assertion": "<JWS — present only when scope required IdP claims>",
  "signature": "0x…" 
}
```

- `signature` = ox/tempo keychain envelope (`Account.fromP256(agentKey, { access: account }).sign({ hash })`), serialized exactly like `ThaiFiAgent` — the wallet account is bound *inside* the signature.
- `assertion` = IdP JWS (§6) when the scope requested claims beyond identity (`aal`, `email`, `profile`).

**Identity + payment on one request:** when a 401 (M401) was answered and the retried request then draws a 402, the payment retry carries `authorization: <payment credential>` plus the original M401 credential in `x-m401-authorization: M401 <b64u>` — verifiers MUST accept either header for the identity proof.

### 3.3 Signature hash — domain `m401-auth-v1`

Byte-exact layout (mirrors `thaifi-agent-auth` v1; only the tag differs):

```
hash = keccak256(
  keccak256("m401-auth-v1")[0:4]   // 4-byte tag
  ‖ 0x01                            // version
  ‖ keccak256(realm)[0:10]          // 10-byte realm fingerprint
  ‖ nonce                           // 32 bytes decoded from challenge
  ‖ utf8(METHOD)                    // uppercase
  ‖ utf8(pathWithQuery)             // pathname + search, no host
  ‖ sha256(body) | 0x00*32          // ZERO_BODY_HASH when no body
  ‖ uint64be(ts)                    // 8 bytes, unix seconds
)
```

Clock skew: `|now − ts| ≤ 120 s`.

## 4. RP verification procedure (L0)

1. Parse `Authorization: M401 <b64u JSON>`; decode credential.
2. Check `nonce` echoes the challenge it issued (and wasn't used before — RP SHOULD burn it).
3. Check `ts` within 120 s, `scope` ⊆ scope it offered.
4. `SignatureEnvelope.extractAddress({payload: hash, root: true})` → `account`; `extractAddress({payload: hash})` → `keyId` (envelope type `0x04` keychain V2, innerHash = `keccak256(0x04 ‖ hash ‖ account)`).
5. Recompute the hash from its own view (§3.3) — never trust agent-supplied fields.
6. `Actions.accessKey.verifyHash(client, { account, hash, signature, admin: false })` — single eth_call to `0xAAAA…0000`. `true` ⇒ key is active, authorized, not expired. **Revocation is instant and on-chain.**
7. (L1/L2) Verify `assertion` (§6): JWS against IdP JWKS, `aud == realm`, `challenge` echo, `exp`.
8. (Recommended) Record: `proofHash = keccak256(nonce_bytes ‖ signature_bytes)` → `M401Registry.record(proofHash, account, keyId, realmFp)`. RP pays gas (~130 k). Dedupe on-chain makes replays a no-op.

Failure modes (problem-details JSON, scheme-prefixed codes): `m401_bad_credential`, `m401_bad_signature`, `m401_key_inactive` (verifyHash false), `m401_expired`, `m401_replayed`, `m401_scope_denied`, `m401_assertion_invalid`. RPC failure ⇒ `503` with `retry-after`, never a verdict.

## 5. Claims catalog & KYC levels

The RP names **exactly which personal-data claims** it wants, as scope tokens in the challenge. The live catalog is published at `/.well-known/m401.json` (`claims[]` with label/description/`requires_kyc`/`available`) — RPs must read it instead of hardcoding.

**Account KYC tiers** (wallet identity assurance):

| Tier | Means | Backing |
|---|---|---|
| KYC 0 | Email OTP account | email verified at sign-in |
| KYC 1 | LINE account | LINE OAuth identity linked |
| KYC 2 | KYC provider | ThaiD / Sumsub (reserved — flow not wired) |

| Claim | Required KYC | Notes |
|---|---|---|
| `aal` | 0 | consent-free; AAL of pairing (L1) or of the fresh ceremony (L2) + `key_exp` |
| `kyc_level` | 0 | the account's tier (0/1/2) |
| `email` + `email_verified` | 0 | `email_verified: false` when the account has none |
| `display_name`, `provider` | 0 | `provider`: `line` \| `email` |
| `line_id` | **1** | LINE user ID — refused for email accounts |
| `national_id` | **2** | บัตรประชาชน — **reserved**: refused with `m401_claim_unavailable` until the KYC2 flow ships |

Semantics:
- Unknown claim ids → `400 m401_claim_unknown` (+ catalog pointer).
- Claims the account cannot release (tier too low / `available: false`) → `403 m401_claim_unavailable` listing the claims and the account's `kyc_level` — **the wallet refuses before the consent screen**, so the owner is never asked about data that cannot exist.
- Legacy alias `profile` expands to `display_name, provider`.
- Any claim other than `aal` is personal data → the **L2 consent ceremony** applies, the consent screen shows each claim's human-readable description, and the on-chain `ConsentGranted` scopeHash binds the exact claim list.
- RPs MUST reject credentials that lack an IdP assertion when the offered scope includes personal-data claims (the SDK enforces this: `m401_assertion_required`).

## 6. IdP assertion (L1/L2)

JWS compact serialization (`b64u(header).b64u(payload).b64u(sig)`), `alg: ES256` (P-256 + SHA-256 — standard JWS, *not* keccak), signed by the wallet's IdP key (`M401_IDP_KEY` secret; public JWK published at `/.well-known/m401.json` with `kid` for rotation).

```json
{
  "typ": "JWT", "alg": "ES256", "kid": "m401-idp-1"
}
{
  "iss": "https://wallet.thaifi.com",
  "aud": "api.example.com",
  "sub": "0x…(wallet account)",
  "key": "0x…(agent keyId)",
  "challenge": "<b64u echo of the RP challenge>",
  "scope": ["aal"],
  "claims": { "aal": "2.2", "key_exp": 1780000000 },
  "consent_id": "mc_…",          // L2 only
  "iat": 1762000000,
  "exp": 1762000900              // +900 s
}
```

### IdP endpoints (wallet.thaifi.com)

| Endpoint | Auth | Purpose |
|---|---|---|
| `GET /.well-known/m401.json` | public | discovery: issuer, chain_id, registry, keychain, JWKS, endpoints, scopes |
| `POST /api/m401/requests` | `ThaiFiAgent` (existing TAA) | body `{realm, challenge, scope[]}` → L1: `200 {status:"issued", assertion}`; L2: `202 {status:"pending_consent", request_id, approve_url}` |
| `GET /api/m401/requests/:id` | public (opaque id) | poll → `issued` (with assertion) / `pending_consent` / `denied` / `expired` |
| `POST /api/m401/consents/approve` | session cookie + PIN proof | user approval → issue assertion + `M401Registry.recordConsent(...)` |

### L2 ceremony

1. Agent receives `approve_url` = `https://wallet.thaifi.com/m401?id=<request_id>`.
2. Wallet UI shows: agent name (from the pairing), RP realm, the exact scope, what will be released. Approving runs the **full AAL ceremony** (same evidence contract as agent pairing): the wallet unlock ceremony — passkey with user verification ⇒ **AAL 2.2**, PIN-guard wallet ⇒ **2.1** — plus the Security-PIN proof (memorized secret, server-verified, bound to the request id). Denying needs no ceremony.
3. The assertion's `aal` claim reflects the **ceremony AAL** (fresh evidence — overrides the pairing-time AAL). Evidence is stored in D1 `m401_requests`.
4. Wallet records `ConsentGranted(pseudonym, realmFp, scopeHash)` on the registry — `pseudonym` = pairwise id `keccak256(userId ‖ realm)[0:20]` (cross-RP unlinkable; the chain never sees the email), `scopeHash` = `keccak256(space-joined scope)`.
5. Poll returns the assertion; agent retries the RP request with it embedded.

## 7. Security considerations

- **Replay:** RP burns the challenge after one use; on-chain `proofHash` dedupe makes re-recording a no-op; assertion `exp` is 900 s.
- **Revocation:** L0/L1 die instantly at `verifyHash` when the owner revokes the key (on-chain). L2 assertions expire in ≤ 15 min; RPs needing instant L2 revocation call `GET /api/m401/consents/:id` (planned) or check `ConsentGranted` recency.
- **Phishing/wrong-RP:** `realm` is bound into the signature hash and into the assertion `aud` — a credential minted for one RP is worthless at another.
- **Privacy:** chain stores only keccak fingerprints + pairwise pseudonyms; PII travels agent↔RP inside the JWS, never on-chain.
- **No CA:** the only trust anchors are (a) the chain (key authorization), (b) the wallet IdP key published at a `.well-known` URL with `kid` rotation. No intermediate authorities.
- **Agent cannot exceed scope:** the assertion's `scope` is checked by the RP against the challenge it issued; the wallet refuses scopes the user hasn't approved (`m401_scope_denied`).

## 8. Gas model

Measured on chain 17 (2026-11-02): `M401Registry.record` ≈ 1.29 M gas, `recordConsent` ≈ 0.27 M — chain gas runs ~10× a local forge simulation, budget accordingly. At baseFee 237,857,380,271 a prove record costs ≈ **0.0003 pathTHB**. Paid by whoever benefits from the record (RP for proves; wallet for consents). Recording is OPTIONAL for RPs but REQUIRED for RPs claiming auditability.

**Registry (mainnet 17):** `0x561128da7e3be6bb967807d488B1161FF398f5A7` — verified at contracts.thaifi.com, deploy tx `0x625054ca45c854a6813be417e78b8b285abb01bbbd962edc3f764e35c3a770ba`.
