diff --git a/README.md b/README.md index 1a6ece8..b1f9fbd 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,64 @@ your own callback. > rejected, check that Minister's `MINISTER_ISSUER_DOMAIN` host matches its > OIDC issuer host. +## Sybil gating with the per-RP nullifier + +Some badges carry a **gating nullifier** — `verifiedBadge.nullifier`, an opaque +`mnv1:...` string typed as `MinisterGatingNullifier`. When present, it is a +per-relying-party Sybil-dedup tag Minister derives from the *credential* behind +the badge (the email, the GitHub account) and stamps inside the signed +`credentialSubject` at disclosure. Use it to enforce "one credential, one +account" on your side (dedup, persistent bans): + +```ts +const badge = badges.find((b) => b.type === "oauth-account"); +if (badge?.nullifier) { + // Stable per (credential, YOUR site). Ban or dedup keyed on this value. + await gateOn(badge.nullifier); +} +``` + +Its guarantees, precisely: + +- **Per-site and unlinkable.** Your site sees a value derived for your + `client_id`; other sites get a *different, unlinkable* tag for the same + credential. It is not a cross-site correlator. +- **Stable and persistent.** The *same* value appears if any account re-proves + the same credential to your site — and it **survives the user deleting and + re-creating their Minister account**, so a ban keyed on it persists across that + churn. +- **Bound under the signature.** It rides in the same signed VC as the badge, + tied to this badge's subject, `jti`, type, and `exp`, so it cannot be lifted + onto another credential or replayed as another user. The existing binding rule + still holds: the wrappers (`verifyBadges` / `exchangeCode` / + `ministerBadgesFromProfile`) require the badge subject's trailing component to + equal the `id_token` `sub`, so a borrowed badge lands in `rejected`. +- **Absent ⇒ untagged.** Badges with no wired nullifier (invite-code, + age/residency, and any pre-M5 disclosure) expose `nullifier: undefined`; + gate accordingly. A present-but-malformed value fails the badge closed. + +**Honesty: this proves one credential, not one person.** Its strength is only +the strength of the credential behind it — see each badge type's +`sybilResistance` (`none` / `weak` / `moderate`). A `weak` anchor (catch-all +email domains, cheap GitHub accounts) is cheap to farm; weight it yourself. +Never treat the nullifier as a unique-human oracle. + +### Two nullifier primitives, permanently distinct + +This value is **not** interchangeable with `@ministryofmany/nullifier`'s +Poseidon/BN254 nullifier, and there is no conversion between them: + +| | `@ministryofmany/nullifier` | `MinisterGatingNullifier` (this) | +| --- | --- | --- | +| Math | Poseidon / BN254 | RFC 9497 VOPRF + HMAC-SHA256 | +| Anchor | the per-RP `sub` (account) | the credential (email, GitHub id) | +| Circuit-usable | yes (SNARK-provable; membership/RLN) | no (gating-only, plaintext compare) | +| Catches | same account across contexts | same credential across accounts | + +The branded type stops the two from being mixed at compile time. A future +circuit-usable *credential* nullifier must be a new Poseidon construction, never +a bridge from this gating value. + ## API | Export | Purpose | diff --git a/dist/auth-js.d.ts b/dist/auth-js.d.ts index 39ad0b7..e62820e 100644 --- a/dist/auth-js.d.ts +++ b/dist/auth-js.d.ts @@ -1,6 +1,6 @@ import { OIDCConfig } from '@auth/core/providers'; import { JWTPayload } from 'jose'; -import { K as KeyInput, B as BadgesResult } from './types-B-nDX3ql.js'; +import { K as KeyInput, B as BadgesResult } from './types-C8FYcOBP.js'; interface MinisterProviderOptions { clientId: string; diff --git a/dist/auth-js.js b/dist/auth-js.js index d56b98a..7152547 100644 --- a/dist/auth-js.js +++ b/dist/auth-js.js @@ -1,6 +1,6 @@ import { verifyMinisterBadges -} from "./chunk-AIB6IEP6.js"; +} from "./chunk-FTCBESCF.js"; import "./chunk-JS6T4O33.js"; // src/auth-js.ts diff --git a/dist/chunk-AIB6IEP6.js.map b/dist/chunk-AIB6IEP6.js.map deleted file mode 100644 index 7707fcd..0000000 --- a/dist/chunk-AIB6IEP6.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["../src/did.ts","../src/errors.ts","../src/verify-badge.ts","../src/jwt.ts","../src/verify-id-token.ts","../src/verify-badges.ts"],"sourcesContent":["// did:web identifier for a domain. Per the W3C did:web spec the DID\n// document is served at https:///.well-known/did.json. Mirrors\n// `@ministryofmany/vc`'s `buildDid` so the VC `iss` this SDK expects matches\n// exactly what Minister stamps when issuing.\nexport function buildDid(domain: string): string {\n return `did:web:${domain}`;\n}\n\n// Derive the issuer DID from a Minister origin (the OIDC `issuer`).\n// Minister signs VCs with `iss = did:web:` where is the\n// origin's host (including a non-default port, encoded per did:web as\n// `host%3Aport`). e.g. \"https://ministry.id\" -> \"did:web:ministry.id\".\n//\n// Explicit + total: a path-bearing issuer is REJECTED rather than silently\n// dropped. Minister's did:web is host-only, so an issuer with a path would\n// make the derived DID diverge and every badge fail closed with no signal —\n// fail loud at config time instead. `issuer` is trusted RP config (set once),\n// so throwing here surfaces a misconfiguration, never attacker input.\nexport function didFromIssuer(issuer: string): string {\n const url = new URL(issuer);\n if (url.pathname !== \"\" && url.pathname !== \"/\") {\n throw new Error(\n `Minister issuer must be an origin with no path (got path \"${url.pathname}\" in \"${issuer}\")`,\n );\n }\n if (url.search !== \"\" || url.hash !== \"\") {\n throw new Error(`Minister issuer must be an origin with no query or fragment: \"${issuer}\"`);\n }\n // did:web encodes a port by percent-encoding the colon.\n const host = url.port ? `${url.hostname}%3A${url.port}` : url.hostname;\n return buildDid(host);\n}\n\n// Build the per-RP PAIRWISE subject DID Minister stamps into a disclosed\n// badge: `did:web::u:`, where is the issuer host and \n// is the id_token pairwise subject. The wrapper uses this to bind a badge to\n// the login (badge `subject` must equal the value this returns for the\n// id_token `sub`).\nexport function buildPairwiseSubjectDid(issuer: string, sub: string): string {\n return `${didFromIssuer(issuer)}:u:${sub}`;\n}\n\n// Parse a pairwise subject DID back into { issuerDid, sub }, or null when it\n// does not match the `did:web:<...>:u:` shape. Explicit `:u:` handling —\n// must be a single, non-empty, colon-free trailing segment — so this is\n// total and never silently mis-splits a path-bearing DID.\nexport function parsePairwiseSubjectDid(\n subject: string,\n): { issuerDid: string; sub: string } | null {\n const marker = \":u:\";\n const idx = subject.lastIndexOf(marker);\n if (idx <= 0) return null;\n const issuerDid = subject.slice(0, idx);\n const sub = subject.slice(idx + marker.length);\n if (!issuerDid.startsWith(\"did:web:\") || sub.length === 0 || sub.includes(\":\")) return null;\n return { issuerDid, sub };\n}\n","// Thrown when a verifiable-credential badge fails verification — bad\n// signature, wrong issuer, malformed envelope, or a subject-binding\n// mismatch. Mirrors `@ministryofmany/vc`'s `VcVerificationError`.\n// Its message may include VC-derived text; do not reflect it to untrusted output.\nexport class VcVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"VcVerificationError\";\n }\n}\n\n// Thrown when the OIDC flow fails for a non-token reason - missing client\n// config, discovery, the token-exchange request, or a malformed token\n// response. id_token verification failures throw MinisterTokenError;\n// individual bad badges are reported in BadgesResult.rejected, not thrown.\nexport class OidcError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"OidcError\";\n }\n}\n\n// Thrown when an id_token itself fails verification - signature, issuer,\n// audience, expiry, or nonce. The token is the trust root, so this is a\n// hard failure (distinct from an individual bad badge, which is reported\n// in BadgesResult.rejected rather than thrown).\n// Its message may include token-derived text; do not reflect it to untrusted output.\nexport class MinisterTokenError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MinisterTokenError\";\n }\n}\n","import { importJWK, type JWK, type JWTVerifyGetKey, type KeyLike } from \"jose\";\n\nimport { badgeTypeOf, getBadgeClaimSchema } from \"./badges/helpers\";\nimport { didFromIssuer } from \"./did\";\nimport { verifyJwt } from \"./jwt\";\nimport { VcVerificationError } from \"./errors\";\nimport type { KeyInput, VerifiedBadge } from \"./types\";\n\n// Verify a Minister-issued verifiable credential against Minister's PUBLIC badge\n// key. Unlike `@ministryofmany/vc`'s `verifyVc` (which threads a full Issuer\n// carrying the private key), an RP only ever holds public material.\n//\n// Badge keys are pinned to the issuer's DID document `assertionMethod` — NOT the\n// raw JWKS. Minister's JWKS at /.well-known/jwks.json serves BOTH the badge\n// signing key (#key-2) AND the in-process token key (#key-3); jose selects a key\n// by the JWT `kid`, so verifying a badge against the full JWKS would accept a VC\n// carrying `kid ...#key-3` — i.e. one forged with a stolen token key — defeating\n// the KMS split. The DID document's `assertionMethod` lists ONLY #key-2, so we\n// resolve badge keys from it and REJECT any `kid` not listed there. That keeps\n// the token key from ever attesting a badge.\n\n// did:web DID document, restricted to the fields we consume.\ninterface DidVerificationMethod {\n id?: unknown;\n publicKeyJwk?: unknown;\n}\ninterface DidDocumentShape {\n verificationMethod?: unknown;\n assertionMethod?: unknown;\n}\n\n// Cache one assertionMethod key resolver per issuer for the process lifetime.\n// Cache key is the trusted RP-config issuer (not request input), so this stays\n// bounded. Each resolver memoizes the fetched-and-imported key map internally and\n// clears it on failure, so a transient did.json fetch error self-heals on the\n// next call rather than permanently wedging badge verification.\nconst didResolverCache = new Map();\n\nfunction assertionResolverFor(issuer: string): JWTVerifyGetKey {\n let resolver = didResolverCache.get(issuer);\n if (!resolver) {\n resolver = createDidAssertionResolver(issuer);\n didResolverCache.set(issuer, resolver);\n }\n return resolver;\n}\n\n// Fetch /.well-known/did.json and build a `kid -> public key` map from\n// its `assertionMethod` — the ONLY keys allowed to verify a badge VC.\nasync function loadAssertionKeys(\n issuer: string,\n): Promise> {\n const url = `${issuer}/.well-known/did.json`;\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(`DID document fetch failed (${res.status}) for ${url}`);\n }\n const doc = (await res.json()) as DidDocumentShape;\n\n const vms = Array.isArray(doc.verificationMethod) ? doc.verificationMethod : [];\n const byId = new Map();\n for (const vm of vms) {\n if (\n vm &&\n typeof vm === \"object\" &&\n typeof (vm as DidVerificationMethod).id === \"string\"\n ) {\n byId.set((vm as DidVerificationMethod).id as string, vm as DidVerificationMethod);\n }\n }\n\n const assertion = Array.isArray(doc.assertionMethod) ? doc.assertionMethod : [];\n if (assertion.length === 0) {\n throw new Error(\"DID document has no `assertionMethod` entries\");\n }\n\n const map = new Map();\n for (const entry of assertion) {\n // Per W3C, an assertionMethod entry is either a string reference to a\n // verificationMethod `id` or an embedded verification method object.\n let vm: DidVerificationMethod | undefined;\n if (typeof entry === \"string\") {\n vm = byId.get(entry);\n } else if (entry && typeof entry === \"object\") {\n vm = entry as DidVerificationMethod;\n }\n const kid = vm && typeof vm.id === \"string\" ? vm.id : undefined;\n const jwk = vm?.publicKeyJwk;\n if (!kid || !jwk || typeof jwk !== \"object\") {\n throw new Error(\n `assertionMethod entry has no resolvable publicKeyJwk: ${String(\n typeof entry === \"string\" ? entry : (vm?.id ?? \"\"),\n )}`,\n );\n }\n map.set(kid, await importJWK(jwk as JWK, \"EdDSA\"));\n }\n return map;\n}\n\nfunction createDidAssertionResolver(issuer: string): JWTVerifyGetKey {\n let keysPromise: Promise> | undefined;\n const load = () => {\n if (!keysPromise) {\n // Don't cache a REJECTED fetch: a transient did.json outage would\n // otherwise wedge badge verification for the process lifetime.\n keysPromise = loadAssertionKeys(issuer).catch((err) => {\n keysPromise = undefined;\n throw err;\n });\n }\n return keysPromise;\n };\n return async (protectedHeader) => {\n const keys = await load();\n const kid = protectedHeader.kid;\n if (typeof kid !== \"string\" || kid.length === 0) {\n throw new Error(\"badge JWT has no `kid`; cannot pin to DID assertionMethod\");\n }\n const key = keys.get(kid);\n if (!key) {\n throw new Error(\n `badge kid (${kid}) is not in the issuer DID document assertionMethod`,\n );\n }\n return key;\n };\n}\n\nexport interface VerifyBadgeOptions {\n // Minister origin, e.g. \"https://ministry.id\".\n issuer: string;\n // Inject the verification key. Defaults to the issuer's DID assertionMethod\n // key set (kid-pinned to #key-2). Pass a public JWK in tests so verification\n // never touches the network.\n key?: KeyInput;\n}\n\n// Verify a received VC JWT (standalone).\n//\n// Beyond `@ministryofmany/vc`'s structural checks, this asserts the VC-INTERNAL\n// invariant `credentialSubject.id === payload.sub` — that the claims are bound\n// to the subject the issuer signed them for. It does NOT (and cannot) bind the\n// badge to any LOGIN: there is no id_token here, so nothing ties this VC to the\n// user in front of you. A valid Minister badge belonging to some OTHER user\n// (e.g. one received via a share link) verifies successfully. Treating a\n// standalone `verifyMinisterBadge` success as \"the current user holds this\n// badge\" is an authorization bug.\n//\n// The holder-to-login binding lives in the wrapper (`verifyMinisterBadges`),\n// which requires `subject === did:web::u:`. Use the wrapper\n// for any access decision; use this only to certify issuance of an out-of-band\n// VC.\nexport async function verifyMinisterBadge(\n vcJwt: string,\n options: VerifyBadgeOptions,\n): Promise {\n const issuer = options.issuer.replace(/\\/$/, \"\");\n const expectedIss = didFromIssuer(issuer);\n const key = options.key ?? assertionResolverFor(issuer);\n\n let payload;\n try {\n const result = await verifyJwt(vcJwt, key, {\n issuer: expectedIss,\n algorithms: [\"EdDSA\"],\n typ: \"vc+jwt\",\n requiredClaims: [\"exp\"],\n });\n payload = result.payload;\n } catch (cause) {\n throw new VcVerificationError(\n cause instanceof Error ? cause.message : String(cause),\n );\n }\n\n if (typeof payload.sub !== \"string\" || payload.sub.length === 0) {\n throw new VcVerificationError(\"VC payload missing string `sub`\");\n }\n\n const vc = payload.vc as\n | { type?: unknown; credentialSubject?: unknown }\n | undefined;\n if (!vc || typeof vc !== \"object\") {\n throw new VcVerificationError(\"VC payload missing `vc` envelope\");\n }\n if (!Array.isArray(vc.type) || !vc.type.every((t) => typeof t === \"string\")) {\n throw new VcVerificationError(\"VC `type` must be a string array\");\n }\n if (\n !vc.credentialSubject ||\n typeof vc.credentialSubject !== \"object\" ||\n Array.isArray(vc.credentialSubject)\n ) {\n throw new VcVerificationError(\"VC missing `credentialSubject` object\");\n }\n\n const credentialSubject = vc.credentialSubject as Record;\n const subjectId = credentialSubject[\"id\"];\n if (typeof subjectId !== \"string\" || subjectId.length === 0) {\n throw new VcVerificationError(\"VC `credentialSubject.id` missing\");\n }\n\n // Holder-binding invariant: the JWT subject must equal the credential\n // subject the issuer signed. (Additional check beyond @ministryofmany/vc.)\n if (subjectId !== payload.sub) {\n throw new VcVerificationError(\n \"VC `credentialSubject.id` does not match `sub`\",\n );\n }\n\n // Map the VC type to a known Minister badge slug.\n const slug = badgeTypeOf(vc.type as string[]);\n if (!slug) {\n throw new VcVerificationError(\n `Unknown Minister badge type: ${(vc.type as string[]).join(\",\")}`,\n );\n }\n\n // Validate the claims against that badge type's schema. Two RESERVED keys\n // are stripped first: `id` (redundant with `sub`) and `issuanceMonth`\n // (Minister's coarse-issuance metadata, stamped at disclosure re-mint —\n // cross-cutting VC metadata, never a per-type claim; stripping it keeps\n // strict per-type schemas like tlsn-attestation passing).\n const {\n id: _id,\n issuanceMonth: rawIssuanceMonth,\n ...rawClaims\n } = credentialSubject;\n\n // Strictly format-check the coarse issuance bucket when present. A\n // malformed value means issuer drift or a claim-shaped smuggle upstream of\n // the signature — fail closed rather than hand policy code a garbage\n // timestamp. Absent is fine (legacy Minister); downstream freshness checks\n // then fail closed on maxAgeDays.\n let issuanceMonth: string | undefined;\n if (rawIssuanceMonth !== undefined) {\n if (\n typeof rawIssuanceMonth !== \"string\" ||\n !/^\\d{4}-(0[1-9]|1[0-2])$/.test(rawIssuanceMonth)\n ) {\n throw new VcVerificationError(\n \"VC `credentialSubject.issuanceMonth` is not a YYYY-MM UTC month\",\n );\n }\n issuanceMonth = rawIssuanceMonth;\n }\n\n const schema = getBadgeClaimSchema(slug);\n let claims: Record;\n try {\n claims = schema\n ? (schema.parse(rawClaims) as Record)\n : rawClaims;\n } catch (cause) {\n throw new VcVerificationError(\n `Badge ${slug} claims failed validation: ${\n cause instanceof Error ? cause.message : String(cause)\n }`,\n );\n }\n\n return {\n type: slug,\n claims,\n subject: payload.sub,\n ...(issuanceMonth !== undefined ? { issuanceMonth } : {}),\n raw: vcJwt,\n };\n}\n\n// Test seam: drop the cached DID assertionMethod key resolver for an issuer (or\n// all issuers).\nexport function _resetBadgeKeyCache(issuer?: string): void {\n if (issuer) didResolverCache.delete(issuer.replace(/\\/$/, \"\"));\n else didResolverCache.clear();\n}\n","import {\n importJWK,\n jwtVerify,\n type JWK,\n type JWTPayload,\n type JWTVerifyOptions,\n type JWTVerifyResult,\n} from \"jose\";\n\nimport type { KeyInput } from \"./types\";\n\n// A bare JWK is a plain object carrying `kty`. A resolved `KeyLike`\n// (CryptoKey/KeyObject) has `type` but never `kty`; a `Uint8Array` and a\n// resolver function are not plain objects with `kty`. So this is total and\n// unambiguous over the `KeyInput` union.\nfunction isJwk(key: KeyInput): key is JWK {\n return (\n typeof key === \"object\" &&\n key !== null &&\n !(key instanceof Uint8Array) &&\n typeof (key as JWK).kty === \"string\"\n );\n}\n\n// jose's `jwtVerify` is overloaded: one signature takes a resolved key\n// (KeyLike/Uint8Array/JWK), the other a key-resolver function (e.g.\n// createRemoteJWKSet). `KeyInput` is the union of both. Narrow on the key\n// shape and dispatch to the right call so the rest of the SDK can pass a single\n// injectable key source — including a raw JWK, which we import ourselves\n// (pinned to EdDSA, the only alg Minister signs with) so a caller never has to.\nexport async function verifyJwt(\n jwt: string,\n key: KeyInput,\n options: JWTVerifyOptions,\n): Promise> {\n if (typeof key === \"function\") {\n return jwtVerify(jwt, key, options);\n }\n const resolved = isJwk(key) ? await importJWK(key, \"EdDSA\") : key;\n return jwtVerify(jwt, resolved, options);\n}\n","import { createRemoteJWKSet, type JWTPayload } from \"jose\";\nimport { verifyJwt } from \"./jwt\";\nimport { MinisterTokenError } from \"./errors\";\nimport type { KeyInput, MinisterClaims } from \"./types\";\n\n// Cache key is the trusted RP-config issuer (not request input), so this stays bounded.\nconst jwksCache = new Map>();\nfunction remoteJwksFor(issuer: string) {\n let set = jwksCache.get(issuer);\n if (!set) {\n set = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));\n jwksCache.set(issuer, set);\n }\n return set;\n}\n\nexport interface VerifyIdTokenOptions {\n issuer: string;\n // REQUIRED (fail-closed audience): the id_token `aud` must equal it. A\n // verifier built without a clientId would silently accept a token minted for\n // another relying party (cross-RP impersonation), so this is not optional and\n // is also enforced at runtime.\n clientId: string;\n // Replay nonce; when set, must equal the id_token `nonce`.\n nonce?: string;\n // Inject the verification key (defaults to the remote JWKS).\n key?: KeyInput;\n}\n\n// Internal: verify the id_token and return the full payload (callers that\n// need minister_badges use this; verifyMinisterIdToken maps to claims).\nexport async function verifyIdTokenPayload(idToken: string, options: VerifyIdTokenOptions): Promise {\n const issuer = options.issuer.replace(/\\/$/, \"\");\n // Fail closed: never verify an id_token without an expected audience. A JS\n // caller can defeat the required-type at runtime, so guard here too.\n if (!options.clientId) {\n throw new MinisterTokenError(\"clientId (expected audience) is required to verify an id_token\");\n }\n const key = options.key ?? remoteJwksFor(issuer);\n let payload: JWTPayload;\n try {\n const result = await verifyJwt(idToken, key, {\n issuer,\n algorithms: [\"EdDSA\"],\n requiredClaims: [\"exp\", \"iat\"],\n clockTolerance: \"30s\",\n audience: options.clientId,\n });\n payload = result.payload;\n } catch (cause) {\n throw new MinisterTokenError(cause instanceof Error ? cause.message : String(cause));\n }\n if (options.nonce !== undefined && payload[\"nonce\"] !== options.nonce) {\n throw new MinisterTokenError(\"id_token `nonce` mismatch\");\n }\n if (typeof payload.sub !== \"string\" || payload.sub.length === 0) {\n throw new MinisterTokenError(\"id_token missing string `sub`\");\n }\n return payload;\n}\n\n// Map a verified id_token payload to the public identity claims. Shared by\n// verifyMinisterIdToken and the flow client so the mapping lives in one place.\nexport function claimsFromPayload(payload: JWTPayload, raw: string): MinisterClaims {\n return {\n sub: payload.sub as string,\n name: typeof payload[\"name\"] === \"string\" ? (payload[\"name\"] as string) : undefined,\n picture: typeof payload[\"picture\"] === \"string\" ? (payload[\"picture\"] as string) : undefined,\n raw,\n };\n}\n\n// Verify a Minister id_token and return its identity claims.\nexport async function verifyMinisterIdToken(idToken: string, options: VerifyIdTokenOptions): Promise {\n const payload = await verifyIdTokenPayload(idToken, options);\n return claimsFromPayload(payload, idToken);\n}\n\nexport function _resetIdTokenJwksCache(issuer?: string): void {\n if (issuer) jwksCache.delete(issuer.replace(/\\/$/, \"\"));\n else jwksCache.clear();\n}\n","import type { JWTPayload } from \"jose\";\nimport { verifyMinisterBadge } from \"./verify-badge\";\nimport { verifyIdTokenPayload } from \"./verify-id-token\";\nimport { buildPairwiseSubjectDid } from \"./did\";\nimport { MinisterTokenError, VcVerificationError } from \"./errors\";\nimport type { KeyInput, BadgesResult } from \"./types\";\n\nexport interface VerifyBadgesOptions {\n issuer: string;\n // Required when a raw id_token STRING is passed (the wrapper is verified,\n // and its `aud` must be enforced fail-closed). Unused on the already-verified\n // payload path.\n clientId?: string;\n key?: KeyInput;\n}\n\n// Verify the `minister_badges` carried by a token AND bind each one to the\n// login.\n//\n// - Given a raw id_token STRING, the wrapper is verified first (throws\n// MinisterTokenError if it fails, including a missing clientId/audience),\n// then its badges are read.\n// - Given an already-verified PAYLOAD object (e.g. Auth.js's profile, or a\n// prior verifyIdToken result), the wrapper is trusted and only the badges are\n// verified.\n//\n// Holder binding: each badge's pairwise subject MUST equal\n// `did:web::u:`. Minister re-mints each disclosed badge\n// under the same pairwise pseudonym it stamps as the id_token `sub`, so a badge\n// whose subject does not bind to THIS login (a borrowed/mismatched credential,\n// or one carrying a stale subject) is pushed to `rejected` rather than trusted.\n//\n// Individual bad badges never throw — they are returned in `rejected`.\nexport async function verifyMinisterBadges(\n tokenOrPayload: string | JWTPayload,\n options: VerifyBadgesOptions,\n): Promise {\n let payload: JWTPayload;\n if (typeof tokenOrPayload === \"string\") {\n // Fail closed: verifying the wrapper string requires an expected audience.\n if (!options.clientId) {\n throw new MinisterTokenError(\"clientId is required to verify a raw id_token string\");\n }\n payload = await verifyIdTokenPayload(tokenOrPayload, {\n issuer: options.issuer,\n clientId: options.clientId,\n key: options.key,\n });\n } else {\n payload = tokenOrPayload;\n }\n\n const raw = (payload as Record)[\"minister_badges\"];\n if (raw === undefined || raw === null) return { badges: [], rejected: [] };\n if (!Array.isArray(raw)) {\n return { badges: [], rejected: [{ raw: String(raw), error: new VcVerificationError(\"minister_badges is not an array\") }] };\n }\n\n // The login the badges must bind to. Without a usable subject we cannot bind,\n // so every badge is rejected (fail closed) rather than trusted unbound.\n const idTokenSub = (payload as Record)[\"sub\"];\n const canBind = typeof idTokenSub === \"string\" && idTokenSub.length > 0;\n const expectedSubject = canBind\n ? buildPairwiseSubjectDid(options.issuer, idTokenSub as string)\n : undefined;\n\n const result: BadgesResult = { badges: [], rejected: [] };\n for (const entry of raw) {\n if (typeof entry !== \"string\") {\n result.rejected.push({ raw: String(entry), error: new VcVerificationError(\"badge entry is not a JWT string\") });\n continue;\n }\n try {\n const badge = await verifyMinisterBadge(entry, { issuer: options.issuer, key: options.key });\n if (!expectedSubject) {\n throw new VcVerificationError(\n \"cannot bind badge: id_token has no usable `sub`\",\n );\n }\n if (badge.subject !== expectedSubject) {\n // Signed by Minister, but its subject does not bind to THIS login —\n // a borrowed/mismatched credential. Do not count it.\n throw new VcVerificationError(\n \"badge subject is not bound to the id_token sub (borrowed or mismatched credential)\",\n );\n }\n result.badges.push(badge);\n } catch (cause) {\n result.rejected.push({\n raw: entry,\n error: cause instanceof VcVerificationError ? cause : new VcVerificationError(String(cause)),\n });\n }\n }\n return result;\n}\n"],"mappings":"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","names":["importJWK","importJWK"]} \ No newline at end of file diff --git a/dist/chunk-AIB6IEP6.js b/dist/chunk-FTCBESCF.js similarity index 95% rename from dist/chunk-AIB6IEP6.js rename to dist/chunk-FTCBESCF.js index cdf0c4c..ae4f4bc 100644 --- a/dist/chunk-AIB6IEP6.js +++ b/dist/chunk-FTCBESCF.js @@ -73,6 +73,7 @@ async function verifyJwt(jwt, key, options) { } // src/verify-badge.ts +var NULLIFIER_RE = /^mnv1:[A-Za-z0-9_-]{20,64}$/; var didResolverCache = /* @__PURE__ */ new Map(); function assertionResolverFor(issuer) { let resolver = didResolverCache.get(issuer); @@ -197,6 +198,7 @@ async function verifyMinisterBadge(vcJwt, options) { const { id: _id, issuanceMonth: rawIssuanceMonth, + nullifier: rawNullifier, ...rawClaims } = credentialSubject; let issuanceMonth; @@ -208,6 +210,15 @@ async function verifyMinisterBadge(vcJwt, options) { } issuanceMonth = rawIssuanceMonth; } + let nullifier; + if (rawNullifier !== void 0) { + if (typeof rawNullifier !== "string" || !NULLIFIER_RE.test(rawNullifier)) { + throw new VcVerificationError( + "VC `credentialSubject.nullifier` is not a well-formed mnv1 nullifier" + ); + } + nullifier = rawNullifier; + } const schema = getBadgeClaimSchema(slug); let claims; try { @@ -222,6 +233,7 @@ async function verifyMinisterBadge(vcJwt, options) { claims, subject: payload.sub, ...issuanceMonth !== void 0 ? { issuanceMonth } : {}, + ...nullifier !== void 0 ? { nullifier } : {}, raw: vcJwt }; } @@ -343,4 +355,4 @@ export { verifyMinisterIdToken, verifyMinisterBadges }; -//# sourceMappingURL=chunk-AIB6IEP6.js.map \ No newline at end of file +//# sourceMappingURL=chunk-FTCBESCF.js.map \ No newline at end of file diff --git a/dist/chunk-FTCBESCF.js.map b/dist/chunk-FTCBESCF.js.map new file mode 100644 index 0000000..5529a2a --- /dev/null +++ b/dist/chunk-FTCBESCF.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/did.ts","../src/errors.ts","../src/verify-badge.ts","../src/jwt.ts","../src/verify-id-token.ts","../src/verify-badges.ts"],"sourcesContent":["// did:web identifier for a domain. Per the W3C did:web spec the DID\n// document is served at https:///.well-known/did.json. Mirrors\n// `@ministryofmany/vc`'s `buildDid` so the VC `iss` this SDK expects matches\n// exactly what Minister stamps when issuing.\nexport function buildDid(domain: string): string {\n return `did:web:${domain}`;\n}\n\n// Derive the issuer DID from a Minister origin (the OIDC `issuer`).\n// Minister signs VCs with `iss = did:web:` where is the\n// origin's host (including a non-default port, encoded per did:web as\n// `host%3Aport`). e.g. \"https://ministry.id\" -> \"did:web:ministry.id\".\n//\n// Explicit + total: a path-bearing issuer is REJECTED rather than silently\n// dropped. Minister's did:web is host-only, so an issuer with a path would\n// make the derived DID diverge and every badge fail closed with no signal —\n// fail loud at config time instead. `issuer` is trusted RP config (set once),\n// so throwing here surfaces a misconfiguration, never attacker input.\nexport function didFromIssuer(issuer: string): string {\n const url = new URL(issuer);\n if (url.pathname !== \"\" && url.pathname !== \"/\") {\n throw new Error(\n `Minister issuer must be an origin with no path (got path \"${url.pathname}\" in \"${issuer}\")`,\n );\n }\n if (url.search !== \"\" || url.hash !== \"\") {\n throw new Error(`Minister issuer must be an origin with no query or fragment: \"${issuer}\"`);\n }\n // did:web encodes a port by percent-encoding the colon.\n const host = url.port ? `${url.hostname}%3A${url.port}` : url.hostname;\n return buildDid(host);\n}\n\n// Build the per-RP PAIRWISE subject DID Minister stamps into a disclosed\n// badge: `did:web::u:`, where is the issuer host and \n// is the id_token pairwise subject. The wrapper uses this to bind a badge to\n// the login (badge `subject` must equal the value this returns for the\n// id_token `sub`).\nexport function buildPairwiseSubjectDid(issuer: string, sub: string): string {\n return `${didFromIssuer(issuer)}:u:${sub}`;\n}\n\n// Parse a pairwise subject DID back into { issuerDid, sub }, or null when it\n// does not match the `did:web:<...>:u:` shape. Explicit `:u:` handling —\n// must be a single, non-empty, colon-free trailing segment — so this is\n// total and never silently mis-splits a path-bearing DID.\nexport function parsePairwiseSubjectDid(\n subject: string,\n): { issuerDid: string; sub: string } | null {\n const marker = \":u:\";\n const idx = subject.lastIndexOf(marker);\n if (idx <= 0) return null;\n const issuerDid = subject.slice(0, idx);\n const sub = subject.slice(idx + marker.length);\n if (!issuerDid.startsWith(\"did:web:\") || sub.length === 0 || sub.includes(\":\")) return null;\n return { issuerDid, sub };\n}\n","// Thrown when a verifiable-credential badge fails verification — bad\n// signature, wrong issuer, malformed envelope, or a subject-binding\n// mismatch. Mirrors `@ministryofmany/vc`'s `VcVerificationError`.\n// Its message may include VC-derived text; do not reflect it to untrusted output.\nexport class VcVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"VcVerificationError\";\n }\n}\n\n// Thrown when the OIDC flow fails for a non-token reason - missing client\n// config, discovery, the token-exchange request, or a malformed token\n// response. id_token verification failures throw MinisterTokenError;\n// individual bad badges are reported in BadgesResult.rejected, not thrown.\nexport class OidcError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"OidcError\";\n }\n}\n\n// Thrown when an id_token itself fails verification - signature, issuer,\n// audience, expiry, or nonce. The token is the trust root, so this is a\n// hard failure (distinct from an individual bad badge, which is reported\n// in BadgesResult.rejected rather than thrown).\n// Its message may include token-derived text; do not reflect it to untrusted output.\nexport class MinisterTokenError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"MinisterTokenError\";\n }\n}\n","import { importJWK, type JWK, type JWTVerifyGetKey, type KeyLike } from \"jose\";\n\nimport { badgeTypeOf, getBadgeClaimSchema } from \"./badges/helpers\";\nimport { didFromIssuer } from \"./did\";\nimport { verifyJwt } from \"./jwt\";\nimport { VcVerificationError } from \"./errors\";\nimport type { KeyInput, MinisterGatingNullifier, VerifiedBadge } from \"./types\";\n\n// The disclosed per-RP Sybil nullifier is version-prefixed `mnv1:` followed by\n// base64url. Strictly format-checked; a malformed value fails the badge closed.\n// LENGTH-BOUNDED: Minister derives the tag as base64url of a 32-byte HMAC/VOPRF\n// output — exactly 43 chars (Minister's own signet trust boundary pins {43}).\n// The value is issuer-signed, so this is defense-in-depth: a bounded tail keeps\n// a buggy/compromised issuer from stamping an arbitrarily long tag that RPs then\n// persist in ban/dedup tables. The window is generous (20..64) rather than a\n// hard {43} so a future same-shape construction is not brittle; a genuinely new\n// wire shape versions behind `mnv2` and gets its own check.\nconst NULLIFIER_RE = /^mnv1:[A-Za-z0-9_-]{20,64}$/;\n\n// Verify a Minister-issued verifiable credential against Minister's PUBLIC badge\n// key. Unlike `@ministryofmany/vc`'s `verifyVc` (which threads a full Issuer\n// carrying the private key), an RP only ever holds public material.\n//\n// Badge keys are pinned to the issuer's DID document `assertionMethod` — NOT the\n// raw JWKS. Minister's JWKS at /.well-known/jwks.json serves BOTH the badge\n// signing key (#key-2) AND the in-process token key (#key-3); jose selects a key\n// by the JWT `kid`, so verifying a badge against the full JWKS would accept a VC\n// carrying `kid ...#key-3` — i.e. one forged with a stolen token key — defeating\n// the KMS split. The DID document's `assertionMethod` lists ONLY #key-2, so we\n// resolve badge keys from it and REJECT any `kid` not listed there. That keeps\n// the token key from ever attesting a badge.\n\n// did:web DID document, restricted to the fields we consume.\ninterface DidVerificationMethod {\n id?: unknown;\n publicKeyJwk?: unknown;\n}\ninterface DidDocumentShape {\n verificationMethod?: unknown;\n assertionMethod?: unknown;\n}\n\n// Cache one assertionMethod key resolver per issuer for the process lifetime.\n// Cache key is the trusted RP-config issuer (not request input), so this stays\n// bounded. Each resolver memoizes the fetched-and-imported key map internally and\n// clears it on failure, so a transient did.json fetch error self-heals on the\n// next call rather than permanently wedging badge verification.\nconst didResolverCache = new Map();\n\nfunction assertionResolverFor(issuer: string): JWTVerifyGetKey {\n let resolver = didResolverCache.get(issuer);\n if (!resolver) {\n resolver = createDidAssertionResolver(issuer);\n didResolverCache.set(issuer, resolver);\n }\n return resolver;\n}\n\n// Fetch /.well-known/did.json and build a `kid -> public key` map from\n// its `assertionMethod` — the ONLY keys allowed to verify a badge VC.\nasync function loadAssertionKeys(\n issuer: string,\n): Promise> {\n const url = `${issuer}/.well-known/did.json`;\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(`DID document fetch failed (${res.status}) for ${url}`);\n }\n const doc = (await res.json()) as DidDocumentShape;\n\n const vms = Array.isArray(doc.verificationMethod) ? doc.verificationMethod : [];\n const byId = new Map();\n for (const vm of vms) {\n if (\n vm &&\n typeof vm === \"object\" &&\n typeof (vm as DidVerificationMethod).id === \"string\"\n ) {\n byId.set((vm as DidVerificationMethod).id as string, vm as DidVerificationMethod);\n }\n }\n\n const assertion = Array.isArray(doc.assertionMethod) ? doc.assertionMethod : [];\n if (assertion.length === 0) {\n throw new Error(\"DID document has no `assertionMethod` entries\");\n }\n\n const map = new Map();\n for (const entry of assertion) {\n // Per W3C, an assertionMethod entry is either a string reference to a\n // verificationMethod `id` or an embedded verification method object.\n let vm: DidVerificationMethod | undefined;\n if (typeof entry === \"string\") {\n vm = byId.get(entry);\n } else if (entry && typeof entry === \"object\") {\n vm = entry as DidVerificationMethod;\n }\n const kid = vm && typeof vm.id === \"string\" ? vm.id : undefined;\n const jwk = vm?.publicKeyJwk;\n if (!kid || !jwk || typeof jwk !== \"object\") {\n throw new Error(\n `assertionMethod entry has no resolvable publicKeyJwk: ${String(\n typeof entry === \"string\" ? entry : (vm?.id ?? \"\"),\n )}`,\n );\n }\n map.set(kid, await importJWK(jwk as JWK, \"EdDSA\"));\n }\n return map;\n}\n\nfunction createDidAssertionResolver(issuer: string): JWTVerifyGetKey {\n let keysPromise: Promise> | undefined;\n const load = () => {\n if (!keysPromise) {\n // Don't cache a REJECTED fetch: a transient did.json outage would\n // otherwise wedge badge verification for the process lifetime.\n keysPromise = loadAssertionKeys(issuer).catch((err) => {\n keysPromise = undefined;\n throw err;\n });\n }\n return keysPromise;\n };\n return async (protectedHeader) => {\n const keys = await load();\n const kid = protectedHeader.kid;\n if (typeof kid !== \"string\" || kid.length === 0) {\n throw new Error(\"badge JWT has no `kid`; cannot pin to DID assertionMethod\");\n }\n const key = keys.get(kid);\n if (!key) {\n throw new Error(\n `badge kid (${kid}) is not in the issuer DID document assertionMethod`,\n );\n }\n return key;\n };\n}\n\nexport interface VerifyBadgeOptions {\n // Minister origin, e.g. \"https://ministry.id\".\n issuer: string;\n // Inject the verification key. Defaults to the issuer's DID assertionMethod\n // key set (kid-pinned to #key-2). Pass a public JWK in tests so verification\n // never touches the network.\n key?: KeyInput;\n}\n\n// Verify a received VC JWT (standalone).\n//\n// Beyond `@ministryofmany/vc`'s structural checks, this asserts the VC-INTERNAL\n// invariant `credentialSubject.id === payload.sub` — that the claims are bound\n// to the subject the issuer signed them for. It does NOT (and cannot) bind the\n// badge to any LOGIN: there is no id_token here, so nothing ties this VC to the\n// user in front of you. A valid Minister badge belonging to some OTHER user\n// (e.g. one received via a share link) verifies successfully. Treating a\n// standalone `verifyMinisterBadge` success as \"the current user holds this\n// badge\" is an authorization bug.\n//\n// The holder-to-login binding lives in the wrapper (`verifyMinisterBadges`),\n// which requires `subject === did:web::u:`. Use the wrapper\n// for any access decision; use this only to certify issuance of an out-of-band\n// VC.\nexport async function verifyMinisterBadge(\n vcJwt: string,\n options: VerifyBadgeOptions,\n): Promise {\n const issuer = options.issuer.replace(/\\/$/, \"\");\n const expectedIss = didFromIssuer(issuer);\n const key = options.key ?? assertionResolverFor(issuer);\n\n let payload;\n try {\n const result = await verifyJwt(vcJwt, key, {\n issuer: expectedIss,\n algorithms: [\"EdDSA\"],\n typ: \"vc+jwt\",\n requiredClaims: [\"exp\"],\n });\n payload = result.payload;\n } catch (cause) {\n throw new VcVerificationError(\n cause instanceof Error ? cause.message : String(cause),\n );\n }\n\n if (typeof payload.sub !== \"string\" || payload.sub.length === 0) {\n throw new VcVerificationError(\"VC payload missing string `sub`\");\n }\n\n const vc = payload.vc as\n | { type?: unknown; credentialSubject?: unknown }\n | undefined;\n if (!vc || typeof vc !== \"object\") {\n throw new VcVerificationError(\"VC payload missing `vc` envelope\");\n }\n if (!Array.isArray(vc.type) || !vc.type.every((t) => typeof t === \"string\")) {\n throw new VcVerificationError(\"VC `type` must be a string array\");\n }\n if (\n !vc.credentialSubject ||\n typeof vc.credentialSubject !== \"object\" ||\n Array.isArray(vc.credentialSubject)\n ) {\n throw new VcVerificationError(\"VC missing `credentialSubject` object\");\n }\n\n const credentialSubject = vc.credentialSubject as Record;\n const subjectId = credentialSubject[\"id\"];\n if (typeof subjectId !== \"string\" || subjectId.length === 0) {\n throw new VcVerificationError(\"VC `credentialSubject.id` missing\");\n }\n\n // Holder-binding invariant: the JWT subject must equal the credential\n // subject the issuer signed. (Additional check beyond @ministryofmany/vc.)\n if (subjectId !== payload.sub) {\n throw new VcVerificationError(\n \"VC `credentialSubject.id` does not match `sub`\",\n );\n }\n\n // Map the VC type to a known Minister badge slug.\n const slug = badgeTypeOf(vc.type as string[]);\n if (!slug) {\n throw new VcVerificationError(\n `Unknown Minister badge type: ${(vc.type as string[]).join(\",\")}`,\n );\n }\n\n // Validate the claims against that badge type's schema. THREE RESERVED keys\n // are stripped first: `id` (redundant with `sub`), `issuanceMonth` (Minister's\n // coarse-issuance metadata), and `nullifier` (the per-RP Sybil-dedup tag,\n // stamped at disclosure re-mint). All three are cross-cutting VC metadata,\n // never per-type claims; stripping them BEFORE the schema parse keeps strict\n // per-type schemas (account-age, social-following, tlsn-attestation) passing\n // — a nullifier-bearing account-age badge would otherwise fail `.strict()`.\n const {\n id: _id,\n issuanceMonth: rawIssuanceMonth,\n nullifier: rawNullifier,\n ...rawClaims\n } = credentialSubject;\n\n // Strictly format-check the coarse issuance bucket when present. A\n // malformed value means issuer drift or a claim-shaped smuggle upstream of\n // the signature — fail closed rather than hand policy code a garbage\n // timestamp. Absent is fine (legacy Minister); downstream freshness checks\n // then fail closed on maxAgeDays.\n let issuanceMonth: string | undefined;\n if (rawIssuanceMonth !== undefined) {\n if (\n typeof rawIssuanceMonth !== \"string\" ||\n !/^\\d{4}-(0[1-9]|1[0-2])$/.test(rawIssuanceMonth)\n ) {\n throw new VcVerificationError(\n \"VC `credentialSubject.issuanceMonth` is not a YYYY-MM UTC month\",\n );\n }\n issuanceMonth = rawIssuanceMonth;\n }\n\n // Format-check the per-RP nullifier when present. A malformed value means\n // issuer drift or a claim-shaped smuggle upstream of the signature — fail\n // closed rather than gate on garbage. Absent is fine (no wired nullifier, or\n // a pre-M5 disclosure); gating code then treats the badge as untagged.\n let nullifier: MinisterGatingNullifier | undefined;\n if (rawNullifier !== undefined) {\n if (typeof rawNullifier !== \"string\" || !NULLIFIER_RE.test(rawNullifier)) {\n throw new VcVerificationError(\n \"VC `credentialSubject.nullifier` is not a well-formed mnv1 nullifier\",\n );\n }\n nullifier = rawNullifier as MinisterGatingNullifier;\n }\n\n const schema = getBadgeClaimSchema(slug);\n let claims: Record;\n try {\n claims = schema\n ? (schema.parse(rawClaims) as Record)\n : rawClaims;\n } catch (cause) {\n throw new VcVerificationError(\n `Badge ${slug} claims failed validation: ${\n cause instanceof Error ? cause.message : String(cause)\n }`,\n );\n }\n\n return {\n type: slug,\n claims,\n subject: payload.sub,\n ...(issuanceMonth !== undefined ? { issuanceMonth } : {}),\n ...(nullifier !== undefined ? { nullifier } : {}),\n raw: vcJwt,\n };\n}\n\n// Test seam: drop the cached DID assertionMethod key resolver for an issuer (or\n// all issuers).\nexport function _resetBadgeKeyCache(issuer?: string): void {\n if (issuer) didResolverCache.delete(issuer.replace(/\\/$/, \"\"));\n else didResolverCache.clear();\n}\n","import {\n importJWK,\n jwtVerify,\n type JWK,\n type JWTPayload,\n type JWTVerifyOptions,\n type JWTVerifyResult,\n} from \"jose\";\n\nimport type { KeyInput } from \"./types\";\n\n// A bare JWK is a plain object carrying `kty`. A resolved `KeyLike`\n// (CryptoKey/KeyObject) has `type` but never `kty`; a `Uint8Array` and a\n// resolver function are not plain objects with `kty`. So this is total and\n// unambiguous over the `KeyInput` union.\nfunction isJwk(key: KeyInput): key is JWK {\n return (\n typeof key === \"object\" &&\n key !== null &&\n !(key instanceof Uint8Array) &&\n typeof (key as JWK).kty === \"string\"\n );\n}\n\n// jose's `jwtVerify` is overloaded: one signature takes a resolved key\n// (KeyLike/Uint8Array/JWK), the other a key-resolver function (e.g.\n// createRemoteJWKSet). `KeyInput` is the union of both. Narrow on the key\n// shape and dispatch to the right call so the rest of the SDK can pass a single\n// injectable key source — including a raw JWK, which we import ourselves\n// (pinned to EdDSA, the only alg Minister signs with) so a caller never has to.\nexport async function verifyJwt(\n jwt: string,\n key: KeyInput,\n options: JWTVerifyOptions,\n): Promise> {\n if (typeof key === \"function\") {\n return jwtVerify(jwt, key, options);\n }\n const resolved = isJwk(key) ? await importJWK(key, \"EdDSA\") : key;\n return jwtVerify(jwt, resolved, options);\n}\n","import { createRemoteJWKSet, type JWTPayload } from \"jose\";\nimport { verifyJwt } from \"./jwt\";\nimport { MinisterTokenError } from \"./errors\";\nimport type { KeyInput, MinisterClaims } from \"./types\";\n\n// Cache key is the trusted RP-config issuer (not request input), so this stays bounded.\nconst jwksCache = new Map>();\nfunction remoteJwksFor(issuer: string) {\n let set = jwksCache.get(issuer);\n if (!set) {\n set = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));\n jwksCache.set(issuer, set);\n }\n return set;\n}\n\nexport interface VerifyIdTokenOptions {\n issuer: string;\n // REQUIRED (fail-closed audience): the id_token `aud` must equal it. A\n // verifier built without a clientId would silently accept a token minted for\n // another relying party (cross-RP impersonation), so this is not optional and\n // is also enforced at runtime.\n clientId: string;\n // Replay nonce; when set, must equal the id_token `nonce`.\n nonce?: string;\n // Inject the verification key (defaults to the remote JWKS).\n key?: KeyInput;\n}\n\n// Internal: verify the id_token and return the full payload (callers that\n// need minister_badges use this; verifyMinisterIdToken maps to claims).\nexport async function verifyIdTokenPayload(idToken: string, options: VerifyIdTokenOptions): Promise {\n const issuer = options.issuer.replace(/\\/$/, \"\");\n // Fail closed: never verify an id_token without an expected audience. A JS\n // caller can defeat the required-type at runtime, so guard here too.\n if (!options.clientId) {\n throw new MinisterTokenError(\"clientId (expected audience) is required to verify an id_token\");\n }\n const key = options.key ?? remoteJwksFor(issuer);\n let payload: JWTPayload;\n try {\n const result = await verifyJwt(idToken, key, {\n issuer,\n algorithms: [\"EdDSA\"],\n requiredClaims: [\"exp\", \"iat\"],\n clockTolerance: \"30s\",\n audience: options.clientId,\n });\n payload = result.payload;\n } catch (cause) {\n throw new MinisterTokenError(cause instanceof Error ? cause.message : String(cause));\n }\n if (options.nonce !== undefined && payload[\"nonce\"] !== options.nonce) {\n throw new MinisterTokenError(\"id_token `nonce` mismatch\");\n }\n if (typeof payload.sub !== \"string\" || payload.sub.length === 0) {\n throw new MinisterTokenError(\"id_token missing string `sub`\");\n }\n return payload;\n}\n\n// Map a verified id_token payload to the public identity claims. Shared by\n// verifyMinisterIdToken and the flow client so the mapping lives in one place.\nexport function claimsFromPayload(payload: JWTPayload, raw: string): MinisterClaims {\n return {\n sub: payload.sub as string,\n name: typeof payload[\"name\"] === \"string\" ? (payload[\"name\"] as string) : undefined,\n picture: typeof payload[\"picture\"] === \"string\" ? (payload[\"picture\"] as string) : undefined,\n raw,\n };\n}\n\n// Verify a Minister id_token and return its identity claims.\nexport async function verifyMinisterIdToken(idToken: string, options: VerifyIdTokenOptions): Promise {\n const payload = await verifyIdTokenPayload(idToken, options);\n return claimsFromPayload(payload, idToken);\n}\n\nexport function _resetIdTokenJwksCache(issuer?: string): void {\n if (issuer) jwksCache.delete(issuer.replace(/\\/$/, \"\"));\n else jwksCache.clear();\n}\n","import type { JWTPayload } from \"jose\";\nimport { verifyMinisterBadge } from \"./verify-badge\";\nimport { verifyIdTokenPayload } from \"./verify-id-token\";\nimport { buildPairwiseSubjectDid } from \"./did\";\nimport { MinisterTokenError, VcVerificationError } from \"./errors\";\nimport type { KeyInput, BadgesResult } from \"./types\";\n\nexport interface VerifyBadgesOptions {\n issuer: string;\n // Required when a raw id_token STRING is passed (the wrapper is verified,\n // and its `aud` must be enforced fail-closed). Unused on the already-verified\n // payload path.\n clientId?: string;\n key?: KeyInput;\n}\n\n// Verify the `minister_badges` carried by a token AND bind each one to the\n// login.\n//\n// - Given a raw id_token STRING, the wrapper is verified first (throws\n// MinisterTokenError if it fails, including a missing clientId/audience),\n// then its badges are read.\n// - Given an already-verified PAYLOAD object (e.g. Auth.js's profile, or a\n// prior verifyIdToken result), the wrapper is trusted and only the badges are\n// verified.\n//\n// Holder binding: each badge's pairwise subject MUST equal\n// `did:web::u:`. Minister re-mints each disclosed badge\n// under the same pairwise pseudonym it stamps as the id_token `sub`, so a badge\n// whose subject does not bind to THIS login (a borrowed/mismatched credential,\n// or one carrying a stale subject) is pushed to `rejected` rather than trusted.\n//\n// Individual bad badges never throw — they are returned in `rejected`.\nexport async function verifyMinisterBadges(\n tokenOrPayload: string | JWTPayload,\n options: VerifyBadgesOptions,\n): Promise {\n let payload: JWTPayload;\n if (typeof tokenOrPayload === \"string\") {\n // Fail closed: verifying the wrapper string requires an expected audience.\n if (!options.clientId) {\n throw new MinisterTokenError(\"clientId is required to verify a raw id_token string\");\n }\n payload = await verifyIdTokenPayload(tokenOrPayload, {\n issuer: options.issuer,\n clientId: options.clientId,\n key: options.key,\n });\n } else {\n payload = tokenOrPayload;\n }\n\n const raw = (payload as Record)[\"minister_badges\"];\n if (raw === undefined || raw === null) return { badges: [], rejected: [] };\n if (!Array.isArray(raw)) {\n return { badges: [], rejected: [{ raw: String(raw), error: new VcVerificationError(\"minister_badges is not an array\") }] };\n }\n\n // The login the badges must bind to. Without a usable subject we cannot bind,\n // so every badge is rejected (fail closed) rather than trusted unbound.\n const idTokenSub = (payload as Record)[\"sub\"];\n const canBind = typeof idTokenSub === \"string\" && idTokenSub.length > 0;\n const expectedSubject = canBind\n ? buildPairwiseSubjectDid(options.issuer, idTokenSub as string)\n : undefined;\n\n const result: BadgesResult = { badges: [], rejected: [] };\n for (const entry of raw) {\n if (typeof entry !== \"string\") {\n result.rejected.push({ raw: String(entry), error: new VcVerificationError(\"badge entry is not a JWT string\") });\n continue;\n }\n try {\n const badge = await verifyMinisterBadge(entry, { issuer: options.issuer, key: options.key });\n if (!expectedSubject) {\n throw new VcVerificationError(\n \"cannot bind badge: id_token has no usable `sub`\",\n );\n }\n if (badge.subject !== expectedSubject) {\n // Signed by Minister, but its subject does not bind to THIS login —\n // a borrowed/mismatched credential. Do not count it.\n throw new VcVerificationError(\n \"badge subject is not bound to the id_token sub (borrowed or mismatched credential)\",\n );\n }\n result.badges.push(badge);\n } catch (cause) {\n result.rejected.push({\n raw: entry,\n error: cause instanceof VcVerificationError ? cause : new VcVerificationError(String(cause)),\n });\n }\n }\n return result;\n}\n"],"mappings":"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","names":["importJWK","importJWK"]} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 318c773..565cf08 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,5 +1,5 @@ -import { K as KeyInput, V as VerifiedBadge, E as ExchangeResult, P as PkcePair, M as MinisterClientConfig, a as MinisterClaims, B as BadgesResult } from './types-B-nDX3ql.js'; -export { b as MinisterTokenError, O as OidcError, c as OidcFlowState, R as RejectedBadge, d as VcVerificationError } from './types-B-nDX3ql.js'; +import { K as KeyInput, V as VerifiedBadge, E as ExchangeResult, P as PkcePair, M as MinisterClientConfig, a as MinisterClaims, B as BadgesResult } from './types-C8FYcOBP.js'; +export { b as MinisterGatingNullifier, c as MinisterTokenError, O as OidcError, d as OidcFlowState, R as RejectedBadge, e as VcVerificationError } from './types-C8FYcOBP.js'; import { JWTPayload } from 'jose'; export { ACCOUNT_AGE_MONTHS, AGE_THRESHOLDS, AccountAgeClaims, AccountAgeMonths, AgeOverClaimsFor, AgeThreshold, BADGE_TYPES, BadgeTypeDef, EmailDomainClaims, EmailExactClaims, FOLLOWERS_BUCKETS, FollowersBucket, InviteCodeClaims, OAUTH_PROVIDERS, OAuthAccountClaims, ResidencyCityClaims, ResidencyCountryClaims, ResidencyStateClaims, SocialFollowingClaims, SybilResistance, TlsnAttestationClaims, badgeScope, badgeScopes, badgeTypeOf, defineBadgeType, getBadgeClaimSchema, knownBadgeTypes, slugForCredentialType } from './badges/index.js'; import 'zod'; diff --git a/dist/index.js b/dist/index.js index 29d1606..df414d8 100644 --- a/dist/index.js +++ b/dist/index.js @@ -11,7 +11,7 @@ import { verifyMinisterBadge, verifyMinisterBadges, verifyMinisterIdToken -} from "./chunk-AIB6IEP6.js"; +} from "./chunk-FTCBESCF.js"; import "./chunk-R4XGCZVA.js"; import { ACCOUNT_AGE_MONTHS, diff --git a/dist/types-B-nDX3ql.d.ts b/dist/types-C8FYcOBP.d.ts similarity index 62% rename from dist/types-B-nDX3ql.d.ts rename to dist/types-C8FYcOBP.d.ts index ec9e00d..d96b357 100644 --- a/dist/types-B-nDX3ql.d.ts +++ b/dist/types-C8FYcOBP.d.ts @@ -32,6 +32,34 @@ interface MinisterClaims { picture?: string; raw: string; } +/** + * Minister's per-relying-party Sybil-dedup nullifier (`mnv1:...`), the value + * Minister stamps in a disclosed badge's `credentialSubject.nullifier` (M5). + * + * BRANDED so it can NEVER be interchanged with the OTHER, unrelated nullifier + * primitive in this ecosystem — `@ministryofmany/nullifier`'s Poseidon/BN254 + * field string (`poseidon2(toField(sub), contextId)`), which is account-anchored + * and SNARK-provable. These two are permanently distinct (M3): + * + * | | `@ministryofmany/nullifier` | this `MinisterGatingNullifier` | + * |---|---|---| + * | math | Poseidon / BN254 | RFC 9497 VOPRF + HMAC-SHA256 | + * | anchor | the per-RP `sub` (account) | the credential (email, github id) | + * | circuit-usable | YES | NO (gating-only, plaintext compare) | + * | catches | same-account-across-contexts | same-credential-across-accounts | + * + * There is no conversion between them. A future circuit-usable credential + * nullifier must be a NEW Poseidon construction, never a bridge from this value. + * + * HONESTY: this proves ONE CREDENTIAL, not one person. It is per-site + * (different, unlinkable at other RPs), stable for the same credential (the same + * value if any account re-proves it here, surviving account delete/re-create), + * and only as strong as the credential behind it (see each badge type's + * `sybilResistance`). Gate on it; do not treat it as a unique-human oracle. + */ +type MinisterGatingNullifier = string & { + readonly __brand: "MinisterGatingNullifier"; +}; /** * A signature-verified, schema-validated badge. * @@ -62,6 +90,7 @@ interface VerifiedBadge { claims: Record; subject: string; issuanceMonth?: string; + nullifier?: MinisterGatingNullifier; raw: string; } interface RejectedBadge { @@ -79,4 +108,4 @@ interface ExchangeResult { } type KeyInput = KeyLike | JWK | Uint8Array | JWTVerifyGetKey; -export { type BadgesResult as B, type ExchangeResult as E, type KeyInput as K, type MinisterClientConfig as M, OidcError as O, type PkcePair as P, type RejectedBadge as R, type VerifiedBadge as V, type MinisterClaims as a, MinisterTokenError as b, type OidcFlowState as c, VcVerificationError as d }; +export { type BadgesResult as B, type ExchangeResult as E, type KeyInput as K, type MinisterClientConfig as M, OidcError as O, type PkcePair as P, type RejectedBadge as R, type VerifiedBadge as V, type MinisterClaims as a, type MinisterGatingNullifier as b, MinisterTokenError as c, type OidcFlowState as d, VcVerificationError as e }; diff --git a/packages/minister-verify/src/verify.ts b/packages/minister-verify/src/verify.ts index beb3066..7e9aa01 100644 --- a/packages/minister-verify/src/verify.ts +++ b/packages/minister-verify/src/verify.ts @@ -129,6 +129,13 @@ export function makeVerifier(deps: VerifierDeps) { // The issuance-month bucket START (coarse, fail-closed) — never the // disclosure-time iat. See issuedAtFromIssuanceMonth. issuedAt: issuedAtFromIssuanceMonth(b.issuanceMonth), + // NOTE: `b.nullifier` (the per-RP `mnv1:` gating tag) is intentionally + // DROPPED here — this mapping is not wired for nullifier-based gating + // yet (the Discreetly gating follow-up is optional per the crypto-core + // ADR). It does NOT propagate to policy evaluation via `attributes` + // either: `b.claims` has the nullifier stripped SDK-side. When room + // gating on the nullifier lands, thread it through as an optional field + // here; until then, do NOT assume it reaches the policy layer. })), }; }; diff --git a/packages/nullifier/README.md b/packages/nullifier/README.md new file mode 100644 index 0000000..08e23b5 --- /dev/null +++ b/packages/nullifier/README.md @@ -0,0 +1,45 @@ +# @ministryofmany/nullifier + +Poseidon/BN254 context nullifier — the **circuit-usable, account-anchored** +nullifier primitive for the Ministry ecosystem (Discreetly RLN/membership, the +Deforum user-sub-forum anchor). It derives a SNARK-friendly field element that is +stable per `(sub, contextId)` and unlinkable across contexts without the `sub`. + +```ts +import { deriveContextNullifier } from "@ministryofmany/nullifier"; + +// poseidon2(toField(sub), contextId % FIELD) +const nul = deriveContextNullifier(pairwiseSub, roomRlnIdentifier); +``` + +`toField` reduces an arbitrary string to a BN254 field element by big-endian +base-256 accumulation mod `FIELD` (NOT a hash — it is the exact reduction +Discreetly's `toField` performs, preserved byte-for-byte). Use +`deriveContextNullifierFromField` when the first input is ALREADY a field element +(e.g. a membership proof's nullifier) rather than an arbitrary string. + +## Two nullifier primitives — permanently distinct, never bridge + +This ecosystem has **two** nullifiers. They are NOT interchangeable, there is no +conversion between them, and mixing them is a correctness bug: + +| | `@ministryofmany/nullifier` (this package) | Minister gating nullifier (`MinisterGatingNullifier`, `mnv1:...`) | +|---|---|---| +| Math | Poseidon / BN254 (`poseidon2(toField(sub), contextId)`) | RFC 9497 VOPRF (stage 1) + HMAC-SHA256 (stage 2) | +| Anchor | the per-RP `sub` (account-anchored) | the credential (email, github id) | +| Circuit-usable | **YES** — SNARK-provable (RLN, membership) | **NO** — gating-only, plaintext compare | +| Catches | same-account-across-contexts linkage | same-credential-across-accounts Sybil | +| Wire form | decimal BN254 field string | `mnv1:` + base64url | + +**Never bridge from `mnv1:`.** The gating nullifier is a plaintext gating tag; it +is not a field element and must never be fed into `toField`, `poseidon2`, or any +circuit input. Doing so would produce a valid-LOOKING field element that silently +conflates two unrelated anonymity namespaces. `toField` enforces this at runtime: +it **throws** on any input matching `^mnv1:`. A future circuit-usable *credential* +nullifier must be a NEW Poseidon construction over an appropriate anchor, not a +reduction of the gating tag. + +The gating nullifier lives on `@minister/client`'s `VerifiedBadge.nullifier` and +is documented in `src/types.ts` (`MinisterGatingNullifier`). Gate on it for +"one credential" (Sybil dedup, ban persistence); do not treat it as a +unique-human oracle. diff --git a/packages/nullifier/src/derive.test.ts b/packages/nullifier/src/derive.test.ts index 5f7f301..c8f8ba2 100644 --- a/packages/nullifier/src/derive.test.ts +++ b/packages/nullifier/src/derive.test.ts @@ -37,6 +37,15 @@ describe("toField golden vectors", () => { it("is field-bounded", () => { expect(toField("any-long-string-".repeat(20))).toBeLessThan(FIELD); }); + + it("REFUSES an mnv1 gating nullifier (M3 cross-primitive guard)", () => { + // The gating nullifier and this Poseidon primitive are non-interchangeable; + // feeding one into the other must fail loudly, not produce a field element. + expect(() => toField("mnv1:AbC-123_def")).toThrow(/mnv1/); + expect(() => deriveContextNullifier("mnv1:AbC-123_def", 700n)).toThrow(/mnv1/); + // A plain sub that merely CONTAINS the substring elsewhere is unaffected. + expect(() => toField("user-mnv1:not-a-prefix")).not.toThrow(); + }); }); describe("deriveContextNullifier golden vectors", () => { diff --git a/packages/nullifier/src/derive.ts b/packages/nullifier/src/derive.ts index 917b07e..fb6304c 100644 --- a/packages/nullifier/src/derive.ts +++ b/packages/nullifier/src/derive.ts @@ -29,6 +29,20 @@ export const FIELD = BigInt( * `toField` would re-hash its digits and is not the identity on field elements. */ export function toField(s: string): bigint { + // Cross-primitive guard (M3). This Poseidon/BN254 nullifier is PERMANENTLY + // DISTINCT from Minister's gating nullifier (`mnv1:...`, HMAC/VOPRF, + // plaintext-compare, NOT circuit-usable). The two must never bridge. The + // TypeScript brand blocks the assignment one direction, but a branded value + // widens back to `string`, so `toField(badge.nullifier)` would type-check and + // silently produce a valid-looking field element. Reject it at runtime: a + // future circuit-usable credential nullifier must be a NEW construction, not a + // reduction of the gating tag. + if (/^mnv1:/.test(s)) { + throw new Error( + "toField refuses an `mnv1:` gating nullifier: it is not interchangeable with the " + + "Poseidon/BN254 primitive (M3). Derive a new nullifier, never bridge from mnv1.", + ); + } let acc = 0n; for (const byte of new TextEncoder().encode(s)) acc = (acc * 256n + BigInt(byte)) % FIELD; return acc; diff --git a/src/badges/drift.test.ts b/src/badges/drift.test.ts new file mode 100644 index 0000000..a566ce9 --- /dev/null +++ b/src/badges/drift.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { BADGE_TYPES } from "./registry"; +import { getBadgeClaimSchema, knownBadgeTypes } from "./helpers"; + +// =========================================================================== +// Cross-package drift gate: this SDK's badge registry is a MIRROR of Minister's +// `@ministryofmany/shared` provider-side registry. The two live in separate +// repos with no shared dependency, so this test pins the SDK against a frozen +// CANONICAL contract that transcribes `@ministryofmany/shared` +// (packages/shared/src/badge-types.ts): the slug set, each type's +// `credentialType` (the VC `type[]` entry Minister stamps), its +// `sybilResistance`, and its claim-schema SHAPE (sample validity + strictness). +// +// When Minister's shared registry changes, update EXPECTED here in the same +// change and re-run — a diff here is the drift alarm. (A stronger gate would +// import both registries into one process; that is impossible across the repo +// boundary, so this frozen transcription is the cheapest real drift gate, and +// this M5 change — which touches both sides — is the moment to land it.) +// =========================================================================== + +type Sybil = "none" | "weak" | "moderate"; + +interface Expected { + credentialType: string; + sybilResistance: Sybil; + // A claims object that MUST parse for this type. + sample: Record; + // z.object(...).strict() rejects unknown keys; plain z.object strips them. + strict: boolean; +} + +const AGE_THRESHOLDS = [16, 18, 21, 25, 30, 35, 40, 45, 55, 65] as const; + +// Reserved cross-cutting VC-metadata keys, stripped by verify-badge.ts before +// the per-type schema parse. No claim schema may declare one (see the guard +// test below). Kept in lockstep with the provider-side badge-types.drift.test. +const RESERVED_KEYS = ["id", "issuanceMonth", "nullifier"] as const; + +const EXPECTED: Record = { + "email-domain": { + credentialType: "MinisterEmailDomainCredential", + sybilResistance: "weak", + sample: { domain: "example.com" }, + strict: false, + }, + "email-exact": { + credentialType: "MinisterEmailExactCredential", + sybilResistance: "weak", + sample: { email: "user@example.com" }, + strict: false, + }, + "oauth-account": { + credentialType: "MinisterOauthAccountCredential", + sybilResistance: "weak", + sample: { provider: "github", handle: "octocat" }, + strict: false, + }, + "account-age": { + credentialType: "MinisterAccountAgeCredential", + sybilResistance: "moderate", + sample: { provider: "github", olderThanMonths: 24 }, + strict: true, + }, + "social-following": { + credentialType: "MinisterSocialFollowingCredential", + sybilResistance: "moderate", + sample: { provider: "github", followersAtLeast: 100 }, + strict: true, + }, + "residency-country": { + credentialType: "MinisterResidencyCountryCredential", + sybilResistance: "none", + sample: { country: "US" }, + strict: false, + }, + "residency-state": { + credentialType: "MinisterResidencyStateCredential", + sybilResistance: "none", + sample: { country: "US", state: "California" }, + strict: false, + }, + "residency-city": { + credentialType: "MinisterResidencyCityCredential", + sybilResistance: "none", + sample: { country: "US", state: "California", city: "San Francisco" }, + strict: false, + }, + "invite-code": { + credentialType: "MinisterInviteCodeCredential", + sybilResistance: "none", + sample: { label: "spring-2026" }, + strict: false, + }, + "tlsn-attestation": { + credentialType: "MinisterTlsnAttestationCredential", + sybilResistance: "none", + sample: { domain: "id.me", claim: "verified" }, + strict: true, + }, + ...Object.fromEntries( + AGE_THRESHOLDS.map((t) => [ + `age-over-${t}`, + { + credentialType: `MinisterAgeOver${t}Credential`, + sybilResistance: "none" as Sybil, + sample: { threshold: t }, + strict: false, + }, + ]), + ), +}; + +describe("badge registry drift vs @ministryofmany/shared (frozen contract)", () => { + it("has the exact same slug set as the canonical contract", () => { + expect(new Set(knownBadgeTypes())).toEqual(new Set(Object.keys(EXPECTED))); + }); + + for (const [slug, exp] of Object.entries(EXPECTED)) { + describe(slug, () => { + it("matches credentialType and sybilResistance", () => { + const def = BADGE_TYPES[slug]; + expect(def, `SDK is missing badge type ${slug}`).toBeDefined(); + expect(def!.credentialType).toBe(exp.credentialType); + expect(def!.sybilResistance).toBe(exp.sybilResistance); + }); + + it("accepts the canonical sample claims", () => { + const schema = getBadgeClaimSchema(slug); + expect(schema).toBeDefined(); + expect(() => schema!.parse(exp.sample)).not.toThrow(); + }); + + it(`is ${exp.strict ? "STRICT (rejects)" : "lax (strips)"} on an unknown key`, () => { + const schema = getBadgeClaimSchema(slug)!; + const withExtra = { ...exp.sample, __driftProbe: 1 }; + if (exp.strict) { + expect(() => schema.parse(withExtra)).toThrow(); + } else { + const parsed = schema.parse(withExtra) as Record; + expect(parsed).not.toHaveProperty("__driftProbe"); + } + }); + + // Reserved-key guard (mirror of the provider-side badge-types.drift.test). + // `id`, `issuanceMonth`, and `nullifier` are cross-cutting VC metadata that + // verify-badge.ts strips BEFORE schema.parse. No per-type schema may + // declare/echo one: a schema-declared `nullifier` would be silently eaten + // (and a strict schema requiring it would reject every badge of that type), + // and a schema-declared `id` would override the pairwise subject and fail + // every RP holder-binding check. + it("never echoes a reserved metadata key back through its schema", () => { + const schema = getBadgeClaimSchema(slug)!; + const withReserved = { + ...exp.sample, + id: "did:web:evil:u:attacker", + issuanceMonth: "1999-01", + nullifier: "mnv1:SMUGGLED", + }; + if (exp.strict) { + expect(() => schema.parse(withReserved)).toThrow(); + } else { + const parsed = schema.parse(withReserved) as Record; + for (const key of RESERVED_KEYS) { + expect(parsed, `${slug} echoed reserved key ${key}`).not.toHaveProperty(key); + } + } + }); + }); + } +}); diff --git a/src/badges/index.ts b/src/badges/index.ts index 9d93683..07f5268 100644 --- a/src/badges/index.ts +++ b/src/badges/index.ts @@ -8,7 +8,9 @@ // carries only what a relying party needs - claim schemas, slugs, // credentialType mappings, and scope helpers - and omits provider/UI // concerns (icon keys, display labels, issuance helpers). Because it is a -// copy it can drift; a drift-check against `@ministryofmany/shared` is planned. +// copy it can drift; `drift.test.ts` pins this registry (slug set, +// credentialType, sybilResistance, and schema shape) against a frozen +// transcription of `@ministryofmany/shared` — update both in lockstep. export * from "./schemas"; export { BADGE_TYPES, defineBadgeType, slugForCredentialType } from "./registry"; export type { BadgeTypeDef, SybilResistance } from "./registry"; diff --git a/src/index.ts b/src/index.ts index 61a5935..129dd41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export type { OidcFlowState, MinisterClaims, VerifiedBadge, + MinisterGatingNullifier, RejectedBadge, BadgesResult, ExchangeResult, diff --git a/src/types.ts b/src/types.ts index 398e6b6..c97a4ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,35 @@ export interface MinisterClaims { raw: string; } +/** + * Minister's per-relying-party Sybil-dedup nullifier (`mnv1:...`), the value + * Minister stamps in a disclosed badge's `credentialSubject.nullifier` (M5). + * + * BRANDED so it can NEVER be interchanged with the OTHER, unrelated nullifier + * primitive in this ecosystem — `@ministryofmany/nullifier`'s Poseidon/BN254 + * field string (`poseidon2(toField(sub), contextId)`), which is account-anchored + * and SNARK-provable. These two are permanently distinct (M3): + * + * | | `@ministryofmany/nullifier` | this `MinisterGatingNullifier` | + * |---|---|---| + * | math | Poseidon / BN254 | RFC 9497 VOPRF + HMAC-SHA256 | + * | anchor | the per-RP `sub` (account) | the credential (email, github id) | + * | circuit-usable | YES | NO (gating-only, plaintext compare) | + * | catches | same-account-across-contexts | same-credential-across-accounts | + * + * There is no conversion between them. A future circuit-usable credential + * nullifier must be a NEW Poseidon construction, never a bridge from this value. + * + * HONESTY: this proves ONE CREDENTIAL, not one person. It is per-site + * (different, unlinkable at other RPs), stable for the same credential (the same + * value if any account re-proves it here, surviving account delete/re-create), + * and only as strong as the credential behind it (see each badge type's + * `sybilResistance`). Gate on it; do not treat it as a unique-human oracle. + */ +export type MinisterGatingNullifier = string & { + readonly __brand: "MinisterGatingNullifier"; +}; + /** * A signature-verified, schema-validated badge. * @@ -101,6 +130,21 @@ export interface VerifiedBadge { // Undefined when the disclosing Minister predates the claim; freshness // checks then fail closed (no evidence ⇒ no maxAgeDays pass). issuanceMonth?: string; + // Minister's per-RP Sybil-dedup nullifier (`mnv1:...`), when present — a + // reserved `credentialSubject.nullifier` key Minister stamps under its + // signature at disclosure (M5). Bound to THIS badge's subject/jti/type/exp, + // so it cannot be lifted onto another credential or replayed as another user. + // + // Use it to gate on "one credential" (Sybil dedup, ban persistence): the SAME + // value appears if any account re-proves the same credential to YOUR site, and + // it PERSISTS across account delete/re-create; a DIFFERENT, unlinkable value + // appears at other sites. It is NOT a unique-human oracle — read + // `MinisterGatingNullifier` for the full honesty + non-interchangeability note. + // + // Undefined for badges with no wired nullifier (invite-code, age/residency, + // and any pre-M5 disclosure). Present-but-malformed (`!^mnv1:[A-Za-z0-9_-]+$`) + // fails the badge closed, like `issuanceMonth`. + nullifier?: MinisterGatingNullifier; // The original VC JWT, for storage or forwarding. raw: string; } diff --git a/src/verify-badge.test.ts b/src/verify-badge.test.ts index 200253a..73800a9 100644 --- a/src/verify-badge.test.ts +++ b/src/verify-badge.test.ts @@ -7,6 +7,11 @@ const ISSUER = "https://ministry.test"; const DID = "did:web:ministry.test"; const SUB = "did:web:ministry.test:users:u1"; +// A well-formed `mnv1:` tag with a REALISTIC 43-char base64url tail (Minister +// derives base64url of a 32-byte HMAC/VOPRF output — always 43 chars). The SDK +// now length-bounds the tail, so test fixtures must use realistic lengths. +const tag = (seed: string): string => `mnv1:${seed.padEnd(43, "0").slice(0, 43)}`; + interface SignOpts { claims?: Record; credentialType?: string; @@ -170,6 +175,84 @@ describe("verifyMinisterBadge", () => { ).rejects.toBeInstanceOf(VcVerificationError); } }); + + // --------------------------------------------------------------------------- + // Reserved per-RP Sybil nullifier: `credentialSubject.nullifier` (`mnv1:...`). + // Issuer metadata, NOT a per-type claim: surfaced as its own field, stripped + // BEFORE the (possibly strict) per-type schema parse, and format-checked. + // --------------------------------------------------------------------------- + + it("surfaces the nullifier as metadata and STRIPS it from the claims", async () => { + const { publicJwk, signVc } = await makeKeyAndSigner(); + const jwt = await signVc({ claims: { domain: "a.com", nullifier: tag("AbC-123_def") } }); + const badge = await verifyMinisterBadge(jwt, { issuer: ISSUER, key: publicJwk }); + expect(badge.nullifier).toBe(tag("AbC-123_def")); + expect(badge.claims).toEqual({ domain: "a.com" }); + }); + + it("keeps a STRICT nullifier-bearing schema (account-age) passing AND exposes the value", async () => { + // AccountAgeClaims is .strict(): if `nullifier` leaked into the schema + // parse, every disclosed account-age badge would be rejected. The strip + // must happen before validation, like `id`/`issuanceMonth`. + const { publicJwk, signVc } = await makeKeyAndSigner(); + const jwt = await signVc({ + credentialType: "MinisterAccountAgeCredential", + claims: { + provider: "github", + olderThanMonths: 24, + nullifier: tag("ACCOUNT_age_tag"), + issuanceMonth: "2026-03", + }, + }); + const badge = await verifyMinisterBadge(jwt, { issuer: ISSUER, key: publicJwk }); + expect(badge.type).toBe("account-age"); + expect(badge.claims).toEqual({ provider: "github", olderThanMonths: 24 }); + expect(badge.nullifier).toBe(tag("ACCOUNT_age_tag")); + expect(badge.issuanceMonth).toBe("2026-03"); + }); + + it("keeps a STRICT nullifier-bearing schema (social-following) passing AND exposes the value", async () => { + const { publicJwk, signVc } = await makeKeyAndSigner(); + const jwt = await signVc({ + credentialType: "MinisterSocialFollowingCredential", + claims: { provider: "github", followersAtLeast: 100, nullifier: tag("SOCIAL_tag_9") }, + }); + const badge = await verifyMinisterBadge(jwt, { issuer: ISSUER, key: publicJwk }); + expect(badge.type).toBe("social-following"); + expect(badge.claims).toEqual({ provider: "github", followersAtLeast: 100 }); + expect(badge.nullifier).toBe(tag("SOCIAL_tag_9")); + }); + + it("tolerates an absent nullifier (ref-less badge / pre-M5): verifies, field undefined", async () => { + const { publicJwk, signVc } = await makeKeyAndSigner(); + const jwt = await signVc({ claims: { domain: "a.com" } }); + const badge = await verifyMinisterBadge(jwt, { issuer: ISSUER, key: publicJwk }); + expect(badge.nullifier).toBeUndefined(); + }); + + it("rejects a present-but-malformed nullifier (fails closed on issuer drift / smuggle)", async () => { + const { publicJwk, signVc } = await makeKeyAndSigner(); + // Missing prefix, wrong prefix, illegal chars, non-string, empty, too-short + // (under the length bound), and UNBOUNDED-length (a compromised issuer + // stamping an arbitrarily long tag RPs would persist) — all must fail closed + // rather than gate on a garbage tag. + for (const bad of [ + "deadbeef", + "mnv2:abc", + "mnv1:", + "mnv1:has space", + "mnv1:plus+slash/", + "mnv1:tooShort", // valid charset but under the 20-char floor + `mnv1:${"A".repeat(65)}`, // over the 64-char cap (was unbounded before) + 123, + "", + ]) { + const jwt = await signVc({ claims: { domain: "a.com", nullifier: bad } }); + await expect( + verifyMinisterBadge(jwt, { issuer: ISSUER, key: publicJwk }), + ).rejects.toBeInstanceOf(VcVerificationError); + } + }); }); // --------------------------------------------------------------------------- diff --git a/src/verify-badge.ts b/src/verify-badge.ts index e464e53..aa239f2 100644 --- a/src/verify-badge.ts +++ b/src/verify-badge.ts @@ -4,7 +4,18 @@ import { badgeTypeOf, getBadgeClaimSchema } from "./badges/helpers"; import { didFromIssuer } from "./did"; import { verifyJwt } from "./jwt"; import { VcVerificationError } from "./errors"; -import type { KeyInput, VerifiedBadge } from "./types"; +import type { KeyInput, MinisterGatingNullifier, VerifiedBadge } from "./types"; + +// The disclosed per-RP Sybil nullifier is version-prefixed `mnv1:` followed by +// base64url. Strictly format-checked; a malformed value fails the badge closed. +// LENGTH-BOUNDED: Minister derives the tag as base64url of a 32-byte HMAC/VOPRF +// output — exactly 43 chars (Minister's own signet trust boundary pins {43}). +// The value is issuer-signed, so this is defense-in-depth: a bounded tail keeps +// a buggy/compromised issuer from stamping an arbitrarily long tag that RPs then +// persist in ban/dedup tables. The window is generous (20..64) rather than a +// hard {43} so a future same-shape construction is not brittle; a genuinely new +// wire shape versions behind `mnv2` and gets its own check. +const NULLIFIER_RE = /^mnv1:[A-Za-z0-9_-]{20,64}$/; // Verify a Minister-issued verifiable credential against Minister's PUBLIC badge // key. Unlike `@ministryofmany/vc`'s `verifyVc` (which threads a full Issuer @@ -217,14 +228,17 @@ export async function verifyMinisterBadge( ); } - // Validate the claims against that badge type's schema. Two RESERVED keys - // are stripped first: `id` (redundant with `sub`) and `issuanceMonth` - // (Minister's coarse-issuance metadata, stamped at disclosure re-mint — - // cross-cutting VC metadata, never a per-type claim; stripping it keeps - // strict per-type schemas like tlsn-attestation passing). + // Validate the claims against that badge type's schema. THREE RESERVED keys + // are stripped first: `id` (redundant with `sub`), `issuanceMonth` (Minister's + // coarse-issuance metadata), and `nullifier` (the per-RP Sybil-dedup tag, + // stamped at disclosure re-mint). All three are cross-cutting VC metadata, + // never per-type claims; stripping them BEFORE the schema parse keeps strict + // per-type schemas (account-age, social-following, tlsn-attestation) passing + // — a nullifier-bearing account-age badge would otherwise fail `.strict()`. const { id: _id, issuanceMonth: rawIssuanceMonth, + nullifier: rawNullifier, ...rawClaims } = credentialSubject; @@ -246,6 +260,20 @@ export async function verifyMinisterBadge( issuanceMonth = rawIssuanceMonth; } + // Format-check the per-RP nullifier when present. A malformed value means + // issuer drift or a claim-shaped smuggle upstream of the signature — fail + // closed rather than gate on garbage. Absent is fine (no wired nullifier, or + // a pre-M5 disclosure); gating code then treats the badge as untagged. + let nullifier: MinisterGatingNullifier | undefined; + if (rawNullifier !== undefined) { + if (typeof rawNullifier !== "string" || !NULLIFIER_RE.test(rawNullifier)) { + throw new VcVerificationError( + "VC `credentialSubject.nullifier` is not a well-formed mnv1 nullifier", + ); + } + nullifier = rawNullifier as MinisterGatingNullifier; + } + const schema = getBadgeClaimSchema(slug); let claims: Record; try { @@ -265,6 +293,7 @@ export async function verifyMinisterBadge( claims, subject: payload.sub, ...(issuanceMonth !== undefined ? { issuanceMonth } : {}), + ...(nullifier !== undefined ? { nullifier } : {}), raw: vcJwt, }; }