diff --git a/docs/guides/testing-on-device.md b/docs/guides/testing-on-device.md index 8b6f834..e11ac1e 100644 --- a/docs/guides/testing-on-device.md +++ b/docs/guides/testing-on-device.md @@ -74,7 +74,7 @@ badge. **Pass criteria:** the picker offers the right card, the order completes, and **no red trust warnings** appear — issuer (via the imported **VICAL**) *and* verifier (via the imported -**RICAL** matched against the reader identity the gate now presents, #51). +**RICAL** matched against the reader identity the gate presents). > **The verifier side needs the reader *private key*, which is not committed.** `run-gate.mjs` > configures `readerIdentity` **only if `keys/reader-key.pem` exists locally** — and that key is diff --git a/docs/guides/trusted-demo-credentials.md b/docs/guides/trusted-demo-credentials.md index eae20d4..331156f 100644 --- a/docs/guides/trusted-demo-credentials.md +++ b/docs/guides/trusted-demo-credentials.md @@ -42,6 +42,12 @@ The demo credentials + trust lists are served from a static page at The set: a driver license (mDL, carries `age_over_21` and `age_over_65`), a digital payment instrument, a membership, and a professional license. +> **A RICAL only clears the verifier warning if the gate holds the matching reader private +> key.** The list names a reader; the gate has to sign as that reader. Import a list whose +> reader key nobody has and nothing changes — the warning stays, and the list is not at +> fault. Check which reader identity your gate presents (`readerIdentity`, above) before +> concluding a trust list is broken. + Next: **[Testing on a device](testing-on-device.md)** walks the import mechanics (including the `adb` fallback) and running a full ceremony. @@ -49,15 +55,37 @@ Next: **[Testing on a device](testing-on-device.md)** walks the import mechanics Two independent trust anchors, distributed as signed lists: -| Anchor | List | Clears the warning… | Status | +| Anchor | List | Clears the warning… | What it takes | | :-- | :-- | :-- | :-- | -| **Issuer** (who signed the card) | VICAL | "untrusted issuer" when holding the card | ✅ works now | -| **Verifier** (who's asking) | RICAL | "unknown verifier" at presentation | ⏳ needs the gate to present the demo reader identity — [#51](https://github.com/openmobilehub/credentagent/issues/51) | - -So today, importing the VICAL makes your cards show as trusted. Importing the RICAL -prepares the *verifier* side, but the "unknown verifier" warning won't fully clear until -the gate is wired to present the matching reader identity (#51) — the gate currently -self-signs an ephemeral reader cert per request, which nothing on the RICAL matches. +| **Issuer** (who signed the card) | VICAL | "untrusted issuer" when holding the card | import the VICAL — that's all | +| **Verifier** (who's asking) | RICAL | "unknown verifier" at presentation | import the RICAL **and** run a gate that signs with the matching reader key | + +Importing the VICAL makes your cards show as trusted, on its own. + +The verifier side takes two halves that have to match. The wallet needs the RICAL — the +list naming which verifiers to trust. The **gate** needs the reader private key behind +that list, so it can sign each request as a reader on it. Give the gate that key and the +"unknown verifier" warning clears; leave it out and the gate self-signs a throwaway +certificate per request, which nothing on any RICAL matches. + +The gate takes the key through one option: + +```js +new CredentAgent({ + walletOrigin: "http://localhost:3007", + readerIdentity: { + key: readFileSync("reader-key.pem", "utf8"), // private — signs the request + cert: readFileSync("reader-cert.pem", "utf8"), // public — the wallet matches this to the RICAL + }, +}); +``` + +> **You have to generate that key yourself.** This repo ships a reader *certificate* and a +> RICAL built around it, but the matching private key is deliberately never committed — +> and without it, that certificate can't sign anything. The shipped pair is a worked +> reference, not a usable identity. Producing your own means regenerating the PKI, which +> also invalidates the shipped credentials and both trust lists — see the producer path +> below for what that costs. ## Producer path — build your own @@ -67,6 +95,13 @@ page). The one decision that matters is the reader certificate's host (`READER_D it must match wherever your **gate** is served (`localhost` for local testing); see that README's "Choose the reader SAN" section. +**Budget for the whole chain, not just the key.** `gen-pki.sh` mints a fresh issuer root +and reader root, so everything signed by the old ones — the `.mpzpass` credentials, the +VICAL, the RICAL — is dead the moment you run it. Clearing the "unknown verifier" warning +therefore means: regenerate the PKI → re-mint the credentials → rebuild both trust lists → +re-import all of it on the phone → point the gate at the new `reader-key.pem`. There is no +shortcut that skips a step, because each artifact is signed by the one above it. + > **For agents:** this pipeline is being packaged as a `demo-pki` skill > ([#53](https://github.com/openmobilehub/credentagent/issues/53)) so it runs in one > step with the host as an input. Until then, execute the README steps directly. diff --git a/examples/quickstart/server.mjs b/examples/quickstart/server.mjs index e8af15a..7a3eeee 100644 --- a/examples/quickstart/server.mjs +++ b/examples/quickstart/server.mjs @@ -25,7 +25,18 @@ const grantCatalog = Object.fromEntries( SAMPLE_CATALOG.map((p) => [p.id, { price: p.price, category: p.category, ...(p.minimumAge ? { minAge: p.minimumAge } : {}) }]), ); // The grants resource lives on the CredentAgent, so construct it BEFORE the storefront wires it in. -const credentagent = new CredentAgent({ walletOrigin, catalog: grantCatalog }); +// The reader identity this gate presents (#51). Supplied, it signs each wallet request as a +// reader named on a trust list (RICAL) the wallet imported, so the wallet resolves the +// verifier instead of warning that the site asking for the data is unknown. Absent, the gate +// self-signs a throwaway cert per request — the ceremony still completes, the wallet just +// shows the verifier as unknown. The cert's SubjectAltName MUST include this origin's host. +// Only the READER key belongs on a verifier: no issuer key is involved, so a popped gate can +// impersonate this reader and nothing else. +const readerIdentity = + process.env.CREDENTAGENT_READER_KEY && process.env.CREDENTAGENT_READER_CERT + ? { key: process.env.CREDENTAGENT_READER_KEY, cert: process.env.CREDENTAGENT_READER_CERT } + : undefined; +const credentagent = new CredentAgent({ walletOrigin, catalog: grantCatalog, ...(readerIdentity ? { readerIdentity } : {}) }); const store = createStorefront({ signingKey: process.env.GATE_SECRET, diff --git a/tools/demo-pki/README.md b/tools/demo-pki/README.md index c20384c..98c4d5b 100644 --- a/tools/demo-pki/README.md +++ b/tools/demo-pki/README.md @@ -156,33 +156,57 @@ curl -sD - -o /dev/null https:///credentials/mdl.mpzpass | grep -i content ## 5. Wire the verifier (gate) — present the reader identity -For the wallet to see the gate as a *trusted verifier* (not just "presence"), the -gate must **authenticate as the reader on the RICAL** — sign its OpenID4VP / DC-API -request with the reader key and present the reader cert chain. - -**The gate needs exactly two files from here — and only these:** - -| Copy to the gate | Role | -|------------------|------| -| `keys/reader-key.pem` | **private** — the gate ES256-signs the request JWT + ISO `ReaderAuthAll` with it | -| `certs/reader-cert.pem` | public — rides in the `x5c` / `x5chain` header so the wallet matches it to the RICAL | - -`certs/reader-root-cert.pem` (public) too, only if you present the full chain -`[reader-cert, reader-root]`. **Keep every other key off the gate** — `ds-key` / -`iaca-key` mint credentials, `reader-root-key` mints readers, `list-signer-key` -forges trust lists; none belong on the verifier, so a compromised gate can only -impersonate the demo reader, nothing more. - -> **Pending code hook (#51).** The gate today mints an *ephemeral self-signed* -> reader cert per request (`makeReaderCert` in -> `packages/credentagent-gate/src/ceremony/mdoc/reader.ts`, and -> `makeMdocReaderCert` in `.../mdoc/mdoc-iso.ts`), so there is **not yet** a config -> point to inject these files — the RICAL match won't happen until that lands. -> Tracked in **#51**: load `reader-key.pem` + `reader-cert.pem` from env/config -> instead of self-signing. Until then, step 5 is documented intent, not a working -> knob. - -Reminder: the gate's serving origin must match a name in the reader SAN (step 1). +Three files, two destinations. All three are needed; any one alone does nothing. + +| File (from step 1 / step 3) | Goes to | Role | +|------|---------|------| +| `certs/reader-cert.pem` | the gate | what it **presents** (rides in `x5c` / `x5chain`) | +| `keys/reader-key.pem` | the gate | what it **signs** with | +| `out/utopia.rical` | the wallet | the list the wallet checks the cert against | + +Never put the RICAL on the gate — it never reads one. Never put any other key there: +`ds-key` / `iaca-key` mint credentials, `list-signer-key` forges trust lists. + +**Gate:** + +```js +new CredentAgent({ + walletOrigin: "http://localhost:3007", + readerIdentity: { + key: readFileSync("reader-key.pem", "utf8"), + cert: readFileSync("reader-cert.pem", "utf8"), + }, +}); +``` + +On a hosted deployment, pass the file contents as environment variables. Omit +`readerIdentity` and the gate self-signs per request — the ceremony still completes, the +wallet just shows the verifier as unknown. + +**Wallet:** Multipaz → Settings → Trust manager → add entry → import the RICAL. Remove any +previously imported RICAL first. + +> A RICAL is only as good as the key behind it: it names a reader, and some gate has to +> sign as that reader. A list whose reader key nobody holds cannot clear the warning — +> which is why the RICAL and the reader key must be built and distributed together. + +The gate's serving origin must match a name in the reader cert's SAN (`READER_DNS`, +step 1). Change it and you must rebuild the RICAL (step 3) and re-import it. On Vercel the +gate's `walletOrigin` is `https://${VERCEL_PROJECT_PRODUCTION_URL}`, and this repo's +shipped cert covers only `credentagent-demo.vercel.app` + `localhost`. Deploy under a +**custom production domain** and that host isn't on the SAN, so the "unknown verifier" +warning never clears — yet a SAN mismatch only logs a `console.warn` at boot (it does +**not** fail the gate), so check the startup log if a deploy you expected to fix it +doesn't. **Preview** URLs are a separate trap: `VERCEL_PROJECT_PRODUCTION_URL` stays the +*production* host, so a ceremony opened on a preview deployment is bound to the wrong +origin and won't clear either — and this one logs nothing. + +**Check it without a phone** — first `./gen-pki.sh` (mints the reader **private** key, +which is gitignored) and build the gate (`cd packages/credentagent-gate && npm run build`, +which produces the un-committed `dist/`), then run +`node tools/demo-pki/verify-reader-trust.mjs`. It proves the cert rides in `x5c`, the +signature verifies under it, the origin matches, and the RICAL names that cert. A bare +clone can't run it — both inputs are absent by design. ## 6. Import trust + credentials on the phone @@ -190,7 +214,7 @@ Open the deployed site on the phone → import the **VICAL + RICAL first** (so c land already-trusted) → then open each **`.mpzpass`**. Then run a ceremony and confirm **no red trust warning**. Detailed device steps + the adb fallback live in the guide: [`docs/guides/testing-on-device.md`](../../docs/guides/testing-on-device.md) -(and the verifier-warning caveat is #51). +— including why the verifier warning survives a fresh clone. **Done when:** a ceremony (e.g. the `age_over_65` senior-discount flow) completes against the gate with no red issuer *or* verifier warning. diff --git a/tools/demo-pki/run-gate.mjs b/tools/demo-pki/run-gate.mjs index c0702ac..1250644 100644 --- a/tools/demo-pki/run-gate.mjs +++ b/tools/demo-pki/run-gate.mjs @@ -31,10 +31,9 @@ const BASE = `http://localhost:${PORT}`; // as untrusted. The trust setup is an upgrade, never a prerequisite. const keyPath = join(HERE, "keys/reader-key.pem"); const certPath = join(HERE, "certs/reader-cert.pem"); -const readerIdentity = - existsSync(keyPath) && existsSync(certPath) - ? { key: readFileSync(keyPath, "utf8"), cert: readFileSync(certPath, "utf8") } - : undefined; +const hasKey = existsSync(keyPath); +const hasCert = existsSync(certPath); +const readerIdentity = hasKey && hasCert ? { key: readFileSync(keyPath, "utf8"), cert: readFileSync(certPath, "utf8") } : undefined; // The REAL storefront — catalog, cart/order stores, checkout page, place-order, cart // mandate, completion, MCP tools. We inject an in-memory created-order store so we can @@ -82,7 +81,9 @@ console.log(`\nCredentAgent demo gate (real storefront) → ${BASE}`); console.log( readerIdentity ? ` reader identity : certs/reader-cert.pem (SAN=localhost, on utopia.rical → verifier shows TRUSTED)` - : ` reader identity : none — self-signed per request. Ceremony still works; wallet shows the\n verifier as UNTRUSTED (red). Run ./gen-pki.sh + import utopia.rical to fix.`, + : hasCert + ? ` reader identity : none — certs/reader-cert.pem is here, but keys/reader-key.pem is NOT.\n A cert can't sign without its key, so the gate self-signs per request and the\n wallet shows the verifier as UNTRUSTED (red). The committed cert + utopia.rical\n are a reference pair; the key is gitignored and never shipped.\n To fix: ./gen-pki.sh, then re-mint the credentials and rebuild BOTH trust lists\n (gen-pki mints new roots, orphaning the shipped ones) and re-import on the phone.` + : ` reader identity : none — no demo PKI here. Self-signed per request; ceremony still works,\n wallet shows the verifier as UNTRUSTED (red). Run ./gen-pki.sh to mint one.`, ); console.log(` seeded order : ORD-DEMO (1× ${restricted.name}, age ${restricted.minimumAge ?? "n/a"}+)\n`); console.log(`Full checkout flow (age → pay → done) — one link:`);