BYOK Canonicalization & Signing Specification
Authoritative. If any client implementation — including Attest ID’s own reference SDK
(clients/byok-sdk-java) — disagrees with this document, this document wins.
Audience: any issuer implementing Bring Your Own Key (BYOK) signing, whether using the reference Java SDK or a from-scratch implementation in another language.
Custodial vs. BYOK
| Custodial (default) | BYOK | |
|---|---|---|
| Key generation | Attest ID generates it | Issuer generates it |
| Key storage | Attest ID, encrypted at rest | Issuer’s own HSM/KMS — never sent to Attest ID |
| Signing | Issuer Registry’s internal SignPayload RPC | Issuer signs locally before submitting |
| What Attest ID sees | Everything | Public key (at registration) + signature (at issuance) only |
The canonicalize → sign → verify flow
Proof format: W3C Data Integrity DataIntegrityProof, cryptosuite eddsa-jcs-2022 (see the
W3C EdDSA Cryptosuites for Data Integrity spec ). Every
implementation — this platform, the reference SDK, any from-scratch client — must match this
byte-for-byte or signatures fail verification.
JSON canonicalization
Both the credential document and the proof options are reduced to deterministic bytes:
- Sort object keys lexicographically (byte-wise, not locale-aware). Arrays keep original order.
- Compact JSON, no whitespace between tokens.
- Strings: escape
",\,\n,\r,\t; other control chars (< 0x20) as\u%04x. Never escape/or non-ASCII characters. - Numbers: parser’s own canonical form (don’t reformat
92as92.0). - UTF-8 encode the result.
This is simpler than full JSON-LD RDF normalization (URDNA2015) on purpose — Attest ID credentials are plain, fixed-shape JSON, not arbitrary JSON-LD graphs.
The document’s top-level proof field is always excluded before canonicalizing — it’s what the
signature secures, not part of what it secures. (A nested field also named proof, e.g. inside
credentialSubject, is real data and is kept.)
Proof options
Built before signing — every proof field except proofValue (the signature’s output):
{
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "2026-09-11T12:34:56.789Z",
"proofPurpose": "assertionMethod",
"verificationMethod": "did:key:z6Mk...#key-1"
}created and verificationMethod are picked by the issuer before signing — there’s no server
round trip to learn them first.
The signing input
Binds the proof options to the signature. Never sign the raw canonicalized document alone —
proof is excluded from the document’s own canonicalization, so a signature over the document
only would leave created/proofPurpose/verificationMethod unauthenticated.
documentHash = SHA-256(canonicalize(document, proof excluded))— 32 bytesconfigHash = SHA-256(canonicalize(proof options))— 32 bytessigningInput = configHash || documentHash— 64 bytes, config hash first (eddsa-jcs-2022-specific ordering; the siblingeddsa-rdfc-2022cryptosuite, unused here, orders the other way)
Sign with pure EdDSA (Ed25519, RFC 8032 — no prehashing, no context string). Result: 64 bytes,
encoded as multibase base58btc (z prefix + base58). A plain-Base64 signature is rejected.
Reference implementation: com.attestpro.shared.vc.VcCanonicalizer (platform) and
com.attestpro.byok.VcCanonicalizer (SDK, byte-for-byte identical, zero platform dependencies).
The credential shape to canonicalize
{
"@context": ["https://www.w3.org/2018/credentials/v1"],
"id": "urn:uuid:<credential_id>",
"type": ["VerifiableCredential", "<credential_type>"],
"issuer": { "id": "<issuer_did>" },
"issuanceDate": "<ISO-8601 instant>",
"expirationDate": "<ISO-8601 instant>",
"credentialSubject": {
"id": "<holder_did>",
"...": "additional claims from credential_data"
}
}id="urn:uuid:" + credential_id, a UUID the issuer generates itself.issuanceDate/expirationDate: ISO-8601 UTC, truncated to millisecond precision before signing. The wire format is Unix milliseconds and the server reconstructs viaInstant.ofEpochMilli(...), which is millisecond-only — signing a sub-millisecondInstant.now()produces a string that won’t match server re-canonicalization. Round-trip throughInstant.ofEpochMilli(instant.toEpochMilli())first.credentialSubject: holder’sidplus everycredential_datakey exceptid(anidinsidecredential_datais ignored).- No
prooffield yet.
Why the issuer generates id, issuanceDate, and proof’s created
A signature must be computed over bytes the signer can predict in advance. If Attest ID generated
these after receiving the request (as custodial issuance does), a BYOK issuer could never know
them before signing. So the issuance request carries caller-supplied credential_id,
issuance_timestamp, and proof_created_timestamp — the server rebuilds an identical
document shell and proof options from those same values, so its recomputed signing input matches
byte-for-byte. A signature submitted without proof_created_timestamp is rejected outright — the
server has no other way to know what created was signed over.
Custodial issuance omits all five BYOK-only fields; Attest ID generates them as usual.
API contract
All endpoints: Core Engine REST, reachable directly or via API Gateway.
Register as a BYOK issuer
POST /api/v1/issuer/register
{
"name": "University of Oxford",
"website_url": "https://www.ox.ac.uk",
"contact_email": "registrar@ox.ac.uk",
"country_code": "GB",
"byok": true,
"public_key": "<base64, raw 32-byte Ed25519 public key>"
}public_key is required when byok: true (400 otherwise); ignored when absent/false.
Response 201:
{
"issuer_id": "...",
"issuer_did": "did:key:z6Mk...",
"status": "PENDING_APPROVAL",
"signing_mode": "BYOK",
"error_message": null
}Every new issuer — BYOK or custodial — starts PENDING_APPROVAL. Issuance is rejected until an
admin approves via POST /api/v1/issuers/{issuer_did}/approve (see
System Design).
Issue a credential (BYOK)
POST /api/v1/credentials/issue
{
"issuer_did": "did:key:z6Mk...",
"holder_did": "did:key:...",
"credential_type": "UniversityDegreeCredential",
"credential_data": { "name": "Jane Doe", "degree": "MSc Computer Science" },
"signature": "<base64, raw 64-byte Ed25519 signature over the signing input>",
"key_id": "did:key:z6Mk...#key-1",
"credential_id": "<UUID the issuer generated and signed over>",
"issuance_timestamp": 1757592896000,
"expiration_timestamp": 1789128896000,
"proof_created_timestamp": 1757592896000
}| Field | Notes |
|---|---|
signature, key_id, credential_id, issuance_timestamp, proof_created_timestamp | BYOK-only — omit all five for custodial issuance |
proof_created_timestamp | Required whenever signature is present — missing it is 400 |
expiration_timestamp | Optional for both modes; defaults to issuance + 365 days |
Attest ID rebuilds the signing input from the request’s own fields and verifies signature
against the issuer’s current registered public key before accepting — an invalid signature is
400.
Response 201:
{
"credential_id": "...",
"operation_status": "SUCCESS",
"credential_status": "ISSUED",
"error_message": null
}The response does not echo the signed credential JSON — the issuer already has everything
needed to assemble it locally (the unsigned shell from above, plus the signature and key_id just
used).
Verify a credential
POST /api/v1/credentials/verify
{
"credential": { "...": "the fully signed W3C VC, including its proof object" },
"check_status": true,
"check_expiration": true
}Response 200:
{
"credential_id": "...",
"is_valid": true,
"status": "VALID",
"credential_type": "UniversityDegreeCredential",
"subject_name": "Jane Doe",
"issuance_date": "...",
"expiration_date": "...",
"results": {},
"issuer": {
"name": "University of Oxford",
"did": "did:key:z6Mk...",
"public_key_fingerprint": "..."
},
"proof": {
"algorithm": "eddsa-jcs-2022",
"verification_method": "did:key:z6Mk...#key-1",
"verified_at": "...",
"revocation_checked": true
},
"error": null
}On failure, error is {"code": "...", "message": "..."}.
Assembling the final signed credential
Attach a proof object — the exact proof options above plus the signature — to the exact unsigned
document shell that was canonicalized and signed:
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "<the exact proof_created_timestamp instant>",
"proofPurpose": "assertionMethod",
"verificationMethod": "<key_id used above>",
"proofValue": "<multibase base58btc signature>"
}Unlike the document’s own fields, created (and every proof-options field) is cryptographically
bound to the signature. A created here that doesn’t exactly match what was signed produces a
credential that fails verification.
This is the shareable, fully signed W3C Verifiable Credential.
Key custody
The issuer’s private key never leaves their own custody boundary. Attest ID’s API only ever receives a public key (registration) and a signature (issuance). Production BYOK issuers must sign using an HSM (PKCS#11), AWS KMS, Azure Key Vault, Google Cloud KMS, or equivalent — never a plaintext on-disk key. See BYOK Reference SDK for plugging a real key-custody backend into the reference SDK.