Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/guides/testing-on-device.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 43 additions & 8 deletions docs/guides/trusted-demo-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,50 @@ 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.

## What "trusted" covers today — and what it doesn't

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

Expand All @@ -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.
13 changes: 12 additions & 1 deletion examples/quickstart/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 41 additions & 28 deletions tools/demo-pki/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,41 +156,54 @@ curl -sD - -o /dev/null https://<host>/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.

**Check it without a phone:** `node tools/demo-pki/verify-reader-trust.mjs` — proves the
cert rides in `x5c`, the signature verifies under it, the origin matches, and the RICAL
names that cert.

## 6. Import trust + credentials on the phone

Open the deployed site on the phone → import the **VICAL + RICAL first** (so cards
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.
11 changes: 6 additions & 5 deletions tools/demo-pki/run-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:`);
Expand Down
Loading