Skip to Content
ConceptsSigning & KeysBYOK Signing Spec

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 generationAttest ID generates itIssuer generates it
Key storageAttest ID, encrypted at restIssuer’s own HSM/KMS — never sent to Attest ID
SigningIssuer Registry’s internal SignPayload RPCIssuer signs locally before submitting
What Attest ID seesEverythingPublic 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 92 as 92.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.

  1. documentHash = SHA-256(canonicalize(document, proof excluded)) — 32 bytes
  2. configHash = SHA-256(canonicalize(proof options)) — 32 bytes
  3. signingInput = configHash || documentHash — 64 bytes, config hash first (eddsa-jcs-2022-specific ordering; the sibling eddsa-rdfc-2022 cryptosuite, 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 via Instant.ofEpochMilli(...), which is millisecond-only — signing a sub-millisecond Instant.now() produces a string that won’t match server re-canonicalization. Round-trip through Instant.ofEpochMilli(instant.toEpochMilli()) first.
  • credentialSubject: holder’s id plus every credential_data key except id (an id inside credential_data is ignored).
  • No proof field 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 }
FieldNotes
signature, key_id, credential_id, issuance_timestamp, proof_created_timestampBYOK-only — omit all five for custodial issuance
proof_created_timestampRequired whenever signature is present — missing it is 400
expiration_timestampOptional 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.