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

Not backwards compatible

Motivation

The v1 unlock request sends six fields and two JWTs. After analysis, four fields and one entire JWT turn out to be redundant:

Description

v2 strips the request down to the cryptographic essentials:

Why the issuerJWT is unnecessary:

  1. 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.
  2. Metadata: issuerName, keyId, and renderId. The service knows its own name. The keyId comes from the page's issuerData. The renderId is in the resourceJWT.

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:

  1. 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).
  2. Check keyId: The keyId in 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 from issuerData on the page).
  3. Unseal: The service unseals the requested content keys. AES-GCM authentication ensures any tampered blob is rejected — no proof hashes needed.

Backwards Compatibility

ScenarioWorks?Notes
v1 client → v1 serviceNo change
v1 client → v2 serviceService auto-detects v1 format, processes normally
v2 client → v2 serviceMinimal request, full security
v2 client → v1 servicev1 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

Backwards compatible

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 fieldJWT claimRFC 7519 nameNotes
domainissIssuerThe publisher that signed the JWT
resourceIdsubSubjectThe resource being accessed
issuedAt (ISO 8601)iatIssued AtUnix timestamp (seconds) instead of ISO string
renderIdjtiJWT IDUnique per-render identifier
datadata(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

Backwards compatible

Motivation

In v1, contentName serves three roles simultaneously:

  1. Content identity — uniquely identifies a content item within a resource (e.g. "bodytext", "sidebar")
  2. Key derivation salt — used as the HKDF salt for periodKey derivation
  3. 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.

Rolev1 (contentName only)v2 (with keyName)
Content identitycontentNamecontentName (unchanged)
Key derivation saltcontentNamekeyName (falls back to contentName)
Access control scopegrantedContentNamesgrantedKeyNames (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 call

Backwards 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

ComponentChangeBreaking?
PublisherNo change (still generates issuerJWT for v1 clients)No
ServiceAuto-detects v1/v2; v2 skips proof verificationNo — v1 requests still work unchanged
ClientNew requestFormat: "v2" option; drops issuerJWT from requestNo — defaults to v1
Wire format4 fields + 1 JWT removed from unlock requestYes — v2 requests fail against v1-only services

Security

v2 provides identical security to v1:

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:

// Current custom format
"sealed": "Base64url(ephemeralPub ‖ nonce ‖ AES-GCM(sharedSecret, plainKey))"

// Proposed JWE Compact Serialization
"sealed": "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6ey4uLn19.    .nonce.ciphertext.tag"
//               header          .CEK.  IV  . ciphertext .tag

The 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 claimStandard claimNotes
domainissPublisher that signed the token
resourceIdsubResource 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:

  1. Rename to timeBucket — explicit and self-documenting. Costs ~10 extra bytes per entry (negligible in practice).
  2. 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.