Skip to main content

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​

ActorWhat they do
IssuerDownloads 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.
HolderExports a VC or badge from their own package on /student (panel Export as a verifiable credential).
VerifierChecks 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 display block and metadataSalt. 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_URL and NEXT_PUBLIC_STATUS_LIST_PUBLISHER set, 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​

RoutePlaceLabelsWho sees it
/records/addIssued card, group Other formatsDownload as verifiable credential, Download as Open Badge, Download as Open Badge with standard proofThe issuer, right after issuing
/dashboardCredential tools, panel Export as a verifiable credentialFormat (Verifiable credential, Open Badge, Open Badge with standard proof), Package to exportA registered issuer
/studentTool group Show only what is needed, panel Export as a verifiable credentialInclude 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 exportA holder
/verifyAfter a lookup, under further checks, panel Check a verifiable credential fileVerifiable credential fileAnyone, 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 memberTaken from
@contextThe VC 2.0 context URL and the embedded Certichain context (CERTICHAIN_CONTEXT in src/lib/vc/constants.ts)
idurn:certichain:eip155:<chainId>:<registry>:<credentialId>
type["VerifiableCredential", "CertichainCredential"]
issuer.iddid:pkh:eip155:<chainId>:<issuer address>
validFromissuedAt as RFC 3339 (YYYY-MM-DDTHH:MM:SSZ)
validUntilvalidUntil as RFC 3339, left out when it is 0 (no expiry)
credentialSubjectrecordType, plus holderName, studentId, programme, award when the package has details; id (a did:pkh of the holder wallet) only when the holder opts in
credentialStatusA CertichainChainStatus entry (chain, registry, credential id), optionally followed by a revocation and a suspension BitstringStatusListEntry
proofA 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​

  1. 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."
  2. When lists are on, VcService.listOptions asks 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."
  3. 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.

Personal data in clear

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 memberValue
@contextVC 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"]
nameThe award, else the programme, else the record type name
issuer{ id: did:pkh..., type: ["Profile"] }
credentialSubject.type["AchievementSubject"]
credentialSubject.identifierTwo IdentityObjects, unhashed: name (holder name) and ext:studentId
credentialSubject.achievementid urn:certichain:achievement:<chainId>:<issuer>:<recordType>:<8 hex of keccak(programme or award)>, achievementType, name, a fixed description and criteria narrative, fieldOfStudy (programme)
credentialStatusOne entry only (the schema allows one): the chain entry, or the revocation list entry when lists are on
evidenceOne Evidence link to <site>/verify?id=<credentialId> named "Certichain anchor"
proofThe 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 typeachievementType
1 DegreeDegree
3 DiplomaDiploma
4 Certificate, 5 Provisional Certificate, 27 Internship CertificateCertificate
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:

  1. Derive. The wallet signs the EIP-712 message OpenBadgeKey { owner, keyVersion } under the domain Certichain keys (version 1, 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 (salt certichain.badge-key.v1, info = owner address and key version) gives a 32-byte Ed25519 seed. The public key becomes the issuer's did:key.
  2. 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".
  3. Sign. The badge body is built with issuer id = the did:key, plus an otherIdentifier entry of type ext:didPkh naming the registered did:pkh. The Ed25519 key signs it with eddsa-rdfc-2022. The Certichain proof, now carrying the keyBinding, 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​

OrderPromptKindWhen
1OpenBadgeKey (domain Certichain keys)Signature, no gasFirst standard badge in the session
2The same OpenBadgeKey againSignature, no gasFirst standard badge in the session
3OpenBadgeKeyBindingSignature, no gasFirst 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​

SituationMessage
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.

What the standard proof proves

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:

CheckResult valuesMeaning
Consistencyok / mismatch with namesEvery 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.
Idmatch / mismatchThe URN names the digest of the signed fields
Detailsverified / unproven / mismatchName 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.
Signaturevalid / needs-contract-checkRecovered from the one canonical spelling (65 bytes, v of 27 or 28). Anything else, including an ERC-1271 signature, needs the chain.
Expiredtrue / falsevalidUntil 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):

CheckValues
chainmatch / other-chain
credentialfound / not-found
anchorThe chain's record equals the signed fields, field by field
issuerRegisteredThe issuer is registered now and its key is not retired
activeStatusRegistry.isValid: not revoked, suspended or expired, and the issuer is not suspended
signaturevalid when offline recovery worked, or the issuer is a contract and isValidSignature confirms it
holdernot-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:

  1. A suite plugin works. The library accepts a subclass of DataIntegrityProof for certichain-eip712-2026. The plugin checks signature and consistency only; registration, suspension and revocation still need the chain.
  2. 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.
  3. 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.
  4. 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.
  5. The plain badge proof does not cover every badge field. With the Certichain proof alone, a changed achievement.name passes 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 validFrom is 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 awardedOn member. The display block of a credential can carry awardedOn (bulk import sets it when the file has an award date), and metadataHash covers 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.ts
  • src/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.ts
  • src/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.ts
  • src/services/domain/vc-service.ts, src/services/domain/badge-service.ts
  • src/components/vc/VcExportPanel.tsx, src/app/records/add/CredentialIssuedCard.tsx, src/app/verify/VcCheckPanel.tsx
  • src/lib/server/api/verify-file.ts
  • blockchain/scripts/verify-credential.ts, blockchain/scripts/lib/verify-vc.ts
  • vc/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.ts
  • src/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.tsx
  • interop/ (independent library checks, run by the Interop CI job)
  • E2E: e2e/journeys/vc-export.journey.ts (2 tests), e2e/journeys/open-badge.journey.ts (3 tests)