@certrev/cert-contract
v0.5.4
Published
CertDeliveryEnvelope: detached-Ed25519-over-RFC-8785(JCS) signed certification credential. Signs structured facts, not rendered JSON-LD. The verify path is edge-runtime safe (WebCrypto, no node:crypto); the issuer sign path is on './signer'.
Maintainers
Readme
@certrev/cert-contract
The signed, platform-agnostic CertDeliveryEnvelope: CertREV's portable "this content was reviewed by a credentialed expert" credential. Sign once, deliver everywhere; every render edge (WordPress plugin, Shopify theme app extension, headless-React SDK, Web Component) verifies it and renders the badge + JSON-LD from the verified facts.
What this is
CertREV's durable asset isn't the WordPress block or the Shopify block; it's a forgery-proof credential. The issuer mints + signs one CertDeliveryEnvelope per certified placement. Each delivery edge reproduces the canonical bytes, verifies the detached Ed25519 signature, runs the fail-closed VerdictKernel (subject / lifecycle / drift), and only then renders.
This package is the shared seam both render layers (and the issuer) build against, so they can't silently diverge.
The two design invariants
1. Sign FACTS, not rendered JSON-LD
payload.content holds structured facts: expert (displayName, credentials[], profileUrl, photoUrl), author, memo, certifiedAt, contentModifiedAt, verifyUrl, plus the optional articleTitle / displayCertId (v0.5, signed). display is deprecated from v0.2: evicted to brand_render_defs, accepted on read from v0.1.x envelopes and never written by a current issuer. The schema.org JSON-LD is projected from these facts at the edge; it is NOT in the signed payload. A schema.org change (new property, vocabulary tweak) therefore never forces re-signing every live credential; only the edge's projection changes.
2. Detached Ed25519 over RFC-8785 (JCS) canonicalization of payload
signature.sig = base64url( Ed25519_sign( privKey, RFC8785_canonicalize(payload) ) )Any language that implements RFC 8785 (JSON Canonicalization Scheme) reproduces byte-identical signing input, so a PHP libsodium verifier and a Node WebCrypto verifier check the same signature over the same bytes. The signature is detached; it does NOT cover signature itself. The cross-language byte contract is pinned by src/__tests__/vectors.json.
Shape
CertDeliveryEnvelope = {
payload: {
contractVersion: 1,
certId: string,
subject: { // richer identity: binds the credential to a STABLE identity
platform: string, // 'shopify' | 'wordpress' | 'headless' | …
externalId: string, // stable per-placement id (Shopify Article GID, WP post id)
logicalArticleId: string, // CertREV's platform-independent article identity
canonicalUrls: string[], // every URL this credential may render on (primary + locale/slug variants)
installationId: string | null,// which app install placed it
contentDigest: string | null, // hex SHA-256 of the reviewed body: the anti-drift hash (null = unbound)
},
content: { // FACTS, no JSON-LD
expert, author, memo, certifiedAt, contentModifiedAt, verifyUrl,
articleTitle?, displayCertId?, // v0.4 typed, v0.5 on the SIGNED bytes; omitted when unset
display?, // deprecated v0.2: evicted to brand_render_defs, accept-on-read only
},
lifecycle: { issuedAt, expiresAt, revokedAt: string | null, revision: number },
},
signature: {
alg: 'ed25519',
kid: string, // selects the issuer public key (rotation); live root = GCP KMS cryptoKeyVersion name
sig: string, // base64url detached Ed25519 over canonicalize(payload)
signedAt: string,
},
}JSON Schema (draft 2020-12, versioned $id): schema/cert-delivery-envelope.v1.schema.json.
The VerdictKernel: the one algorithm every edge runs (fail-closed)
1. parse + shape-check the envelope; unknown contractVersion → suppress
2. signature.alg === 'ed25519' → else suppress
3. resolve signature.kid → public key → else suppress (unknown_key)
4. Ed25519-verify(sig) over canonicalize(payload) → else suppress (invalid_signature)
5. subject.platform === this edge's platform → else suppress (platform_mismatch)
6. subject.externalId === the article being rendered → else suppress (subject_mismatch)
7. lifecycle.revokedAt === null AND now < expiresAt → else suppress (revoked | expired)
8. live content hash, if supplied AND subject.contentDigest set, must match → else suppress (content_drift)
otherwise → render (carrying the verified payload)Steps 1–4 are cryptographic; 5–8 are policy. verifyEnvelope() runs the whole pipeline. A malformed envelope or a throwing resolver fails closed to suppress; it never throws into the caller (which might swallow it into a render).
import { verifyEnvelope, type CertDeliveryEnvelope } from '@certrev/cert-contract'
const verdict = await verifyEnvelope(envelope, resolveKid, {
platform: 'shopify',
externalId: 'gid://shopify/Article/8675309',
liveContentHash, // optional; omit when the edge can't read live content (drift check is then skipped)
now: new Date(),
})
if (verdict.decision === 'render') {
// verdict.payload is cryptographically verified; project JSON-LD + render the badge from it
} else {
// verdict.reason ∈ unsupported_contract_version | unsupported_alg | unknown_key |
// invalid_signature | platform_mismatch | subject_mismatch |
// revoked | expired | content_drift → render NOTHING
}resolveKid(kid) returns the issuer public key for a kid (or null → unknown_key). It can return an Ed25519PublicKeyInput (pem | spki-base64 | spki-der | raw 32-byte) or, if the edge already imported the key, a WebCrypto CryptoKey. A Node KeyObject is not accepted: the kernel is WebCrypto-only so it runs on edge runtimes. Handing it one fails closed to a blank badge, reported as invalid_signature (the key never imports, so nothing verifies against it). It may be async so an edge can fetch + cache a published key set.
Signing
The live signing root is GCP KMS (EC_SIGN_ED25519, PureEdDSA with no digest flag), so private key material never leaves KMS. KMS returns a raw 64-byte Ed25519 signature over the canonical bytes; the issuer base64url-encodes it into signature.sig and sets kid to the KMS cryptoKeyVersion resource name. For tests and any signer holding a local key, signPayloadEd25519(payload, privateKey) (on the ./signer subpath) produces the identical signature over the same bytes.
Revocation tombstone (v0.3)
A revoked placement should serve proof of revocation, not the certified facts: a blanked badge has no business carrying the expert's identity (an avoidable privacy + payload-size leak). So instead of re-minting a full envelope with lifecycle.revokedAt set, the issuer mints a slim CertTombstone: { kind: 'tombstone', contractVersion, subject, revokedAt, revocationReason, signature }. It reuses the envelope's CertSubject (so the edge's platform + externalId match is unchanged) and is signed by the same trust root: the detached Ed25519 signature covers canonicalTombstoneBytes (the signable fields, RFC-8785 JCS). The kernel's verifyArtifact dispatches on the kind discriminator: a valid, subject-matched tombstone → suppress: 'revoked'; an envelope → the full pipeline.
Rollout is fail-safe. An old (pre-0.3) edge that receives a tombstone fails its envelope shape-check (!payload) and suppresses: a blank badge, the correct outcome for a revoked cert. So a tombstone is safe to serve to an un-upgraded edge; new edges call verifyArtifact for the precise 'revoked' verdict. Mint one with signTombstone(...) on the ./signer subpath.
API
Two entry points. The main entry is edge-runtime safe (WebCrypto, no node:crypto, no Buffer) and carries the verify path; the Node-only ./signer subpath carries the issuer/sign path.
import { verifyEnvelope } from '@certrev/cert-contract' // verify · edge or Node
import { mintEnvelope, sha256Hex } from '@certrev/cert-contract/signer' // issue + sign · Node only| Export | Entry | Purpose |
|---|---|---|
| canonicalizeJson / canonicalBytes / canonicalPayloadBytes | main | RFC-8785 canonicalization (string / UTF-8 bytes / payload-bytes). The signing + hashing input. |
| canonicalTombstoneBytes | main | RFC-8785 canonicalization of a tombstone's signable fields (the tombstone signing input, v0.3). |
| verifyEnvelope | main | The full VerdictKernel: verify + policy, fail-closed. |
| verifyArtifact | main | Unified kernel entry (v0.3): dispatches an envelope OR a CertTombstone. New edges call this. |
| verifyTombstone / isTombstone | main | Tombstone verdict (verify + subject-match → suppress:'revoked') / the kind discriminator. |
| verifySignatureOnly | main | Phase-1-only (parse + kid + Ed25519). For app-proxy edges that split crypto from policy. |
| renderVerdict | main | Phase-2-only policy over an already-verified payload (pure, sync). |
| verifyDetachedSignature | main | Low-level: does this base64url sig verify over canonicalize(payload)? |
| importEd25519PublicKey | main | Import a verify-only WebCrypto CryptoKey (async) from pem / spki-base64 / spki-der / raw. |
| base64urlEncode / base64urlDecode / bytesToBase64 / base64ToBytes | main | Runtime-agnostic base64 / base64url (unpadded) codecs. No Buffer. |
| mintEnvelope / buildPayload | ./signer | Mint a signed envelope / build the payload it signs. |
| signTombstone | ./signer | Mint a slim revocation tombstone. |
| signPayloadEd25519 / localEd25519Signer | ./signer | Sign with a local Ed25519 key (tests / non-KMS signers). |
| sha256Hex / sha256OfCanonical / computeContentDigest | ./signer | SHA-256 helpers (used for subject.contentDigest + the golden vectors). |
| Types | main | CertDeliveryEnvelope, CertDeliveryArtifact, CertTombstone, CertTombstoneSignable, CertPayload, CertSubject, CertContent, CertCredential, CertDisplayConfig, CertLifecycle, CertSignature, CertVerdict, CertSuppressReason, ComplianceClass, ContractVersion, SignatureAlg, RenderContext, ResolvePublicKeyByKid, Ed25519PublicKeyInput |
| Constants | main | CONTRACT_VERSION (1), CANONICALIZATION ('RFC8785-JCS'), SIGNATURE_ALG ('ed25519') |
Cross-language contract: golden vectors
src/__tests__/vectors.json maps representative + adversarial input values (unicode, key-order, nested objects, arrays, integers/floats, exponent forms, empty/null, control-char escapes, and a full CertPayload shape) → their RFC-8785 canonical UTF-8 bytes (hex) → SHA-256. A foreign (PHP / Go / …) RFC-8785 implementation MUST reproduce canonicalHex + sha256 for every input. The vectors are generated from the same canonicalize() the runtime uses (src/__tests__/generate-vectors.mjs) and re-asserted by the test suite, so a vector can never silently drift from the implementation, and an independent SHA-256 of the recorded bytes is checked too.
Dependencies + crypto
- Canonicalization:
canonicalize(RFC 8785), vetted rather than hand-rolled. The package's only runtime dependency. - Ed25519 verify (main entry): the WebCrypto SubtleCrypto API on the global
crypto(crypto.subtle.importKey/crypto.subtle.verify), which needs no dependency and no Node builtin. Ed25519 is PureEdDSA, so the kernel verifies a GCP KMSEC_SIGN_ED25519signature unchanged. PHP edges verify the same bytes with libsodiumsodium_crypto_sign_verify_detached. - Ed25519 sign + SHA-256 (
./signersubpath):node:crypto. The live issuer signs in GCP KMS (see Signing);node:cryptobacks the local-key signer used by tests and non-KMS issuers, plussha256Hex/sha256OfCanonicalforsubject.contentDigest.
The main entry imports no node:crypto and uses no Buffer, so the same kernel runs on Node 20+, Cloudflare Workers, Shopify Oxygen, Vercel Edge and in the browser. It is DOM-lib-free too: the WebCrypto CryptoKey type arrives through a type-only import the compiler erases (src/webcrypto-types.ts), so a server-only TS project compiles against it without adding lib: ["DOM"]. The issuer-only sign path stays quarantined on ./signer.
Tests
pnpm test # 137 deterministic + 6 KMS-gated = 143
pnpm build
pnpm typecheck- Golden vectors: every recorded canonical-bytes + SHA-256 re-derived and asserted, plus an independent hash of the recorded bytes.
- Round-trip: sign with a generated Ed25519 keypair →
verifyEnveloperenders the verified payload. - Tamper: a changed payload field (and a re-pointed nested
subject.externalId, a wrong-key signature, a garbage signature) →invalid_signature, fail-closed. - Verdict pipeline: unknown kid, wrong alg, unknown contract version, platform/subject mismatch, revoked, expired, content drift, drift-check-skipped paths, throwing resolver → fail-closed.
- Live GCP KMS real-sign proof (
real-sign-kms.test.ts): signscanonicalize(payload)with the production KMS Ed25519 key, the kernel renders it against the real SPKI public key, and a tampered payload with the same KMS signature fails closed. Conditional: runs only whengcloudcan reach the KMS key version; config-gated skip otherwise.
Live signing root
| | |
|---|---|
| GCP project | portal-486217 |
| KMS keyring | certrev-signing (location global) |
| Key / version | cert-envelope-issuer v1, EC_SIGN_ED25519 |
| kid | projects/portal-486217/locations/global/keyRings/certrev-signing/cryptoKeys/cert-envelope-issuer/cryptoKeyVersions/1 |
| Public key (SPKI base64) | MCowBQYDK2VwAyEAMWN956IOPjpAq900dL428VzA28TO/pVXnq3brwqUmwM= |
gcloud kms asymmetric-sign --version 1 --key cert-envelope-issuer \
--keyring certrev-signing --location global --project portal-486217 \
--input-file <CANONICAL_BYTES> --signature-file <SIG> # PureEdDSA: no --digest; output = raw 64-byte sigCurrent consumers
| Repo | Status |
|---|---|
| Portal | Planned: issuer (mint + KMS-sign) + the WordPress/Shopify/headless delivery adapters build against this seam. |
| AgOS | Planned: any AgOS-side verification of a delivered credential runs the same verifyEnvelope. |
| WordPress plugin (PHP) | Planned: ports the VerdictKernel to libsodium; the golden vectors are its JCS conformance target. |
Version
Current: 0.5.4. See CHANGELOG.md.
0.5.3· docs-only. Corrected the crypto section (the VERIFY path is WebCrypto, notnode:crypto), the API table (subpaths marked; the non-existenttoEd25519PublicKeyremoved), theresolveKidcontract, and the v0.5contentshape. No executable code changed from 0.5.2.0.5.2· metadata-only.repository/homepage/bugsadded, so the npm page links to the public source mirror and the issue tracker.0.5.1· docs-only (this README + changelog brought current). Code identical to 0.5.0.0.5.0·articleTitle/displayCertIdthreaded onto the signed envelope (covered by the signature). First 0.5-line publish to the public npm registry.0.4.0· thearticleTitle/displayCertIdpayload extensions formalized.0.3.0· the slimCertTombstonerevocation artifact (signTombstone+verifyArtifact/verifyTombstone+canonicalTombstoneBytes), carrying only the subject + revocation facts (no certified content). Additive + fail-closed: old edges suppress an unknown tombstone shape (a blank badge: the correct revoked outcome). Golden tombstone canonicalization vector added.0.2.0· facts-only signed envelope (sign facts, not rendered JSON-LD).0.1.0· initial release.CertDeliveryEnvelopefacts model (sign facts, not JSON-LD), richersubjectidentity binding, RFC-8785 JCS canonicalizer + SHA-256, golden cross-language vectors, fail-closed VerdictKernel, live GCP KMS real-sign proof.
