DEV Community

Darshit Khandelwal
Darshit Khandelwal

Posted on

LFDT: Week 5 Report

Week 5: Actually Issuing the Credential

Firstly before starting, apologies to everyone who might be following with me, I was busy with my internship interviews, (I had an interview with Google, I would talk about that in some other blog in detail).
So, I was unable to do much of the work, but, now I am back on track with full energy and motivation !
The first four weeks were about building the foundation , the schema, the DID, the GPG verification, the identity binding. Week 5 is where the foundation stops being theoretical. This is the week a real signed credential actually gets issued into a contributor's wallet.

Here's everything that happened.


The Problem This Week Solves

At the end of Week 4, we had a ContributorBinding record in the database that said: "this person's GitHub account, GPG key, and Heka wallet are all confirmed as belonging to the same human."

That's meaningful, but it's still just a database row. It lives entirely inside Heka's infrastructure. A wallet can't hold it. A verifier can't check it independently without calling Heka's API. It's not portable, and it's not self-contained.

What we actually want is a Verifiable Credential — a cryptographically signed document the contributor holds in their own wallet, which any verifier can check without calling home.

Week 5 is how we get there.


What is OID4VCI?

OpenID for Verifiable Credential Issuance (OID4VCI) is the protocol that defines how an issuer hands a credential to a wallet. It's the handshake between Heka (the issuer) and the contributor's wallet (the holder).

The short version of how it works:

  1. The wallet discovers what credentials an issuer can issue, via a .well-known/openid-credential-issuer metadata document
  2. The issuer creates a credential offer — a URI starting with openid-credential-offer:// that the wallet scans or follows
  3. The wallet authenticates and presents proof of key possession
  4. The issuer signs the credential and delivers it

This week implemented steps 1 and 2. Steps 3 and 4 are handled by the existing Credo/AFJ framework already wired into heka-identity-service.


The Static Issuer: One Global Issuer for the Prototype

One design decision I want to explain upfront: we are not building multi-tenant dynamic issuer provisioning. This is a prototype, and the goal is to validate the end-to-end flow cleanly without admin overhead.

Instead, there is a single static global issuer — the pre-existing demo tenant (the same DEMO_USER account already used in heka-auth-service). On every application startup, a bootstrap sequence runs:

  1. Look up the demo user's wallet → get their Credo tenant ID
  2. Open their tenant agent
  3. Check if an OID4VCI issuer record exists for their did:hedera DID
  4. If not → create it (including the GithubContributorCredential profile)
  5. If yes → check if GithubContributorCredential is already in the supported list
  6. If not → add it (idempotent)
  7. If yes → log and skip

The entire sequence is idempotent by design. Restarting the application never creates duplicate issuers or duplicate credential configurations. The bootstrap can fail gracefully (missing wallet, missing DID) without crashing the application — it just logs a warning.

This is the same pattern used by the PrepareWalletService in this codebase. When the infrastructure is ready, everything self-configures.


The GithubContributorCredential Profile

The credential type registered in the issuer metadata is GithubContributorCredentialSdJwt, with a VCT (Verifiable Credential Type) URI of:

https://hiero.ledger.org/vct/GithubContributorCredential
Enter fullscreen mode Exit fullscreen mode

This URI is how wallets and verifiers identify what kind of credential they're dealing with. It's the same identifier defined in the Week 2 schema.

The credential profile declares all five claims:

claims: {
  githubAccountId: { mandatory: true },
  githubUsername:  { mandatory: true },
  gpgFingerprint:  { mandatory: true },
  verifiedAt:      { mandatory: true },
  walletId:        { mandatory: true },
}
Enter fullscreen mode Exit fullscreen mode

SD-JWT: Selective Disclosure in Practice

The credential format is SD-JWT VC (Selective Disclosure JWT Verifiable Credential). The "selective disclosure" part is what makes this interesting.

A standard JWT credential puts all its claims in the open. Anyone who receives it can see everything. An SD-JWT VC lets the holder choose which claims to reveal and which to keep private, on a per-presentation basis.

The "disclosure frame" is what controls this. It's a configuration that tells the signing framework which claims should be placed in cryptographically blinded digest groups (_sd) rather than revealed directly:

// Week 2 disclosure policy
const disclosureFrame = {
  _sd: ['githubUsername', 'gpgFingerprint']
}
Enter fullscreen mode Exit fullscreen mode

What this means in practice:

Claim Always in the credential? Can the holder hide it?
githubAccountId ✅ Always revealed No — it's the identity anchor
verifiedAt ✅ Always revealed No — it's the audit timestamp
walletId ✅ Always revealed No — it's the wallet binding
githubUsername Digest only ✅ Yes — holder can choose
gpgFingerprint Digest only ✅ Yes — holder can choose

The design intent: a contributor should be able to prove they are a verified Hiero contributor (by showing their numeric account ID and verification timestamp) without necessarily revealing their GitHub handle or GPG key fingerprint to every verifier. The holder controls what gets shared.


Deterministic Verification Method Selection

When signing the SD-JWT VC, you need to select which verification method (which key) from the issuer DID document to sign with. If you pick differently each time, different verifiers may not be able to reproduce the check.

The approach here is simple and consistent with how the rest of heka-identity-service handles this: take the first verification method from the resolved DID document. For a did:hedera DID with a single key pair (which is the standard setup for the demo tenant), this is always the same key. Deterministic, predictable, no surprise.


The Credential Offer Endpoint

POST /v2/contributor-credential/offer
{ "githubAccountId": "12345678" }
Enter fullscreen mode Exit fullscreen mode

The service:

  1. Looks up the ContributorBinding for the given GitHub account ID
  2. Throws 404 if no binding exists (contributor hasn't completed OAuth login)
  3. Throws 409 if the binding exists but GPG verification is incomplete
  4. Resolves the demo tenant's did:hedera DID
  5. Builds the SD-JWT VC payload from the binding fields
  6. Applies the Week 2 disclosure frame
  7. Calls Credo's createCredentialOffer() directly
  8. Returns the openid-credential-offer:// URI

A key implementation detail on step 7: I'm calling tenantAgent.openid4vc.issuer.createCredentialOffer() directly rather than routing through OpenId4VcIssuanceSessionService.offer(). The reason is that the session service unconditionally calls statusListService.getOrCreate(authInfo) — which needs a full User entity from the database — before checking whether the credential even uses revocation. SD-JWT VCs don't support revocation (it's in the spec). Routing around the status list check for SD-JWT VCs is actually the architecturally correct thing to do here.


Two Bugs Found During Review

I did a deep review pass before calling Week 5 done. Two real bugs caught:

Bug 1: Wrong enum value (silent failure)

The updateIssuerMetadata call was using:

action: 'Add' as const  // ❌ Wrong
Enter fullscreen mode Exit fullscreen mode

The actual enum is:

UpdateIssuerSupportedCredentialsAction.Add = 'add'  // lowercase
Enter fullscreen mode Exit fullscreen mode

The switch statement in issuer.service.ts was hitting the default branch and silently doing nothing. The credential config would never have been registered on existing issuers. Fixed to use the proper enum import.

Bug 2: Fake AuthInfo crashing the status list service

Earlier I was constructing a minimal auth-info object to pass to the session service. The status list service does em.find(CredentialStatusList, { owner: authInfo.user }) — and authInfo.user was undefined, which would have been a runtime crash on every credential offer request. Fixed by bypassing the session service entirely (see step 7 above).

Both bugs were caught by reading the actual source of every dependency — not just the types. TypeScript alone won't catch a structural type that's missing a field when the missing field is only accessed at runtime inside a service you're calling.


Test Coverage

11 unit tests covering:

  • Bootstrap creates issuer when demo wallet exists and no issuer is registered
  • Bootstrap skips when credential config already registered
  • Bootstrap logs warning without throwing when demo wallet is missing
  • Bootstrap uses the correct 'add' enum value (explicit assertion)
  • Credential offer returns a valid openid-credential-offer:// URI
  • Payload carries all five Week 2 claims with correct values
  • Disclosure frame contains exactly the two selectively disclosable claims
  • Non-selectively-disclosable claims are absent from _sd
  • Verification method from DID document is passed through (deterministic selection)
  • NotFoundException when no binding exists
  • ConflictException when binding is GPG-unverified
  • NotFoundException when demo tenant wallet is missing

307 total tests, all passing.


How the Five Weeks Connect

Looking back at the full arc:

  • Week 1: Project setup, infrastructure, development environment
  • Week 2: Credential schema, disclosure policy, DID on Hedera testnet
  • Week 3: GPG challenge-response — cryptographic proof of key ownership
  • Week 4: Identity binding — tying GPG key, GitHub account, and Heka wallet together permanently
  • Week 5: OID4VCI issuance — turning that binding into a real signed credential a wallet can hold

Each week was a necessary prerequisite for the next. You can't issue a credential without a schema (Week 2). You can't bind an identity without verifying the key (Week 3). You can't issue a meaningful credential without a completed binding (Week 4).


What's Next

Week 6 moves into the Web Wallet UI and the PR-gating flow — the two pieces that make the credential actually useful. A contributor will be able to present their GithubContributorCredential when opening a PR, and the GitHub App will check the verification status before allowing the PR to be merged.

The credential is now real. Next week it starts doing work.

See you then.


This is part of my LFDT Mentorship 2026 blog series. Each week I write about what I built, what I learned, and what surprised me. If you're working on decentralized identity, verifiable credentials, or open source supply chain security, I'd love to hear from you.

Top comments (0)