Verifiable credentials and Open Badges
A Certichain credential lives on chain as an anchor plus a credential package (the JSON file the issuer hands to the holder). Other software does not read that package. This flow turns the package into two standard shapes that other wallets and verifiers understand:
- a W3C Verifiable Credential 2.0 file (
credential-<8 hex>.vc.json), and - an Open Badges 3.0 file (
badge-<8 hex>.ob.json) for record types that are achievements.
Both are views of the same issuer signature. Nothing new is signed for a plain VC or a plain badge: the proof inside them is the issuer's existing EIP-712 signature over the credential, with the signed fields next to it so anyone can recompute it. Optionally the issuer can add a second, standard eddsa-rdfc-2022 proof to a badge, which stock Data Integrity verifiers can check without any Certichain code.
The file format is described field by field in Verifiable credential file. The standards themselves are introduced in Standards.
Who takes part
| Actor | What they do |
|---|---|
| Issuer | Downloads a VC or badge from the issued card after issuing, or from Credential tools on /dashboard. Only the issuer can add the standard proof to a badge. |
| Holder | Exports a VC or badge from their own package on /student (panel Export as a verifiable credential). |
| Verifier | Checks any of these files without a wallet on /verify, in the panel Check a verifiable credential file, with the command line script, or through POST /api/v1/verify. |
| Publisher (server) | Optional. Signs the Bitstring Status Lists that a VC can point to (see Status lists). |
Preconditions
- A v0.3 credential and its credential package. v0.2 records have no package and cannot be exported.
- For the holder details (name, student id, programme, award) to appear in the file, the package must contain its
displayblock andmetadataSalt. A package without them gives a VC whose details are "unproven". - For an Open Badge, the record type must be an achievement (see Achievement mapping).
- For the standard badge proof: the issuer's own wallet, an externally owned account (not a contract wallet), on the credential's chain.
- For status list entries in the export: the deployment has
NEXT_PUBLIC_STATUS_LIST_BASE_URLandNEXT_PUBLIC_STATUS_LIST_PUBLISHERset, and the credential is at least 64 blocks deep. As of 2026-10-24 the handbook lists these settings as not yet made on the hosted Sepolia site, so exported VCs there carry the chain status entry only.
Where it starts in the UI
| Route | Place | Labels | Who sees it |
|---|---|---|---|
/records/add | Issued card, group Other formats | Download as verifiable credential, Download as Open Badge, Download as Open Badge with standard proof | The issuer, right after issuing |
/dashboard | Credential tools, panel Export as a verifiable credential | Format (Verifiable credential, Open Badge, Open Badge with standard proof), Package to export | A registered issuer |
/student | Tool group Show only what is needed, panel Export as a verifiable credential | Include my wallet address as the subject id (anyone who gets two of your credentials can then link them), Format (Verifiable credential, Open Badge), Package to export | A holder |
/verify | After a lookup, under further checks, panel Check a verifiable credential file | Verifiable credential file | Anyone, no wallet |
Two differences between the places matter:
- The issued card exports the VC with no status list entries and no subject id. The two panels ask the status list checker for entries when lists are on.
- The standard proof option appears only where the page can sign as the issuer: the issued card and the dashboard panel. The student page never offers it.
How a VC is built
exportVc(pkg, options) in src/lib/vc/export.ts reads only the signed fields, the signature and the display details of the package. The document key, the storage id and the attribute data of selective disclosure have no path into the output.
| VC member | Taken from |
|---|---|
@context | The VC 2.0 context URL and the embedded Certichain context (CERTICHAIN_CONTEXT in src/lib/vc/constants.ts) |
id | urn:certichain:eip155:<chainId>:<registry>:<credentialId> |
type | ["VerifiableCredential", "CertichainCredential"] |
issuer.id | did:pkh:eip155:<chainId>:<issuer address> |
validFrom | issuedAt as RFC 3339 (YYYY-MM-DDTHH:MM:SSZ) |
validUntil | validUntil as RFC 3339, left out when it is 0 (no expiry) |
credentialSubject | recordType, plus holderName, studentId, programme, award when the package has details; id (a did:pkh of the holder wallet) only when the holder opts in |
credentialStatus | A CertichainChainStatus entry (chain, registry, credential id), optionally followed by a revocation and a suspension BitstringStatusListEntry |
proof | A DataIntegrityProof with cryptosuite certichain-eip712-2026: the issuer signature as proofValue, the signing domain, the eight signed fields and the two salts |
The proof is the reason the file can be trusted. The rest of the document must agree with it, and the checker compares every visible claim (issuer, dates, record type, status entry, verification method) with the signed fields.
What the cryptosuite signs
The issuer signed one EIP-712 message when the credential was issued (see Signatures). Its domain is Certichain, version 1, the chain id and the CredentialRegistry address. Its type is:
Credential(address issuer, bytes32 subjectCommitment, bytes32 documentHash,
bytes32 metadataHash, uint32 recordType, uint64 issuedAt,
uint64 validUntil, uint256 nonce)
The credential id is the EIP-712 digest of that message. A verifier recomputes the digest from proof.signedFields and proof.signingDomain, checks that the id URN and the status entry name it, and recovers the signer from proofValue. The holder details are checked by recomputing metadataHash (with metadataSalt) and subjectCommitment (with subjectSalt), as described in Hashing and commitments. The full description, written for other implementers, is in vc/cryptosuite-certichain-eip712-2026.md.
Exporting a VC
- The user picks Format and drops the package on Package to export. Files over 256 KiB are refused with "This file is too large to be a credential package."
- When lists are on,
VcService.listOptionsasks the checker for the credential's position among its issuer's credentials that are at least 64 blocks deep. A newer credential gets no entries, and the saved message ends with "Status list entries were left out: this credential is not yet confirmed deeply enough to have a stable position. Export it again in about 15 minutes." - The file is built in the browser and downloaded. The panel shows Verifiable credential saved.
No wallet prompt is involved in a VC export or a plain badge export.
A VC or badge file carries the holder's name and student id in clear, so the panel tells the user to share it only with someone who may see them. To show fewer fields, use a selective disclosure file instead. The optional subject id links every credential the holder exports with it, which is why it is opt-in.
Exporting an Open Badge
exportOpenBadge(pkg, options) in src/lib/vc/openbadges/export.ts builds an OpenBadgeCredential from the same package and puts the same Certichain proof on it.
| Badge member | Value |
|---|---|
@context | VC 2.0 context, the Open Badges 3.0.3 context (https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json), the embedded Certichain context |
type | ["VerifiableCredential", "OpenBadgeCredential"] |
name | The award, else the programme, else the record type name |
issuer | { id: did:pkh..., type: ["Profile"] } |
credentialSubject.type | ["AchievementSubject"] |
credentialSubject.identifier | Two IdentityObjects, unhashed: name (holder name) and ext:studentId |
credentialSubject.achievement | id urn:certichain:achievement:<chainId>:<issuer>:<recordType>:<8 hex of keccak(programme or award)>, achievementType, name, a fixed description and criteria narrative, fieldOfStudy (programme) |
credentialStatus | One entry only (the schema allows one): the chain entry, or the revocation list entry when lists are on |
evidence | One Evidence link to <site>/verify?id=<credentialId> named "Certichain anchor" |
proof | The Certichain proof (one proof), or an array of two proofs with the standard proof |
The achievement id is the same for every holder of the same programme at the same issuer, so a programme reads as one achievement.
Achievement mapping
achievementMapping(recordType) in src/lib/vc/openbadges/achievement-type.ts decides which record types are achievements.
| Record type | achievementType |
|---|---|
| 1 Degree | Degree |
| 3 Diploma | Diploma |
| 4 Certificate, 5 Provisional Certificate, 27 Internship Certificate | Certificate |
| Issuer-defined types (1000 and above) | Achievement (the registry's name for a custom type is not signed, so it is never put in a badge) |
| Any other built-in type (transcripts, receipts, notices and so on) | Not exportable: "<Type> is not an achievement, so it cannot be exported as an Open Badge." |
Adding the standard proof to a badge
A plain badge carries only the Certichain proof, which stock Open Badges and Data Integrity tools do not understand. The standard proof fixes that: the issuer signs the badge with an Ed25519 key under the registered eddsa-rdfc-2022 cryptosuite (RDF dataset canonicalisation of the JSON-LD document).
The problem is that the issuer has an Ethereum wallet, not an Ed25519 key. The app derives one from the wallet, deterministically, and has the wallet vouch for it:
- Derive. The wallet signs the EIP-712 message
OpenBadgeKey { owner, keyVersion }under the domainCertichain keys(version1, the chain id). It signs it twice. If the two signatures differ, the wallet does not sign deterministically and the export stops ("This wallet does not sign the same message the same way twice..."). HKDF-SHA256 over the signature (saltcertichain.badge-key.v1, info = owner address and key version) gives a 32-byte Ed25519 seed. The public key becomes the issuer'sdid:key. - Attest. The wallet signs
OpenBadgeKeyBinding { registry, issuer, publicKey, keyVersion }under the same domain. This key binding says "this Ed25519 key is mine for this registry". - Sign. The badge body is built with issuer id = the
did:key, plus anotherIdentifierentry of typeext:didPkhnaming the registereddid:pkh. The Ed25519 key signs it witheddsa-rdfc-2022. The Certichain proof, now carrying thekeyBinding, is added as the second proof.
The seed is never stored. It lives in the page's memory for the session, scoped to the account, chain and registry, and is dropped when any of them changes. Signing the same message again later re-creates the same key.
Wallet prompts
| Order | Prompt | Kind | When |
|---|---|---|---|
| 1 | OpenBadgeKey (domain Certichain keys) | Signature, no gas | First standard badge in the session |
| 2 | The same OpenBadgeKey again | Signature, no gas | First standard badge in the session |
| 3 | OpenBadgeKeyBinding | Signature, no gas | First standard badge in the session |
Later standard badges in the same session reuse the key and ask for nothing. The UI states this: "Your wallet signs three requests the first time: two to make a signing key and one to vouch for it."
Refusals
| Situation | Message |
|---|---|
| The package fails the offline checks (edited details, wrong signature) | "This credential package is not genuine: its details or signature do not match what the issuer signed, so it was not signed as an Open Badge." |
| Connected account is not the issuer | "Connect the issuer account <address> to sign this badge. The standard proof must come from the issuer's own wallet." |
| Wrong network | "The wallet is on a different network than this credential. Switch network and try again." |
| Contract wallet issuer | "This account is a contract wallet, whose signatures are not guaranteed to repeat, so it cannot sign an Open Badge with the standard proof. Use the badge with the Certichain proof, or a plain wallet account." |
| Account changed between prompts | "The wallet account changed while signing, so the signature came from a different account than the issuer wallet. Switch back to the issuer account and try again." |
| User declines a prompt | "The wallet request was declined, so nothing was signed or saved." |
The package check matters because the display details are only hash-bound: a holder could edit them, and the issuer's Ed25519 key must never sign details nobody checked.
A stock verifier that checks only eddsa-rdfc-2022 learns that some key signed the badge and that nothing changed since. It does not learn that the key belongs to a registered institution. That link is the key binding inside the Certichain proof, which only Certichain checks read. The interop tests confirm this: a badge re-signed with an unrelated Ed25519 key passes the stock suite.
Checking a file
The check runs the same way for a VC and a badge. parseCredentialFile reads the type: anything with OpenBadgeCredential is parsed as a badge, the rest as a VC. Both parsers are strict: exact key sets, pinned contexts and types, bounded text (500 characters per detail, 200 for an issuer name), and a 65,536 character limit for the whole file.
Offline checks (no network)
verifyViewOffline in src/lib/vc/verify.ts:
| Check | Result values | Meaning |
|---|---|---|
| Consistency | ok / mismatch with names | Every visible claim agrees with the signed fields: issuer, validFrom, validUntil, recordType, credentialStatus (chain entry and list entries point at this chain, registry, issuer and id), created, verificationMethod. A badge adds identifier, achievementType, name, achievement, evidence, title, issuerKey and, when the standard proof fails, standardProof. |
| Id | match / mismatch | The URN names the digest of the signed fields |
| Details | verified / unproven / mismatch | Name and student id (and programme and award) hash to metadataHash with the salt; the subject commitment matches when subjectSalt is present. No details at all is unproven, never verified. |
| Signature | valid / needs-contract-check | Recovered from the one canonical spelling (65 bytes, v of 27 or 28). Anything else, including an ERC-1271 signature, needs the chain. |
| Expired | true / false | validUntil is set and in the past |
On-chain checks
VcService.verifyOnChain (only when the file's chain and registry are the ones this page reads):
| Check | Values |
|---|---|
chain | match / other-chain |
credential | found / not-found |
anchor | The chain's record equals the signed fields, field by field |
issuerRegistered | The issuer is registered now and its key is not retired |
active | StatusRegistry.isValid: not revoked, suspended or expired, and the issuer is not suspended |
signature | valid when offline recovery worked, or the issuer is a contract and isValidSignature confirms it |
holder | not-claimed (no subject id), match, mismatch, or unbound (no wallet bound for this student) |
isFullyVerified is true only when everything above passes, the details are not mismatch, the file is not expired, the holder is not-claimed or match, and the list entries are not mismatch. A list that is unreachable, stale or disagrees is shown as a caution line but never overrides the chain.
What the verify page shows
The heading is Credential verified, Credential verified, details not proven (no details in the file), or Credential not verified, followed by one line per problem. Examples:
- "Parts of the file differ from what the issuer signed: issuer, validFrom."
- "The details in the file do not match the hashes the issuer signed."
- "This credential was made for a different network or registry."
- "The credential is no longer active (revoked, suspended, expired or its issuer is suspended)."
- "The subject id is not the wallet the chain binds to this student."
- "The key that signed this file is not one the issuer's wallet attested for this registry."
- For a badge with a valid standard proof: "The standard proof is valid. It shows that a key signed this file; only the key the issuer's wallet attested is the issuer's."
A file about a different credential than the one looked up is refused with "This file belongs to a different credential." A network failure gives "The check could not be completed because the network lookup failed. This is not a verification result. Please try again." The footer reminds the verifier that the issuer name in the file is not signed and the registered name should be used.
Command line
From blockchain/:
npx ts-node scripts/verify-credential.ts credential-6c4ee9b6.vc.json
npx ts-node scripts/verify-credential.ts badge-6c4ee9b6.ob.json --rpc <url> --registry <CredentialRegistry address>
Without --rpc and --registry the script runs the offline checks and says plainly that the issuer, registry and revocation status are not verified. With them it adds the anchor, registration, signature and active checks. It exits 0 when valid, 1 when not, 2 on an error. See Command line tools.
Verification API
POST /api/v1/verify accepts a VC or badge file and returns a signed verdict built from the same VcService checks (src/lib/server/api/verify-file.ts). See REST API.
Interoperability findings
The interop/ package checks these files with the Digital Bazaar libraries (@digitalbazaar/vc 7.3.0, @digitalbazaar/data-integrity 2.5.0, @digitalbazaar/eddsa-rdfc-2022-cryptosuite 1.3.0, @digitalbazaar/vc-bitstring-status-list 2.0.1) with no network access. The report is docs/interop/report.md. What a reader of these docs should know:
- A suite plugin works. The library accepts a subclass of
DataIntegrityProofforcertichain-eip712-2026. The plugin checks signature and consistency only; registration, suspension and revocation still need the chain. - A stock verifier learns only that a key signed a badge with the standard proof. Who owns the key is in the key binding, which no stock verifier reads.
- Status lists need two changes to the library's defaults: turn off the "list issuer equals credential issuer" check (lists come from a separate publisher key) and pin the publisher address instead. The library verifies with the real clock, so a list is valid only within its 10 minute window.
- List entries in a plain VC are not signed by the Certichain proof. A verifier that recomputes the position from the chain (as the app does) catches a moved index; the standard proof of a badge does cover the entry.
- The plain badge proof does not cover every badge field. With the Certichain proof alone, a changed
achievement.namepasses the plugin; the Certichain parser and the standard proof catch it.
Limits
- The issuer name in a file is not signed. Verifiers should read the registered name.
- A future
validFromis not rejected by the offline checks. - Contract wallet issuers can export VCs and plain badges (checked through ERC-1271 on chain) but cannot add the standard proof.
- The standard proof is made in the browser that holds the issuer wallet; there is no server-side signing.
- The VC and badge subjects have no
awardedOnmember. The display block of a credential can carryawardedOn(bulk import sets it when the file has an award date), andmetadataHashcovers it. Reading the code, such a credential exports without the date, so its details check would come out as a mismatch. No test covers this case as of 2026-10-24. - The Open Badges 3.0 conformance validator run is a manual check that is still open as of 2026-10-24.
Source files
src/lib/vc/constants.ts,src/lib/vc/types.ts,src/lib/vc/export.ts,src/lib/vc/parse.ts,src/lib/vc/fields.ts,src/lib/vc/verify.ts,src/lib/vc/detect.ts,src/lib/vc/to-package.tssrc/lib/vc/openbadges/export.ts,src/lib/vc/openbadges/constants.ts,src/lib/vc/openbadges/achievement-type.ts,src/lib/vc/openbadges/parse.ts,src/lib/vc/openbadges/verify.tssrc/lib/vc/openbadges/standard/key.ts,src/lib/vc/openbadges/standard/export.ts,src/lib/vc/openbadges/standard/eddsa-rdfc-2022.ts,src/lib/vc/openbadges/standard/did-key.ts,src/lib/vc/openbadges/standard/verify.tssrc/services/domain/vc-service.ts,src/services/domain/badge-service.tssrc/components/vc/VcExportPanel.tsx,src/app/records/add/CredentialIssuedCard.tsx,src/app/verify/VcCheckPanel.tsxsrc/lib/server/api/verify-file.tsblockchain/scripts/verify-credential.ts,blockchain/scripts/lib/verify-vc.tsvc/cryptosuite-certichain-eip712-2026.md,vc/contexts/,vc/schemas/,vc/test-vectors/golden-vc.json
Tests
src/lib/vc/export.test.ts,parse.test.ts,verify.test.ts,detect.test.ts,golden.test.ts(the golden vector),schema.test.ts,time.test.ts,to-package.test.tssrc/lib/vc/openbadges/*.test.ts(export, parse, verify, achievement type, schema against the vendored 1EdTech schema, signing error text)src/lib/vc/openbadges/standard/*.test.ts(key derivation,did:key, base58, document loader,eddsa-rdfc-2022, export, verify and its error cases)src/services/domain/vc-service.test.ts,src/services/domain/badge-service.test.ts,src/components/vc/VcExportPanel.test.tsx,src/app/records/add/CredentialIssuedCard.test.tsxinterop/(independent library checks, run by theInteropCI job)- E2E:
e2e/journeys/vc-export.journey.ts(2 tests),e2e/journeys/open-badge.journey.ts(3 tests)