v2 Changes Beta
v2 introduces three changes to the unlock protocol. Each is described below with its motivation, what changed, and whether it's backwards compatible.
1. Simplified Unlock Request
Motivation
The v1 unlock request sends six fields and two JWTs. After analysis, four fields and one entire JWT turn out to be redundant:
resource— unsigned copy of what's already inresourceJWTissuerName— the subscription service already knows its own nameissuerJWT— contains SHA-256 integrity proofs of the sealed blobs, but AES-GCM is already authenticated encryption — any tampered blob fails at unseal time. The proofs are redundant.
Description
v2 strips the request down to the cryptographic essentials:
resourceJWT— publisher-signed resource metadata (authentication)sealed— the sealed keys (AES-GCM provides integrity)keyId— which private key to use (from page'sissuerData)
Why the issuerJWT is unnecessary:
- Integrity proofs: SHA-256 hashes of sealed blobs, to detect tampering. But sealed blobs use AES-GCM (authenticated encryption) — any modification causes the unseal to fail with a GCM authentication error. The hashes add nothing.
- Metadata:
issuerName,keyId, andrenderId. The service knows its own name. ThekeyIdcomes from the page'sissuerData. TherenderIdis in theresourceJWT.
Removing the issuerJWT eliminates one JWT signature verification per unlock request and the SHA-256 proof computation on both publisher and service sides.
// v1 unlock request (current) — 6 fields + 2 JWTs
POST /api/unlock
{
"resource": { "domain": "news.example.com", "resourceId": "…", … },
"resourceJWT": "eyJ…",
"issuerJWT": "eyJ…",
"sealed": { "bodytext": { "contentKey": "…", "periodKeys": { … } } },
"keyId": "issuer-key-1",
"issuerName": "sesamy",
"clientPublicKey": "…" // optional
}
// v2 unlock request (beta) — 3 fields + 1 JWT
POST /api/unlock
{
"resourceJWT": "eyJ…",
"sealed": { "bodytext": { "contentKey": "…", "periodKeys": { … } } },
"keyId": "2025-10",
"clientPublicKey": "…" // optional
}The service auto-detects the format based on whether resource is present in the request:
- Verify resourceJWT: Decode the JWT payload (unverified) to get the domain for publisher key selection. Verify the signature with the looked-up key. This is standard JWT practice (same as OIDC).
- Check keyId: The
keyIdin the request must match the service's configured key. This is the same check as v1, just sourced from the request body (which the client reads fromissuerDataon the page). - Unseal: The service unseals the requested content keys. AES-GCM authentication ensures any tampered blob is rejected — no proof hashes needed.
Backwards Compatibility
| Scenario | Works? | Notes |
|---|---|---|
| v1 client → v1 service | ✅ | No change |
| v1 client → v2 service | ✅ | Service auto-detects v1 format, processes normally |
| v2 client → v2 service | ✅ | Minimal request, full security |
| v2 client → v1 service | ❌ | v1 service requires issuerJWT, resource, issuerName |
Recommended migration: Upgrade the service first (accepts both formats), then switch clients to v2 at your own pace.
2. Standard JWT Claims in resourceJWT
Motivation
The v1 resourceJWT uses custom field names (domain, resourceId, issuedAt, renderId) instead of the well-known JWT claim names defined in RFC 7519. Using standard claims improves interoperability and makes the JWT self-describing.
Description
v2 maps the resourceJWT payload to standard JWT claim names:
| DcaResource field | JWT claim | RFC 7519 name | Notes |
|---|---|---|---|
domain | iss | Issuer | The publisher that signed the JWT |
resourceId | sub | Subject | The resource being accessed |
issuedAt (ISO 8601) | iat | Issued At | Unix timestamp (seconds) instead of ISO string |
renderId | jti | JWT ID | Unique per-render identifier |
data | data | (custom) | Publisher-defined metadata, unchanged |
The decoded resourceJWT payload now looks like a standard JWT:
// resourceJWT payload (decoded)
{
"iss": "news.example.com", // domain
"sub": "article-123", // resourceId
"iat": 1735689600, // issuedAt (Unix seconds)
"jti": "abc123def456", // renderId
"data": { "section": "politics"} // custom metadata
}The page's DcaData.resource still uses the human-readable field names (domain, resourceId, etc.) for debugging and display. The mapping happens automatically when the publisher creates the JWT and when the service verifies it.
Backwards Compatibility
Fully backwards compatible. The service detects the claim format automatically (checking for iss vs domain). Both v1 and v2 resource JWTs are accepted. No publisher or client changes are required.
3. keyName: Decoupling Content Identity from Key Domain
Motivation
In v1, contentName serves three roles simultaneously:
- Content identity — uniquely identifies a content item within a resource (e.g. "bodytext", "sidebar")
- Key derivation salt — used as the HKDF salt for periodKey derivation
- Access control scope — the issuer grants access by contentName
This conflation forces publishers to use artificial names like "TierA" or "premium" as their contentName, losing the ability to describe what the content actually is. If a page has both a premium body and a premium sidebar, they need different contentNames but the same access scope — impossible in v1.
Description
keyName is an optional field on each content item that controls which key domain that item belongs to. When set, HKDF uses keyName instead of contentName as the salt, and the issuer can grant access by keyName instead of listing individual content names.
| Role | v1 (contentName only) | v2 (with keyName) |
|---|---|---|
| Content identity | contentName | contentName (unchanged) |
| Key derivation salt | contentName | keyName (falls back to contentName) |
| Access control scope | grantedContentNames | grantedKeyNames (resolves via contentKeyMap) |
Publisher — before & after:
// v1: contentName = "TierA" (conflates identity with access scope)
const result = await publisher.render({
resourceId: "article-123",
contentItems: [
{ contentName: "TierA", content: body, contentType: "text/html" },
],
issuers: [{
issuerName: "sesamy",
publicKeyPem, keyId, unlockUrl,
contentNames: ["TierA"],
}],
});
// v2: contentName describes WHAT, keyName describes WHO can access
const result = await publisher.render({
resourceId: "article-123",
contentItems: [
{ contentName: "bodytext", keyName: "premium", content: body, contentType: "text/html" },
{ contentName: "sidebar", keyName: "premium", content: side, contentType: "text/html" },
],
issuers: [{
issuerName: "sesamy",
publicKeyPem, keyId, unlockUrl,
keyNames: ["premium"], // seals all items with this keyName
}],
});Wire format — contentKeyMap:
When any content item has an explicit keyName, the publisher includes a contentKeyMap in the DCA data — a lightweight mapping from contentName to keyName:
// DcaData (embedded in page)
{
"resource": { "resourceId": "article-123", "domain": "news.example.com", … },
"contentKeyMap": {
"bodytext": "premium",
"sidebar": "premium"
},
"contentSealData": { "bodytext": { … }, "sidebar": { … } },
"sealedContentKeys": { "bodytext": [ … ], "sidebar": [ … ] },
…
}The contentKeyMap is omitted when all keyNames equal their contentNames (the v1-compatible case), keeping zero overhead for publishers that don't use this feature.
Issuer — grantedKeyNames:
The issuer's access decision can now use grantedKeyNames instead of (or alongside) grantedContentNames:
// v1: grant by content name
const result = await issuer.unlock(request, {
grantedContentNames: ["bodytext", "sidebar"],
deliveryMode: "periodKey",
});
// v2: grant by key name — resolves to all matching content items
const result = await issuer.unlock(request, {
grantedKeyNames: ["premium"], // grants both "bodytext" and "sidebar"
deliveryMode: "periodKey",
});Client — transparent handling:
The client handles keyName transparently. Period keys are cached by keyName instead of contentName, so unlocking any "premium" article automatically caches the key for all other "premium" articles:
const client = new DcaClient({ requestFormat: "v2" });
const page = client.parsePage();
// contentKeyMap is included automatically in the unlock request
const keys = await client.unlock(page, "sesamy");
// Decrypt by contentName — keyName is resolved internally
const body = await client.decrypt(page, "bodytext", keys);
const side = await client.decrypt(page, "sidebar", keys);
// Period key cache is keyed by "premium" (the keyName),
// so navigating to another "premium" article skips the unlock callBackwards Compatibility
Fully backwards compatible. When keyName is omitted it defaults to contentName, so existing pages and unlock requests work unchanged. The contentKeyMap is only included when explicitly needed.
Client Usage
import { DcaClient } from '@sesamy/capsule-client';
// v1 (default) — compatible with all services
const clientV1 = new DcaClient();
// v2 (beta) — requires v2-capable service
const clientV2 = new DcaClient({ requestFormat: "v2" });
// Usage is identical — only the wire format changes
const page = clientV2.parsePage();
const response = await clientV2.unlock(page, "sesamy");
const html = await clientV2.decrypt(page, "bodytext", response);Service-Side Setup
The service automatically handles both v1 and v2 requests with no configuration needed. The detection is based on whether the resource field is present:
// No code changes needed — auto-detection is built in
const issuer = createDcaIssuer({
issuerName: "sesamy",
privateKeyPem: process.env.ISSUER_ECDH_P256_PRIVATE_KEY!,
keyId: "2025-10",
trustedPublisherKeys: {
"news.example.com": process.env.PUBLISHER_ES256_PUBLIC_KEY!,
},
});
// Handles both v1 and v2 requests transparently
const result = await issuer.unlock(request, {
grantedContentNames: ["bodytext"],
deliveryMode: "contentKey",
});Summary
| Component | Change | Breaking? |
|---|---|---|
| Publisher | No change (still generates issuerJWT for v1 clients) | No |
| Service | Auto-detects v1/v2; v2 skips proof verification | No — v1 requests still work unchanged |
| Client | New requestFormat: "v2" option; drops issuerJWT from request | No — defaults to v1 |
| Wire format | 4 fields + 1 JWT removed from unlock request | Yes — v2 requests fail against v1-only services |
Security
v2 provides identical security to v1:
- Publisher authentication: The
resourceJWTis ES256-signed by the publisher. The service verifies the signature against the trusted-publisher allowlist. This is unchanged from v1. - Sealed blob integrity: AES-GCM is authenticated encryption — modifying any sealed blob causes the unseal to fail with a GCM authentication error. The SHA-256 proof hashes in the issuerJWT were a redundant second integrity check.
- Blob substitution: Content keys are random per render. Substituting sealed blobs from a different article gives you that article's keys, which cannot decrypt this article's content.
- Domain lookup from JWT: The service decodes the JWT payload (unverified) only for key selection, then fully verifies the signature. Standard JWT practice (same as OIDC providers).
Suggested Updates
The following changes are under consideration for future versions. They are not yet implemented but documented here for discussion.
A. JWE for Sealed Key Blobs
The current seal format is a custom binary blob: ephemeral public key ‖ nonce ‖ ciphertext. This works but is non-standard. Replacing it with JWE Compact Serialization (RFC 7516) would give us:
- A well-known, auditable format instead of a custom byte layout
- Standard algorithm identifiers (
alg: ECDH-ES,enc: A256GCM) - Built-in algorithm agility through the
alg/encheaders - Interoperability with any JWE library (jose, node-jose, etc.)
// Current custom format
"sealed": "Base64url(ephemeralPub ‖ nonce ‖ AES-GCM(sharedSecret, plainKey))"
// Proposed JWE Compact Serialization
"sealed": "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6ey4uLn19. .nonce.ciphertext.tag"
// header .CEK. IV . ciphertext .tagThe epk (ephemeral public key) moves into the JWE protected header, and AES-GCM nonce/ciphertext/tag are standard JWE fields. The same ECDH-ES + HKDF key derivation is used under the hood.
B. Standard JWT Claims for Share Link Tokens
Share link tokens currently use custom claim names (domain, resourceId, type) similar to v1 resource JWTs. The same RFC 7519 mapping applied to resourceJWT in Change 2 above should be applied to share tokens:
| Current claim | Standard claim | Notes |
|---|---|---|
domain | iss | Publisher that signed the token |
resourceId | sub | Resource being shared |
type: "dca-share" | JWT header typ: "dca-share+jwt" | Distinguishes from resource JWTs without a payload claim |
This aligns all publisher-signed JWTs (resource + share) under the same conventions and allows reusing the same verification code path.
C. Structured Error Types
Currently, callers distinguish error kinds by parsing error.message strings (e.g. error.message.includes("not trusted")). This is fragile — message text can change between versions.
Instead, the library should expose typed error subclasses with a stable code property:
// Proposed error hierarchy
class DcaError extends Error {
code: string;
}
class DcaUntrustedPublisherError extends DcaError {
code = "UNTRUSTED_PUBLISHER";
domain: string;
}
class DcaKeyMismatchError extends DcaError {
code = "KEY_MISMATCH";
expected: string;
received: string;
}
class DcaUnsealError extends DcaError {
code = "UNSEAL_FAILED";
algorithm: string;
}
// Callers can now match on stable codes
try {
await issuer.unlock(request, decision);
} catch(err) {
if(err instanceof DcaUntrustedPublisherError) {
// handle untrusted publisher
}
// or match on err.code === "UNTRUSTED_PUBLISHER"
}D. Rename DcaSealedContentKey.t Field
The DcaSealedContentKey type uses a single-character field name t for the time bucket identifier:
// Current wire format
"sealedContentKeys": {
"bodytext": [
{ "t": "2025-06-d", "nonce": "...", "key": "..." },
{ "t": "2025-06-d-12", "nonce": "...", "key": "..." }
]
}The abbreviated name saves a few bytes per entry but hurts readability and discoverability. Two options:
- Rename to
timeBucket— explicit and self-documenting. Costs ~10 extra bytes per entry (negligible in practice). - Switch to array format — use
[timeBucket, nonce, key]tuples instead of objects. Smaller wire size than either naming option and positional semantics are unambiguous given the fixed schema.