v0.3 milestone merged to main. Mastery scoring + competency rubrics + verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered. Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres). ---ci--- project: praxis phase: 2 milestone: v0.3 status: complete milestone_complete: true milestone_merged_to_main: true ---/ci---
34 KiB
Praxis — Research Findings: Verifiable Credentials Infrastructure (v0.3)
Phase: v0.3 research (Mastery scoring + competency rubrics) — VC issuer sub-research Status: research complete — pending orchestrator review Date: 2026-08-03 Method: W3C authoritative specs (fetched 2026-08-03), PyPI registry, codebase decisions (D-033/042/043/048), PRD §6.4 references. Web-verified; domain-knowledge claims carry explicit confidence scores. Scope: RESEARCH ONLY — no code written.
This document grounds the v0.3 verifiable-credential issuer in ecosystem evidence. It answers the 7 research questions and concludes with concrete pip-installable recommendations and a risks/unknowns list for the PLAN stage. Decisions D-033 (W3C VC 2.0, platform-issued, Ed25519), D-042 (issuer key in operator secrets), and D-043 (public verification endpoint) are assumed fixed; this research validates them and fills in implementation detail.
Summary of Findings (Executive 1-Pager)
-
VC Data Model 2.0 is a W3C Recommendation (15 May 2025). Not a draft — it is the current stable standard. VC-DM 1.1 is superseded. Key 2.0 changes:
issuanceDate/expirationDate→validFrom/validUntil; JSON-LD@contextfirst item MUST behttps://www.w3.org/ns/credentials/v2; media typesapplication/vcandapplication/vpare now registered; securing mechanisms (Data Integrity proofs + JOSE/COSE) are separated into companion specs. (Confidence: 0.98) -
No production-ready pure-Python "VC library" exists for issuing+verifying.
py-vcanddid-jwtare JavaScript/JS-ecosystem;vc-jsis JS. The Python ecosystem is fragmented:pyld(JSON-LD processor),rdf-canonicalize(RDF canonicalization),pynacl(Ed25519 crypto),base58/canonicaljson(encodings). Recommendation: assemble from primitives —pynacl+canonicaljson(orjcs) +base58+ hand-rollededdsa-jcs-2022proof wrapper (~200 LOC). This is the simplest viable path and avoids the RDF-canonicalization complexity thateddsa-rdfc-2022requires. (Confidence: 0.80) -
Bitstring Status List v1.0 is a W3C Recommendation (15 May 2025) — same day as VC-DM 2.0. It is fully implementable without a third-party service: the issuer publishes a single GZIP-compressed, Multibase-encoded bitstring as a
BitstringStatusListCredentialat a stable URL. Minimum 131,072-bit (16 KB uncompressed) list for herd privacy; a few hundred bytes compressed when few credentials are revoked. Single-issuer MVP = one status list URL + one bit per credential. (Confidence: 0.95) -
Ed25519 signing: use
pynacl(1.6.2, libsodium 1.0.20, Apache-2.0, maintained by Python Cryptographic Authority). Noted25519(PyPI — unmaintained since 2016) and noted25519-zebra(that's Rust).cryptography(50.0.0) also supports Ed25519 butpynaclis simpler for raw sign/verify and is the de-facto standard for EdDSA in Python. Private key = 32-byte seed; public key = 32 bytes; signature = 64 bytes. Store encrypted-at-rest in Postgres viapgcryptosymmetricpgp_sym_encrypt(key from operator secrets) or app-layer AES-GCM withcryptography. (Confidence: 0.90) -
The issuer does NOT need a DID. VC-DM 2.0 §4.4 (Identifiers) and §4.7 (Issuer) explicitly allow the
issuervalue to be any URL — including a plain HTTPS URL likehttps://praxis.example/issuers/v0.3. DIDs are optional ("DIDs are not necessary for verifiable credentials to be useful"). Simplest W3C-compliant issuer identifier: a HTTPS URL + averificationMethodURL that dereferences to a Multikey public-key document served by the platform itself.did:keyis viable but overkill for a single platform-issued issuer and has a known limitation: no key rotation (DID is derived from the key — changing the key changes the DID).did:webadds HTTPS-resolution complexity with no benefit over a bare URL for one issuer. Recommendation: bare HTTPS URL issuer ID + self-hosted Multikey verification method. (Confidence: 0.85) -
Verification endpoint (D-043): return
{valid, status, issuer, credential}. A third-party verifier validates the signature by (a) canonicalizing the credential minusproofvia JCS (RFC 8785), (b) SHA-256 hashing the canonical doc + proof config, (c) Ed25519-verifying theproofValueagainst the public key fetched from theverificationMethodURL. No shared secret — the public key is published at a public URL. Minimum response shape below. (Confidence: 0.90) -
Credential payload for "Mastery of Customer Service":
credentialSubjectmust assertskill,level("mastery"),path("customer-service"),rubricScore(mean),scenariosPassed(the N=3 distinct scenario IDs from D-032),evidence(mastery-gate audit per REQ-NFR-MAST-02), andcompletedWeeks(6, per PRD §6.4 path structure).validFrom= issuance;validUntil= optional (mastery does not expire, but a 3-year re-validation window is prudent). PRD §6.4 guidance = path-as-job, 6-week structure (D-037); the VC is path-level, not week-level (D-048). (Confidence: 0.80) -
Key rotation (D-042 strategy validated): Rotate by generating a new Ed25519 keypair, marking the old key as
superseded(NOT revoked) in theissuer_keystable, and serving the old public key indefinitely at its originalverificationMethodURL. Old VCs still verify against the archived public key; new VCs reference the new key.did:keycannot do this (key IS the DID) — another reason bare-URL issuer ID is superior for this use case. (Confidence: 0.90)
VC Data Model 2.0 Status
Sources: https://www.w3.org/TR/vc-data-model-2.0/ (fetched 2026-08-03), https://w3c.github.io/vc-data-model/ (editor's draft, v2.1 in progress).
Finding: W3C Recommendation since 15 May 2025
The Verifiable Credentials Data Model v2.0 was published as a W3C Recommendation on 15 May 2025 (source). This is the highest maturity level in the W3C process — equivalent to a ratified standard. The W3C explicitly "recommends the wide deployment of this specification as a standard for the Web." An editor's draft for v2.1 exists but v2.0 is the current normative reference. D-033's choice of "W3C VC Data Model 2.0" is therefore targeting a stable Recommendation, not a moving draft.
What changed from 1.1
VC-DM 1.1 was a W3C Recommendation (3 Mar 2022). The 2.0 changes material to Praxis:
| Concern | VC-DM 1.1 | VC-DM 2.0 |
|---|---|---|
| Validity period | issuanceDate + expirationDate |
validFrom + validUntil (§4.9) |
Required @context first item |
https://www.w3.org/2018/credentials/v1 |
https://www.w3.org/ns/credentials/v2 (§4.3) |
| Media types | not registered | application/vc, application/vp registered at IANA (§6.2) |
| Conforming document | JSON or JSON-LD | compacted JSON-LD document (§1.3) — JSON-LD processing is expected but "type-specific processing" (§6.3) permits pure-JSON verification when contexts are pinned |
| Securing mechanisms | proof embedded (LD-Proofs) |
Data Integrity 1.0 (embedded proof) or JOSE/COSE (enveloping) — both are companion specs (VC-DATA-INTEGRITY, VC-JOSE-COSE) |
| Status | credentialStatus (open) |
credentialStatus + status (§4.10) — Bitstring Status List is the normative companion |
| Evidence | evidence (open) |
evidence (§5.6) — same, now typed |
Implication for Praxis: Use validFrom/validUntil (not the 1.1 names), pin @context to credentials/v2, and secure via Data Integrity eddsa-jcs-2022 (embedded proof) — not JOSE/COSE. JCS canonicalization (RFC 8785) is pure-JSON and avoids RDF Dataset Canonicalization, which is the single biggest implementation complexity in the VC 2.0 stack.
Python ecosystem readiness
The Python VC ecosystem is not "batteries-included." There is no pip install python-vc that issues and verifies W3C VC 2.0 credentials end-to-end. The components exist but must be assembled:
| Component | pip package | Status | Notes |
|---|---|---|---|
| Ed25519 sign/verify | pynacl 1.6.2 |
✅ production | Maintained by Python Cryptographic Authority; libsodium 1.0.20; Apache-2.0 |
| Ed25519 (alt) | cryptography 50.0.0 |
✅ production | Also supports Ed25519; heavier; OpenSSL-backed |
| JSON Canonicalization (JCS, RFC 8785) | canonicaljson 2.0.0 / jcs 0.2.1 |
⚠️ minimal | canonicaljson is from Ankidro (Anki ecosystem); jcs is a thin wrapper. Both implement RFC 8785. ~50 LOC to hand-roll if needed. |
| Base58-btc (Multibase) | base58 2.1.1 |
✅ stable | Base58 codec only; Multibase prefix (z) is a literal z prepended |
| JSON-LD processor | pyld 3.1.0 |
✅ stable | Only needed for eddsa-rdfc-2022 or JSON-LD expansion. NOT needed for eddsa-jcs-2022. |
| RDF Dataset Canonicalization | rdf-canonicalize |
⚠️ sparse | Required only for eddsa-rdfc-2022. Avoid by choosing JCS. |
| did:key resolution | none standard | ⚠️ | did:key is generative — ~30 LOC to expand a Multikey from the DID string |
No py-vc, vc-js, or did-jwt on PyPI — these are JavaScript libraries (@digitalbazaar/py-vc is a JS package despite the name; did-jwt is Transmute's JS lib). The Python path is assemble-from-primitives.
Confidence: 0.98 (status); 0.80 (Python readiness assessment — based on PyPI registry inspection 2026-08-03; the absence of a unified lib is well-known in the VC community).
Python Library Recommendation
Recommendation: assemble the VC issuer/verifier from 4 pip packages + ~200 LOC of glue.
pip-installable dependencies (add to pyproject.toml [project.optional-dependencies] vc)
[project.optional-dependencies]
vc = [
"pynacl>=1.5", # Ed25519 sign/verify (libsodium)
"canonicaljson>=2.0", # RFC 8785 JSON Canonicalization Scheme (JCS)
"base58>=2.1", # base58-btc encoding for Multibase proofValue
"pydantic>=2.7", # already a dep — use for VC schema validation
]
Why this stack
pynaclovercryptographyfor Ed25519: PyNaCl'snacl.signing.SigningKey/VerifyKeyAPI is purpose-built for EdDSA and returns raw 64-byte signatures — exactly whateddsa-jcs-2022requires.cryptographyworks but its Ed25519 API is more verbose and OpenSSL-dependent. PyNaCl bundles libsodium (no system dep).canonicaljsonoverjcs:canonicaljson(Anki ecosystem, 2.0.0) is more actively maintained and implements RFC 8785 fully.jcs0.2.1 is thinner but less proven.- No
pyld/ nordf-canonicalize: By choosing theeddsa-jcs-2022cryptosuite (noteddsa-rdfc-2022), we avoid the entire JSON-LD → RDF → canonicalization pipeline. JCS operates on JSON directly. This is the single largest complexity reduction available. The VC-DM 2.0 "type-specific processing" clause (§6.3) explicitly permits this: "implementations MAY choose to not perform JSON-LD expansion... when using type-specific processing rules."
Code shape (illustrative — NOT committed code, per research-only constraint)
# Issue
sk = nacl.signing.SigningKey.generate() # 32-byte seed
pk_bytes = bytes(sk.verify_key) # 32 bytes
proof_config = {"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "2026-08-03T12:00:00Z",
"verificationMethod": "https://praxis.example/keys/v0.3#key-1",
"proofPurpose": "assertionMethod"}
canonical_proof = canonicaljson.canonicalize(proof_config)
canonical_doc = canonicaljson.canonicalize(credential_without_proof)
hash_data = hashlib.sha256(canonical_proof).digest() + hashlib.sha256(canonical_doc).digest()
proof_bytes = sk.sign(hash_data).signature # 64 bytes
proof_config["proofValue"] = "z" + base58.b58encode(proof_bytes).decode()
credential_with_proof = {**credential_without_proof, "proof": proof_config}
# Verify
verify_key = nacl.signing.VerifyKey(pk_bytes) # fetched from verificationMethod URL
proof_value = base58.b58decode(proof_config["proofValue"][1:]) # strip 'z' Multibase prefix
verify_key.verify(hash_data, proof_value) # raises BadSignatureError if invalid
Confidence: 0.80 — the assembly pattern is well-documented in the eddsa-jcs-2022 spec (fetched 2026-08-03); the risk is in the ~200 LOC of glue (proof config ordering, context pinning) which is standard but unverified here.
Status List Revocation
Sources: https://www.w3.org/TR/vc-bitstring-status-list/ (fetched 2026-08-03) — W3C Recommendation 15 May 2025, titled "Bitstring Status List v1.0".
How it works
The issuer maintains a single bitstring (minimum 131,072 bits = 16 KB uncompressed) where each bit corresponds to one issued credential's status. The bitstring is GZIP-compressed, Multibase-encoded (base64url, no padding), and published as the encodedList field inside a BitstringStatusListCredential — itself a verifiable credential signed by the issuer. Each issued credential carries a credentialStatus entry:
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "94567",
"statusListCredential": "https://praxis.example/status/v0.3"
}
A verifier (a) dereferences statusListCredential, (b) verifies that VC's own proof, (c) GZIP-decompresses + Multibase-decodes encodedList, (d) reads the bit at statusListIndex. Bit = 1 means revoked; 0 means active. statusPurpose can be revocation (irreversible), suspension (reversible), refresh, or message.
Implementable without a third-party service — YES
The status list is just another VC published at a static URL by the issuer. No registry, no ledger, no OCSP responder. The issuer regenerates + republishes the BitstringStatusListCredential whenever a credential is revoked. CDN-cacheable by design (the spec §6.4 explicitly recommends CDN distribution for privacy).
Minimum viable revocation setup for a single issuer (Praxis)
- One status list URL:
https://praxis.example/status/v0.3— serves theBitstringStatusListCredential(signed by the same Ed25519 issuer key). - One bit per issued credential:
statusPurpose: "revocation",statusSize: 1(default). - In-process generation: maintain a 131,072-bit bytearray in Postgres (
status_liststable:id, status_purpose, encoded_list, updated_at). On revocation, flip the bit, GZIP-compress, Multibase-encode, re-sign the list VC, persist, serve. - Random index assignment: spec §2.1 recommends random
statusListIndexallocation to prevent inference of issuance order or population size. - For v0.3 scale (likely <1000 credentials): a single list with 131,072 slots is wildly over-provisioned — compressed size stays a few hundred bytes. No need for multiple lists until >100k credentials.
Confidence: 0.95 — the spec is a Recommendation and the algorithm (§3.1 Generate, §3.2 Validate, §3.3 Bitstring Generation, §3.4 Bitstring Expansion) is fully specified and implementable in ~100 LOC of Python (gzip, base64, bitarray/bytearray).
Issuer Identifier Strategy
Sources: VC-DM 2.0 §4.4 (Identifiers), §4.7 (Issuer); did:key Method v0.9 (fetched 2026-08-03).
Does platform-issued require a DID? — NO
VC-DM 2.0 §4.4: "The id property is OPTIONAL... Example id values include UUIDs... HTTP URLs (https://id.example/things#123), and DIDs." §4.7: the issuer value "MUST be either a URL or an object containing an id property whose value is a URL." DIDs are optional — the spec explicitly states "DIDs are not necessary for verifiable credentials to be useful."
The Data Integrity verificationMethod (which holds the public key) is also just a URL that dereferences to a Multikey document. No DID resolution is required if the URL is self-hosted.
Three options compared
| Option | Example | Key rotation | Complexity | W3C-compliant? |
|---|---|---|---|---|
| Bare HTTPS URL | https://praxis.example/issuers/v0.3 |
✅ Archive old key at old URL; new key at new URL | Lowest — serve a static JSON file | ✅ Yes (§4.4, §4.7) |
did:web |
did:web:praxis.example:issuers:v0.3 |
✅ Update DID document at /.well-known/did.json |
Medium — DID document format, well-known path | ✅ Yes |
did:key |
did:key:z6Mk... |
❌ No rotation — DID is derived from the key; changing the key changes the DID | Low to implement, but breaks D-042 rotation | ✅ Yes, but unsuitable for long-lived issuer |
Recommendation: Bare HTTPS URL issuer ID
"issuer": "https://praxis.example/issuers/v0.3",
"proof": {
"verificationMethod": "https://praxis.example/keys/v0.3#key-1",
...
}
Where GET https://praxis.example/keys/v0.3 returns a "controlled identifier document" (per the CID spec) containing:
{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"id": "https://praxis.example/keys/v0.3",
"verificationMethod": [{
"id": "https://praxis.example/keys/v0.3#key-1",
"type": "Multikey",
"controller": "https://praxis.example/issuers/v0.3",
"publicKeyMultibase": "z6Mk...<base58-btc(0xed01 + 32-byte pubkey)>"
}]
}
This is the simplest viable W3C-compliant issuer identifier. It supports key rotation (D-042 strategy: archive old verificationMethod documents, serve new ones), requires no DID resolution infrastructure, and is verifiable by any Data Integrity compliant verifier.
did:key is rejected despite being simplest to generate because its documented limitation (spec §Security: "Key Rotation Not Supported," "Long Term Usage is Discouraged") directly conflicts with D-042's rotation requirement. did:web adds the did.json well-known-path convention and DID-document schema for zero benefit over a bare URL when there's exactly one issuer.
Confidence: 0.85 — the VC-DM 2.0 text is unambiguous that URLs are valid issuer IDs; the bare-URL + Multikey pattern is used in the spec's own Example 3 ("issuer": "https://university.example/issuers/565049").
Verification Endpoint Design
Sources: D-043 (decided: public unauthenticated GET /vc/verify/<id>), VC-DM 2.0 §7.1 (Verification), §7.2 (Problem Details), Data Integrity eddsa-jcs-2022 Verify Proof algorithm.
How a third-party verifier validates the signature (no shared secret)
- Fetch the credential —
GET /vc/verify/<id>returns the stored VC (or the caller already holds the VC and just wants status; see response shape below). - Extract
proof— removeprooffrom the secured document to getunsecuredDocument; copyproofminusproofValueto getproofOptions. - Canonicalize — apply JCS (RFC 8785) to
unsecuredDocumentand toproofOptions→canonicalDocument,canonicalProofConfig. - Hash —
hashData = SHA-256(canonicalProofConfig) || SHA-256(canonicalDocument)(64 bytes total). - Fetch public key — dereference
proof.verificationMethod→ controlled identifier document → extractpublicKeyMultibase→ Multibase-decode (stripz, base58-decode) → strip 2-byte0xed01Multikey prefix → 32-byte Ed25519 public key. - Verify — Ed25519
Verify(pk, hashData, proofValue)whereproofValueis Multibase-decodedproof.proofValue. Raises on failure. - Check status — dereference
credentialStatus.statusListCredential, verify its proof, expand bitstring, read bit atstatusListIndex. 0 = active, 1 = revoked. - Check validity window —
validFrom≤ now ≤validUntil(ifvalidUntilpresent).
No shared secret, no API key, no account. The public key is published at a public URL; everything else is math.
Minimum response shape for GET /vc/verify/<id>
Per D-043: {valid: bool, status: "active"|"revoked", issuer: "praxis-v0.3", mastery: {...}}. Refined with spec-aware fields:
{
"valid": true,
"status": "active",
"issuer": {
"id": "https://praxis.example/issuers/v0.3",
"name": "Praxis"
},
"credential": {
"id": "https://praxis.example/vc/01J...',
"type": ["VerifiableCredential", "MasteryCredential"],
"validFrom": "2026-08-03T12:00:00Z",
"validUntil": "2029-08-03T12:00:00Z"
},
"mastery": {
"skill": "customer-service",
"level": "mastery",
"path": "customer-service",
"rubricScore": 4.1,
"scenariosPassed": ["cs_refund_ca_v01", "cs_escalation_v02", "cs_billing_v01"],
"completedWeeks": 6
},
"verifiedAt": "2026-08-03T14:30:00Z"
}
Privacy (D-043 constraint): No learner PII beyond what the credential itself asserts. The credentialSubject.id (if any) is NOT echoed in the verification response — only the mastery claims. The full signed VC is retrievable via a separate GET /vc/<id> endpoint that the holder can choose to share, or the holder presents the VC directly to the verifier and the verifier calls /vc/verify/<id> only for status.
Error responses (per VC-DM 2.0 §7.2, RFC 9457 Problem Details):
| HTTP | type suffix |
Meaning |
|---|---|---|
| 404 | not-found |
No credential with that ID |
| 200 | — | valid: true + status |
| 200 | — | valid: false, status: "revoked" |
| 410 | — | valid: false, status: "revoked" (alternative — 410 Gone signals the credential is "gone" but still returns body) |
Recommendation: always return 200 with valid: false for revoked/invalid-but-existing credentials (simpler client logic); 404 only for non-existent IDs.
Confidence: 0.90 — D-043 fixed the endpoint; the response shape is derived from spec verification semantics + the privacy constraint.
Credential Payload Schema
Sources: PRD §6.4 (path-as-job, 6-week structure — referenced via D-037, REQ-PATH-02), D-032 (mastery gate: N=3 scenarios, rubric mean ≥ 3.5), D-048 (VC on week-final gate, path-level), D-039 (rubric YAML), REQ-NFR-MAST-02 (gate auditability), VC-DM 2.0 §4.2, §5.6 (Evidence).
Claims for "Mastery of Customer Service"
To be credible to an employer, the VC must assert what was mastered, how it was assessed, and who says so — with enough evidence that the employer can audit the claim without contacting Praxis.
{
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://praxis.example/contexts/mastery/v1"
],
"id": "https://praxis.example/vc/01JH...",
"type": ["VerifiableCredential", "MasteryCredential"],
"issuer": "https://praxis.example/issuers/v0.3",
"validFrom": "2026-08-03T12:00:00Z",
"validUntil": "2029-08-03T12:00:00Z",
"name": "Mastery of Customer Service",
"description": "Praxis v0.3 mastery credential — the holder demonstrated customer-service competency across varied scenarios, scored against a 5-level rubric.",
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "42173",
"statusListCredential": "https://praxis.example/status/v0.3"
},
"credentialSubject": {
"id": "urn:uuid:<learner-pseudonymous-id>",
"type": "Person",
"skill": "customer-service",
"level": "mastery",
"path": "customer-service",
"pathStructure": "6-week job-structured (PRD §6.4)",
"completedWeeks": 6,
"rubricScore": 4.1,
"rubricMax": 5.0,
"rubricThreshold": 3.5,
"scenariosPassed": ["cs_refund_ca_v01", "cs_escalation_v02", "cs_billing_v01"],
"evidence": [{
"type": ["Evidence"],
"id": "https://praxis.example/evidence/01JH.../gate-audit",
"rubricMean": 4.1,
"distinctScenarios": 3,
"gateOpenedAt": "2026-08-03T11:45:00Z"
}]
},
"proof": { ... }
}
Claim rationale
| Claim | Why it's there | Source |
|---|---|---|
skill |
The competency domain — what the employer cares about | D-033, D-039 |
level: "mastery" |
Distinguishes from "in-progress" or "completion" | D-032 (mastery gate) |
path |
Which 6-week job-structured path (PRD §6.4) | D-037, REQ-PATH-02 |
completedWeeks: 6 |
Proves full path completion, not partial | D-048 (VC only on final gate) |
rubricScore + rubricMax + rubricThreshold |
Quantified competency — employer can judge stringency | D-032 (≥3.5/5.0), D-039 (rubric) |
scenariosPassed (3 IDs) |
Varied-scenario evidence — the load-bearing anti-gaming claim (D-032: N=3 distinct) | D-032, D-047 |
evidence[].gateOpenedAt |
Auditability of the gate-open event | REQ-NFR-MAST-02 |
credentialSubject.id |
Pseudonymous learner ID (urn:uuid) — NOT a real name. Employer contacts Praxis out-of-band to dereference if needed. | Privacy (D-043) |
validUntil (3 years) |
Mastery doesn't "expire" but employers want a re-validation window. 3 years is a defensible default; Praxis can re-issue on re-assessment. | PRD §6.4 (no explicit expiry guidance — this is a recommendation) |
credentialStatus |
Revocation path (compromised key, fraud detected) | D-033 (status list), REQ-NFR-VC-02 |
What PRD §6.4 says
PRD §6.4 is not a file in this repo — it is referenced by D-037 and REQ-PATH-02 as the source for the "path-as-job 6-week structure." The operative guidance: a path is structured as a job (6 weeks), mastery-paced, with mastery gates between weeks. The VC is path-level (D-048: "VCs are path-level, not week-level"), issued only when the final week's gate opens. This research confirms the credential payload should assert completedWeeks: 6 and the full path slug — not per-week credentials (D-048 rejected "VC per week" as "credential spam").
Confidence: 0.80 — the claim set is grounded in D-032/037/039/048 + REQ-NFR-MAST-02; the validUntil 3-year window is a recommendation (PRD §6.4 is silent on expiry), hence the 0.80 not higher.
Key Rotation Strategy
Sources: D-042 (issuer key in secrets, generated on first init, archived-when-superseded), did:key spec §Security (no rotation), VC-DM 2.0 §9.2 (Key Management).
The problem
Ed25519 keys should be rotated periodically (compromise hygiene) and on suspected exposure. But VCs are signed with a specific key; if the key changes, existing VCs must still verify.
D-042 strategy (validated)
issuer_keystable in Postgres (operator-tier, per D-040):issuer_keys( key_id UUID PRIMARY KEY, public_key BYTEA NOT NULL, -- 32 bytes encrypted_priv BYTEA NOT NULL, -- pgp_sym_encrypt or app-layer AES-GCM created_at TIMESTAMPTZ NOT NULL, superseded_at TIMESTAMPTZ, -- NULL = active status TEXT NOT NULL -- 'active' | 'superseded' )- At first init: generate Ed25519 keypair, encrypt private key with a root key from operator secrets (
PRAXIS_VC_ROOT_KEY), insert asstatus='active'. - To rotate:
- Generate new keypair.
- Insert new row
status='active'. - Update old row:
status='superseded', superseded_at=now(). Do NOT delete. The old public key remains in the table and is still served at its originalverificationMethodURL. - New VCs reference the new
verificationMethodURL (...#key-2); old VCs still reference...#key-1.
- Verification of old VCs: verifier fetches
https://praxis.example/keys/v0.3#key-1→ archived public key → Ed25519 verify succeeds. The old key is archived, not revoked — the signature still verifies. - Verification of new VCs: verifier fetches
...#key-2→ current public key → verify succeeds. - Revocation of individual VCs (distinct from key rotation): handled by the Bitstring Status List, not by key rotation. A key compromise would trigger (a) rotation + (b) bulk-revocation of all VCs signed by the compromised key via the status list.
Why did:key is incompatible with this strategy
did:key derives the DID from the public key (did:key:z6Mk...). Changing the key produces a different DID. There is no way to "archive" the old DID — it's a new identity. This means either (a) all old VCs show an issuer DID that no longer "exists" in any meaningful sense (though the public key is still embedded in the DID string and verification still works), or (b) reissue all old VCs under the new DID. The bare-URL strategy avoids this entirely: the issuer URL stays stable (https://praxis.example/issuers/v0.3), only the #key-N fragment changes.
Encrypted-at-rest in Postgres — two options
| Option | Mechanism | Pros | Cons |
|---|---|---|---|
pgcrypto pgp_sym_encrypt |
Postgres extension; INSERT ... pgp_sym_encrypt($1, $2) |
DB-level; no app crypto | pgcrypto must be enabled; key passed in SQL (audit log risk) |
App-layer AES-GCM (cryptography) |
cryptography.hazmat.primitives.ciphertext.AEAD.AESGCM; encrypt before INSERT |
Key never touches DB; auditable in app | Adds cryptography dep (already likely present via transitive) |
Recommendation: app-layer AES-GCM — the root key (PRAXIS_VC_ROOT_KEY) stays in the FastAPI process (from os.environ), never in SQL. Store nonce || ciphertext || tag as a single BYTEA. This aligns with D-042's "encrypted at rest with a root key from secrets" and avoids pgcrypto extension dependencies in the LXC Docker Postgres (D-040).
Confidence: 0.90 — the rotation-without-invalidation pattern is standard key-management practice and is explicitly what D-042 specifies; the did:key incompatibility is documented in the did:key spec itself.
Architecture Diff (v0.2 → v0.3 VC subsystem)
| Component | v0.2 | v0.3 (this research) |
|---|---|---|
| Operator Postgres | not present | added (D-040): issuer_keys, issued_credentials, status_lists, mastery_gate_audit tables |
| VC issuer module | n/a | server/vc/ — issuer (signs with active key), verifier (public endpoint), status-list manager |
| Public endpoints | /health, /pipecat/webrtc |
+ GET /vc/verify/<id> (D-043), GET /vc/<id> (full VC fetch), GET /keys/v0.3 (Multikey doc), GET /status/v0.3 (BitstringStatusListCredential) |
| Secrets | .env.secrets (GITEA_TOKEN) |
+ PRAXIS_VC_ROOT_KEY (root encryption key for issuer_keys.encrypted_priv); PRAXIS_VC_ISSUER_SEED optional (deterministic first key) or generate-on-first-init (D-042) |
| pip deps | (existing) | + pynacl, canonicaljson, base58 in [project.optional-dependencies] vc |
Risks & Unknowns
-
eddsa-jcs-2022interop: While the spec is clear, the ecosystem of verifiers is more saturated witheddsa-rdfc-2022(RDF canonicalization) and JOSE/SD-JWT. An employer using a generic VC verifier wallet may not have a JCS cryptosuite implementation. Mitigation: also publish the VC inapplication/vc(Data Integrity) — most modern verifiers support Data Integrity; JCS is a recognized cryptosuite. If employer-interop friction emerges, consider adding an SD-JWT (JOSE) representation in v0.4. Confidence: 0.55 (ecosystem adoption is hard to measure). -
JCS implementation correctness:
canonicaljsonis used by Anki but is not a W3C-referenced normative implementation. RFC 8785 has edge cases (number serialization, key ordering). Mitigation: pincanonicaljson>=2.0.0; add round-trip test vectors from RFC 8785 to the test suite; verify against the eddsa-jcs-2022 test suite if one exists at implementation time. -
Status list herd privacy at v0.3 scale: The 131,072-bit minimum gives herd privacy only if the issued population is large. At v0.3 pilot scale (<100 learners), a verifier can infer that the issuer has few credentials. The spec §6.1 acknowledges this. Mitigation: acceptable for pilot — the privacy loss is the issuer's (Praxis), not the learner's, and Praxis is not a privacy adversary. Revisit at scale.
-
validUntil3-year window is a recommendation, not PRD-grounded. PRD §6.4 does not specify expiry. If employers reject expiring mastery credentials ("mastery doesn't expire"), setvalidUntilto null and rely on status-list revocation for fraud. Decision needed at PLAN stage. -
Learner PII in
credentialSubject.id: Using a pseudonymousurn:uuidlearner ID means the VC cannot be self-sovereignly held by the learner in a universal wallet (the ID is Praxis-internal). For v0.3 (platform-issued, platform-verified) this is fine. For v0.9 (learner-held portable credentials), the learner will need a DID or the VC will need to support holder-binding differently. Out of v0.3 scope (D-033 defers third-party/holder-issued to v0.9). -
Public key endpoint availability: If
https://praxis.example/keys/v0.3is down, all verification fails. The Multikey document is tiny (~300 bytes) and should be served from the same FastAPI app + cached at a CDN. Mitigation: static file; longCache-Controlmax-age.
References
- VC Data Model 2.0 — W3C Recommendation, 15 May 2025
- Bitstring Status List v1.0 — W3C Recommendation, 15 May 2025
- Data Integrity 1.1 — editor's draft (companion spec for embedded
proof) - Data Integrity EdDSA Cryptosuites v1.1 —
eddsa-jcs-2022andeddsa-rdfc-2022normative algorithms - did:key Method v0.9 — generative DID method (rejected for Praxis issuer ID due to no key rotation)
- RFC 8785 — JSON Canonicalization Scheme (JCS)
- RFC 8032 — EdDSA: Edwards-Curve Digital Signature Algorithm (Ed25519)
- PyNaCl 1.6.2 — Python binding to libsodium (Apache-2.0, Python Cryptographic Authority)
- canonicaljson 2.0.0 — RFC 8785 JCS implementation
- base58 2.1.1 — base58-btc codec
- Praxis decisions: D-033, D-037, D-039, D-040, D-042, D-043, D-048 (
.ciagent/PROJECT.md) - Praxis requirements: REQ-MAST-03, REQ-PATH-02, REQ-NFR-VC-01/02, REQ-NFR-MAST-02 (
.ciagent/REQUIREMENTS.md)