Skip to content

Credential export — design (v1)

Status: v1 milestone complete — slices 1–4 (OB 3.0) implemented & integration/conformance-validated (2026-06-13); slice 5 (CLR 2.0) added & validated (2026-06-15). Slice 1 (issuer profiles + Vault provisioning + KeySigner

  • JWKS), slice 2 (OB 3.0 assembly + learner-pulled export endpoint), slice 3 (the OB conformance lane, validating against the official 1EdTech schema), and slice 4 (the cross-lane Achievement.alignment projection to durable CASE node URIs) land in apps/api/src/features/credential/ + tests/conformance/. The CLR 2.0 assembler (slice 5) is built to clr-export-v1.md. Decisions this builds on: ADR-0013 (person-rooted identity bindings), ADR-0014 (erasure is anonymization — signed documents are un-erasable by construction), ADR-0015 (per-org issuer Profiles, platform-custodial keys, evidence by reference, sign-on-export), ADR-0016 (VC-JOSE/JWT proofs with Ed25519, Data Integrity deferred), ADR-0017 (Supabase Vault custody behind a KeySigner seam, OpenBao the named upgrade trigger) Design groundwork: credential-export-v1-brief.md, identity-issuer.md (runbook — the env-owned issuer-string precedent), case-competency-v1.md (durable competency-node URIs for alignment) Boundary contracts: @conform-ed/contracts open-badges/v3_0 (AchievementCredential, Achievement, AchievementSubject, IdentityObject, Profile), clr/v2_0 (CLR 2.0 assembler — slice 5), vc-data-model/v2_0
  1. Issuers are rows keyed to organization; the platform default is the one row with a null org. issuer_profile.organization_id is nullable — null is the platform default issuer for independent learners; one profile per org. Issuer provenance surviving org archival is already handled by the restrict FK on qualification_award.issuer_organization_id (orgs archive, never delete, so the issuer row always resolves) — the brief’s “replace the set null” is done in schema.
  2. Issuer URIs and JWKS URLs are computed, never stored. They derive from CREDENTIAL_ISSUER_BASE_URL (fallback https://localhost) + the profile id, exactly mirroring CASE_PROVIDER_BASE_URL / ONEROSTER_PROVIDER_BASE_URL. Environment-specific strings stay out of the schema (the identity-issuer runbook principle), and an issuer dying with its domain is semantically correct (ADR-0015) — the opposite of the subject-id rule.
  3. Ed25519 keys: private JWK in Supabase Vault, public JWK duplicated to a column. The private key lives in vault.secrets under a dedicated name scope (issuer_signing_key:{kid}); the public JWK is copied onto issuer_signing_key.public_jwk so JWKS serving never touches the vault. A dedicated credential_signer DB role is the only reader of decrypted issuer secrets (through a name-scoped view); the general application role may write secrets (encryption only) but can never decrypt them (ADR-0017 guardrail 1).
  4. The KeySigner seam is the single signing interface. Every signature flows through signCredentialJws(...) in apps/api; VaultKeySigner is the v1 implementation (private key enters app memory at sign time — the one recorded ADR-0017 deviation), connecting as credential_signer over a separate SIGNING_DATABASE_URL. Custody is an implementation detail; an OpenBaoKeySigner (transit sign, key never leaves the KMS) is the named upgrade behind the same seam (guardrail 2).
  5. kid is the signing-key row uuid; rotation is additive. Rotating mints a new active key and flips the prior key to retired; both stay in the JWKS, so every kid ever emitted keeps verifying. No kid is ever removed, and rotation is never a re-issuance event (ADR-0016/0017).
  6. Sign-on-export with a deterministic credential id. Nothing signed is stored. The credential id is urn:uuid:{uuidv5(awardId, CREDENTIAL_NS)} — stable per award, so re-export is stable and a future credentialStatus slot has a fixed target without storing artifacts (ADR-0015’s “identifier slots reserved at issuance”, honored by derivation, not a table).
  7. Pairwise subject identifiers are identity_binding rows. On export we reuse-or-mint a binding (provider = 'credential-subject', issuer = issuer_uri, external_subject = a minted pairwise URI) so the subject id is stable from one issuer and uncorrelatable across issuers (ADR-0013). The hashed-email IdentityObject salt lives in binding_meta; the binding is cascade erasure — deletion crypto-shreds the link.
  8. Alignment reuses competency_alignment. Rather than a new table, the competency lane adds a qualification_definition value to competency_alignment_target_type (the only coordinated cross-lane schema touch); the credential export reads alignments for the definition and projects Achievement.alignment to durable CASE node URIs ({CASE_PROVIDER_BASE_URL}/CFItems/{nodeId}). Additive per credential — the first signed credential ships with zero alignment rows (cross-lane note).
  9. Evidence by reference, including the audit log. The credential embeds achievement claims plus provenance references only (ADR-0014/0015). The sign-event log carries no direct person FK — an award husk reference, the credential urn, the kid, and a payload digest — so accountability never re-introduces PII into an un-erasable-adjacent record.

Issuer schema (packages/db/src/schema/credential-issuance.ts)

Section titled “Issuer schema (packages/db/src/schema/credential-issuance.ts)”

The CLR/OB issuer Profile, one per issuing Organization plus the platform default.

Column Notes
id uuid PK — the stable segment of the issuer URI and JWKS URL
organization_id FK → organization, restrict, nullable; null = platform default issuer. Restrict matches the award FK — issuer rows outlive nothing
status enum active | archived (no-destruction lifecycle; follows the org but is its own row)
created_by_person_id actor FK → person, set null (erasure: sever-class actor, like case_source/oneroster_source)
profile_meta jsonb — display overrides (name/url) when not projected from the org
audit columns auditColumnsUs()
  • Partial unique indexes: one profile per org (organization_id where not null) and exactly one platform default (where organization_id is null).
  • Display name/url project from the linked organization at assembly time (Emergent when platform-scoped) — not duplicated here, since the org row always survives (archival, never deletion).

One row per key version per profile. The public half lives here; the private half lives only in the vault.

Column Notes
id uuid PK — this is the kid (opaque, unique, durable; embedded in every JWS header)
issuer_profile_id FK → issuer_profile, cascade (profile is durable)
public_jwk jsonb — OKP/Ed25519 public JWK (+ kid, alg: "EdDSA", use: "sig"); the JWKS source, no vault read
vault_secret_name text — pointer into vault.secrets (issuer_signing_key:{id}); the private key is never in this table
alg text, default "EdDSA" (forward-compat; single algorithm in v1)
status enum active | retiredactive is selected for new signatures; retired keeps verifying
rotated_at timestamp, nullable — set when flipped to retired
audit columns auditColumnsUs()
  • Partial unique: at most one active key per profile ((issuer_profile_id) where status = 'active').
  • Not person-linked → no erasure declaration (keys are issuer infra; provisioning is captured by change_audit_entry + the sign-event log).

credential_sign_event (ADR-0017 guardrail 3)

Section titled “credential_sign_event (ADR-0017 guardrail 3)”

Application-level audit of every signature. Carries no PII by construction.

Column Notes
id uuid PK
issuer_profile_id FK → issuer_profile, cascade
signing_key_id FK → issuer_signing_key, set null (the key may be cleaned up in a far future; the event survives)
award_id FK → qualification_award, set null — the award is sever and survives erasure as a husk; this reaches it while linked, dangles after
credential_id text — the urn:uuid: emitted (decision 6); stable per award
payload_sha256 text — digest of the signed bytes for tamper-evidence; not the content
signed_at timestamp
created_at_us usColumn
  • No direct person FK → not a person-rooted table; no erasure declaration (the subject is reachable only through the severable award). Index (issuer_profile_id, signed_at) and (award_id).

Vault scope + signer role (custom migration credential_signing_role)

Section titled “Vault scope + signer role (custom migration credential_signing_role)”

A hand-written migration (the external_package_storage_bucket pattern — SQL the Drizzle schema cannot express):

-- Dedicated signing role: the ONLY reader of decrypted issuer secrets.
CREATE ROLE credential_signer NOLOGIN; -- operator grants LOGIN + password
GRANT USAGE ON SCHEMA public TO credential_signer;
-- Name-scoped decrypted view: issuer secrets only, never the whole vault.
CREATE VIEW public.issuer_signing_secret
WITH (security_barrier = true) AS
SELECT name, decrypted_secret
FROM vault.decrypted_secrets
WHERE name LIKE 'issuer_signing_key:%';
REVOKE ALL ON public.issuer_signing_secret FROM PUBLIC, anon, authenticated, service_role;
GRANT SELECT ON public.issuer_signing_secret TO credential_signer;
-- The signer reads keys + the public table, writes the audit log; nothing else.
GRANT SELECT ON public.issuer_signing_key TO credential_signer;
GRANT SELECT, INSERT ON public.credential_sign_event TO credential_signer;
  • vault.decrypted_secrets is owner/superuser-only by default, so no general role can decrypt — the scoped view is the single, audited read path.
  • Operator steps (never the agent, like the DB reset and app.identity_issuer settings): ALTER ROLE credential_signer LOGIN PASSWORD '…' and wire SIGNING_DATABASE_URL for the deploy. Recorded in a new runbook docs/runbooks/credential-issuer-keys.md.
  • Provisioning writes (vault.create_secret(...)) run on the normal admin connection — create_secret encrypts and never exposes plaintext, so the guardrail (locked decryption) holds.

KeySigner seam (apps/api/src/features/credential/key-signer.ts)

Section titled “KeySigner seam (apps/api/src/features/credential/key-signer.ts)”
interface KeySigner {
signCredentialJws(input: {
issuerProfileId: string;
credentialId: string; // for the audit row
awardId: string; // for the audit row (husk reference)
payload: Record<string, unknown>; // the assembled, contract-valid credential
}): Promise<{ jws: string; kid: string }>;
}

VaultKeySigner (v1):

  1. Select the active issuer_signing_key for the profile (kid, vault_secret_name).
  2. Over the signer connection (SIGNING_DATABASE_URL, role credential_signer): SELECT decrypted_secret FROM issuer_signing_secret WHERE name = $vault_secret_name → the private JWK.
  3. importJWK(privateJwk, "EdDSA") (jose, already a dependency).
  4. new CompactSign(payloadBytes).setProtectedHeader({ alg: "EdDSA", kid, typ: "vc+jwt", cty: "vc-ld+json" }).sign(key).
  5. INSERT credential_sign_event (profile, key, award, credential id, digest).
  6. Return { jws, kid }. The private key reference is dropped immediately; never logged, never returned.

The export service depends on the KeySigner interface, never the impl — the custody swap (OpenBao transit) is a one-file change behind the seam.

JWKS + issuer surface (apps/api/src/features/credential/jwks-routes.ts)

Section titled “JWKS + issuer surface (apps/api/src/features/credential/jwks-routes.ts)”

Public, unauthenticated, mirroring /lti/jwks:

  • GET /credentials/issuers/:profileId/jwks.json{ keys: [public_jwk, …] } for all keys of the profile (active + retired) — built from issuer_signing_key.public_jwk, no vault access. Short cache-control.
  • GET /credentials/issuers/:profileId → the OB 3.0 issuer Profile ({ "@context": …, id: issuer_uri, type: ["Profile"], name, url }), so the credential’s issuer.id URI resolves. Cheap; aids verifier resolution.

issuer_uri = {CREDENTIAL_ISSUER_BASE_URL}/credentials/issuers/{profileId}; the JWKS URL is {issuer_uri}/jwks.json. No first-party verify page in v1 (brief): verification is JWKS-first, the proof family JWKS natively serves (ADR-0016).

Export pipeline (apps/api/src/features/credential/export-service.ts)

Section titled “Export pipeline (apps/api/src/features/credential/export-service.ts)”

POST /workflow/credentials/achievements/:awardId/credential — the authenticated learner mints a fresh signed credential for one of their own granted awards. Nothing is persisted but the sign-event row.

  1. Authz (learner-self). Load the award; require award.person_id === principal.personId and award.state === 'granted' (else 404/403 — never distinguish “not yours” from “not found” to a learner). Resolve the issuer profile from award.issuer_organization_id (else the platform default).
  2. Subject identity. Reuse-or-mint the pairwise identity_binding (provider = 'credential-subject', issuer = issuer_uri) for this (person, issuer); read/generate the salt in binding_meta. Build the IdentityObject (hashed: true, identityType: "emailAddress", identityHash = "sha256$" + hex(sha256(email + salt)) — the Open Badges convention) from a person email person_contact_point; omit it when the learner has no email (the pairwise id alone is a valid subject).
  3. Assemble (core → OB, wire.ts). The one-vocabulary boundary:
    • Achievementqualification_definition: id = achievement URI, type: "Achievement", name = title, description + criteria.narrative from criteria_payload, humanCode = code, alignment projected from competency_alignment (decision 8 / slice 4; omitted when there are no alignment rows).
    • AchievementSubject: id = pairwise URI, achievement, identifier = [IdentityObject].
    • AchievementCredential: deterministic urn:uuid id, type ["VerifiableCredential", "OpenBadgeCredential", "AchievementCredential"], issuer = the issuer Profile/URI, validFrom + awardedDate = granted_at. No embedded proof — the enveloping JWS is the proof (VC-JOSE, ADR-0016).
  4. Totality guard. Validate the candidate against AchievementCredentialSchema (@conform-ed/contracts) before signing — fail closed (mirrors the CASE export’s parseCfPackage).
  5. Sign. KeySigner.signCredentialJws(...) → compact JWS; the audit row is written inside the signer.
  6. Return the compact JWS (application/vc+jwt-style). Nothing signed is stored.

The OB 3.0 conformance lane runs against the export surface, not as endgame QA — a runner alongside run-lrs-conformance-conform-ed.ts, run-cmi5-conform-ed.ts, and tools/testing/lti13-conformance/: tools/scripts/run-ob-conform-ed.ts (delegating to the integration compose harness) + tests/conformance/ob-credential.conformance.test.ts + docs/runbooks/ob-conformance-suite.md, run by bun run test:ob:conformance. It boots the stack, drives the real HTTP routes (provision issuer + key, grant a fixture award, POST the export endpoint, GET the JWKS + Profile document), then runs the battery: VC-JOSE shape, kid resolves in the JWKS and the signature verifies, and — the authoritative assertion — the decoded credential validates against the vendored official 1EdTech ob_v3p0_achievementcredential JSON Schema (draft 2019-09, via ajv; the upstream artifact the @conform-ed/contracts zod schemas are derived from), plus normative structural checks. Full green is the lane’s validated bar (standards.md).

  1. Issuer + signing infrastructure — DONE (2026-06-13). Schema, the signing-role migration, the KeySigner seam + VaultKeySigner, issuer provisioning + key generation/rotation, and the JWKS + issuer-Profile endpoints. Proven end-to-end (throwaway-podman integration) by signing a fixture payload and verifying it against the served JWKS.
  2. Export endpoint — DONE (2026-06-13). wire.ts OB assembly, pairwise subject-binding minting, learner-self authz, deterministic credential id, contract totality guard, sign-on-export. Unit (assembly/identity-object, route auth/translation) + integration (grant → export → external verify; another learner’s award → 404).
  3. OB conformance lane — DONE (2026-06-13). The runner + conformance test + runbook above; validates the live export against the official 1EdTech schema.
  4. Alignment projection — DONE (2026-06-13). The competency_alignment_target_type enum gains qualification_definition (greenfield baseline regenerated); the export reads alignments for the definition (lateral join to the durable node’s revision for statement text) and projects Achievement.alignment (targetType: CFItem) to {CASE_PROVIDER_BASE_URL}/CFItems/{nodeId}. Additive — credentials with no alignment rows omit the field. Covered by a wire unit test and an aligned- credential conformance test (validates against the official schema).
  5. CLR 2.0 assembler — built (slice 5); see clr-export-v1.md. Built after single-credential signing was proven (brief). Adds no schema: assembles the learner’s granted-award achievements into one platform-transcript ClrCredential, signed on the existing issuer/KeySigner/JWKS machinery, with its own conformance lane (bun run test:clr:conformance).

Scope is exactly the brief’s named first PR: issuer profiles + Vault provisioning + KeySigner + JWKS — signing and discovery, no credential assembly.

  • Schema (credential-issuance.ts): issuer_profile, issuer_signing_key, credential_sign_event + enums; register in schema/index.ts, schema-config.test.ts, schema-smoke.test.ts; add the issuer_profile sever/actor declaration to erasure-registry.ts (+ its registry test).
  • Migrations: regenerate the baseline (greenfield mode, the runbook) so the three tables land in initial_baseline; add the custom credential_signing_role migration (role + name-scoped vault view + grants). DB reset/apply is the operator’s step — the agent stops at “schema + migrations ready”.
  • KeySigner seam (key-signer.ts): the interface + VaultKeySigner + the lazy credential_signer signing-connection helper (SIGNING_DATABASE_URL).
  • Issuer service (issuer-service.ts, platform-admin authz via hasPlatformAdminGrant): provisionIssuerProfile (create the row; idempotent on org / platform-default), generateIssuerKey / rotateIssuerKey (Ed25519 keygen via jose/WebCrypto, vault.create_secret the private JWK, write public_jwk + flip the prior active key to retired). Error-descriptor + httpStatus pattern (the CASE export service).
  • Routes: POST /workflow/credentials/issuers, POST /workflow/credentials/issuers/:profileId/keys (rotate), GET /credentials/issuers/:profileId/jwks.json, GET /credentials/issuers/:profileId; path-aliases + app.ts registration.
  • Runbook credential-issuer-keys.md: the operator role-password + SIGNING_DATABASE_URL steps and rotation procedure.
  • Tests: unit (keygen shape, JWKS projection, rotation flips status); a podman-compose integration test that provisions a profile + key, fetches the JWKS, signs a fixture payload through VaultKeySigner, and verifies the JWS against the served key with jose — closing the custody → discovery loop before any credential exists.
  • credentialStatus / revocation — status-list storage; the stable credential id (decision 6) is the reserved slot. Its arrival is an ADR-0017 upgrade trigger (custody → OpenBao transit).
  • Data Integrity proofs (eddsa-rdfc-2022) — additive second emitter, no re-issuance; no RDF canonicalization in v1 (ADR-0016).
  • did:web issuer alias — additive to the HTTPS issuer URI if wallet ecosystems demand it (ADR-0015).
  • Learner-held keys — ADR-0013 custody ladder rungs 2–3; v1 subjects are keyless pairwise ids.
  • First-party verify page; admin/on-behalf export — JWKS-only, learner-self in v1.
  • OpenBao transit custody — the named upgrade behind the KeySigner seam (ADR-0017 guardrail 4).