Warning
This is a throw-away repository, built for testing the LCW sandbox's issuance flow. It is not meant for long-term use: expect it to change without notice, be reset, or disappear entirely.
A Verifiable Credential issuer for the LCW sandbox, defined with AWS SAM: a badge issuer (the on-demand LCW Sandbox Badge), a batch issuer (notification emails plus collection-time issuance for batches built in the wallet's batch-issuer-ui), and the S3+CloudFront-hosted React collection page wallets claim from (https://issuer.lcw-sandbox.org). All issuance runs over VCALM workflow exchanges.
One workflow, lcw-sandbox-badge:
| Method | Path | Purpose |
|---|---|---|
| POST | /workflows/lcw-sandbox-badge/exchanges |
Create an exchange; returns its URL and the CHAPI-ready verifiablePresentationRequest (DIDAuthentication query, challenge, domain, and interact.service endpoints). An optional JSON body {"name": "..."} puts that name on the issued credential's subject |
| POST | /workflows/lcw-sandbox-badge/exchanges/{exchangeId} |
Participate: an empty body gets the DIDAuthentication request; a body with verifiablePresentation gets verified and answered with the issued credential. The LCW mobile wallet's shape is also accepted — a bare signed presentation as the body (challenge only, no domain), answered with the bare presentation holding the credential |
| GET | /workflows/lcw-sandbox-badge/exchanges/{exchangeId} |
The exchange's state |
| POST | /workflows/lcw-sandbox-badge/notifications |
Send a claim email: {"name", "email"} → SES mails the address a link to the collection page with ?name= attached |
The wallet proves control of a DID by signing a DIDAuth presentation over the
exchange's challenge and domain (Ed25519Signature2020). On success the badge
is issued with credentialSubject.id set to that DID, signed with the
issuer's seed-derived did:key
(did:key:z6MkkCNaxehr7RoeDJQP39oQ1yFbmUg29ziXfLwoyeCo1QFf), and returned
inside a presentation envelope — the exchange's final result. A completed
exchange replays its result idempotently; exchanges expire after 15 minutes
(DynamoDB TTL).
Two more Lambdas serve batches created in the wallet's Credential Issuer
screen; each batch lives in its own WAS space (a Space whose type array carries BatchSpace).
lcw-batch-notify(POST /notify,src/notify/): a zCap invocation signed by the wallet's registered DID, whose batch space must be registered to that DID. For each recipient row it stages a KMS-encrypted bundle (the template id, issuer details, CSV fields, and a per-run signing seed) atcollections/{credId}/bundle.jsonin the batch space, records acredId → spacepointer in the bundles table, emails the recipient a collection link carryingcredIdand the decryption contextctx(which is never stored), and appends to the space'slogs/log.json— by credId only, no recipient data.lcw-batch-exchange(/workflows/batch-credential/exchanges...,src/batch-exchange/): the collection page drives it like the badge exchange. At collection time it decrypts the bundle (thectxfrom the link's query string is bound into the KMS encryption context), populates the template through the credential-templates API, allocates a Bitstring Status List position (credentialStatusgoes on the credential; the revocation token into the batch log — a repeat collection reuses the credential's one position), binds the credential to the holder's DIDAuth-proved DID, and signs with the batch's seed-deriveddid:key. A credential whose log entry is marked revoked is no longer collectable: new exchanges and replays both answer 410.
collection-page/ is a small React app with two faces: the badge flow below,
and the batch flow — opened with ?credId=...&ctx=... from a notification
email, it drives the batch-credential exchange for that one staged credential.
Opened plain (https://issuer.lcw-sandbox.org), it prompts for the name to
put on the badge: Continue to claim proceeds in this browser (writing ?name=
into the URL), or an email address turns it into a mailed claim link — the
notifications endpoint emails a link back to this page with the name as a
?name= query parameter. Opened with a name, it shows the badge card for
that name with two claim options, each creating an exchange (passing the name
along, so the issued credential's subject carries it): Add to Web Wallet
hands the presentation request to the user's wallet via
CHAPI, and Add to Mobile Wallet builds the
Learner Credential Wallet app's
request deep link
(https://lcw.app/request?issuer=…&vc_request_url=…&challenge=…&auth_type=bearer),
shown as a QR code to scan plus a tappable link for phones. The CHAPI call
goes through
navigator.credentialsPolyfill.credentials.get rather than
navigator.credentials.get, which password-manager extensions (e.g.
1Password) can lock — a call that reaches the native API throws "No credential
type was specified in the request". The page must be served over HTTPS (CHAPI
requires a secure context), which is why the template fronts the bucket with
CloudFront rather than S3 website hosting.
IssuerSeed(NoEcho, required) — 64-char hex seed for the issuer's Ed25519 key. Keep it stable: the deriveddid:keyis what verifiers register (the LCW sandbox wallet lists it as LCW Sandbox Issuer).DomainName/CertificateArn/HostedZoneId— the collection page's domain (defaultissuer.lcw-sandbox.org), its ACM certificate, and the Route 53 zone the alias records go in.NotifyFromEmail/SesIdentity— the address claim-notification emails are sent from (defaultissuer@lcw-sandbox.org) and the verified SES identity it sends under (defaultlcw-sandbox.org, the domain identity the lcw-back-end stack verified).TemplatesApiBase— the credential-templates API the batch exchange populates credentials from.StatusApiBase/StatusApiKey— the Bitstring Status List service (defaulthttps://status.lcw-sandbox.org) and its/allocatekey (NoEcho), used at collection time.
sam build
sam deploy --guided # pass IssuerSeed; later deploys reuse it
cd collection-page
echo "VITE_EXCHANGE_API=<ExchangeApi output>" > .env.production
npm install && npm run build
aws s3 sync dist/ s3://<CollectionPageBucket output>/ --delete
aws cloudfront create-invalidation --distribution-id <CollectionPageDistributionId output> --paths "/*"cd src/issuer && npm install && npm test
cd src/batch-exchange && npm install && npm testEach runs its whole exchange in-process with mocked AWS clients, playing the
wallet side with its own did:key. The badge suite: create →
DIDAuthentication request → a wrong challenge is rejected → a valid DIDAuth
presentation gets the badge, bound to the holder and signed by the
seed-derived issuer DID → the signature verifies → the completed exchange
replays its result. The batch suite additionally covers the ctx-bound
decryption, template population, the status position (allocated once,
recorded in the log, reused on re-collection), and that a revoked credential
is no longer collectable.
The lcw-front-end repo's npm run test:claim drives the same flow against
the deployed API using the wallet's own signing stack.