@certrev/cert-block 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +12 -1
  3. package/dist/builder/cert-review-card.d.ts.map +1 -1
  4. package/dist/builder/cert-review-card.js +68 -3
  5. package/dist/builder/cert-review-card.js.map +1 -1
  6. package/dist/builder/delivered-face.d.ts +74 -0
  7. package/dist/builder/delivered-face.d.ts.map +1 -0
  8. package/dist/builder/delivered-face.js +122 -0
  9. package/dist/builder/delivered-face.js.map +1 -0
  10. package/dist/builder/delivery-runtime.d.ts +1 -1
  11. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  12. package/dist/builder/delivery-runtime.js +3 -1
  13. package/dist/builder/delivery-runtime.js.map +1 -1
  14. package/dist/builder/index.d.ts +4 -1
  15. package/dist/builder/index.d.ts.map +1 -1
  16. package/dist/builder/index.js +4 -1
  17. package/dist/builder/index.js.map +1 -1
  18. package/dist/builder/ssr-seed.d.ts +13 -1
  19. package/dist/builder/ssr-seed.d.ts.map +1 -1
  20. package/dist/builder/ssr-seed.js +14 -2
  21. package/dist/builder/ssr-seed.js.map +1 -1
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/verify/get-verified-delivery.d.ts +28 -0
  27. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  28. package/dist/verify/get-verified-delivery.js +29 -3
  29. package/dist/verify/get-verified-delivery.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/builder/cert-review-card.tsx +96 -4
  32. package/src/builder/delivered-face.ts +142 -0
  33. package/src/builder/delivery-runtime.ts +3 -1
  34. package/src/builder/index.tsx +4 -1
  35. package/src/builder/ssr-seed.ts +25 -2
  36. package/src/index.ts +3 -0
  37. package/src/verify/get-verified-delivery.ts +45 -3
@@ -0,0 +1,142 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * The delivered face gate: when the Builder card may paint the server's markup (1.3.0)
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * The Delivery API may serve a server-rendered face (`face: { html, css }`) beside the signed artifact. That sibling
7
+ * is UNSIGNED. What binds it is the signed `payload.content.faceDigest` (cert-contract 0.6.0): the lowercase hex sha256
8
+ * of the markup's and the stylesheet's UTF-8 bytes, for one saved design version. The card paints the delivered face
9
+ * only when ALL of these hold:
10
+ *
11
+ * - the verdict is `render` (the caller has already checked that);
12
+ * - the card is not pinned (a design pin keeps 1.2.1's pinned face; the caller checks that);
13
+ * - the block shows the whole card: `part` is not `'header'` or `'memo'`, so a split banner never paints the face
14
+ * twice (`showsWholeCard`);
15
+ * - the verified content carries a digest it understands (`v: 1`, two 64-char lowercase hex digests, a numeric
16
+ * `defVersion`);
17
+ * - a well-formed face was served;
18
+ * - a def was served and its `v` IS the digest's `defVersion` (markup rendered for another design version is not
19
+ * this design);
20
+ * - sha256(html) and sha256(css) equal the signed values.
21
+ *
22
+ * In every other case the card paints exactly what 1.2.1 paints. Markup whose bytes do not hash to the signed digest
23
+ * is never painted, and a WebCrypto failure counts as a mismatch, never as a pass.
24
+ *
25
+ * Hashing uses WebCrypto (`crypto.subtle.digest`), the global the kernel verifies with, so it runs wherever the card
26
+ * already verifies (Node SSR, edge runtimes, a browser in a secure context). It is react-free; the card owns the
27
+ * suspension around the async digest.
28
+ */
29
+
30
+ import type { CertContent, CertFaceDigest } from '../contract/kernel.js'
31
+ import type { DeliveredFace } from '../verify/get-verified-delivery.js'
32
+ import type { CertChromeRenderDef } from './cert-chrome-data.js'
33
+
34
+ const SHA256_HEX = /^[0-9a-f]{64}$/
35
+
36
+ /**
37
+ * Whether a block shows the whole card, by `renderCertBlock`'s own rule: only `'header'` and `'memo'` split the
38
+ * banner; absent, `'full'`, null or anything unrecognised is the full card. A split block keeps its own part even
39
+ * when a delivered face matches, because the delivered face is a whole design.
40
+ */
41
+ export function showsWholeCard(part: unknown): boolean {
42
+ return part !== 'header' && part !== 'memo'
43
+ }
44
+
45
+ /** The signed digest, accepted only in the shape cert-contract 0.6.0 defines; anything else is null. */
46
+ export function acceptedFaceDigest(content: CertContent | null | undefined): CertFaceDigest | null {
47
+ const digest: unknown = (content as { faceDigest?: unknown } | null | undefined)?.faceDigest
48
+ if (typeof digest !== 'object' || digest === null || Array.isArray(digest)) return null
49
+ const { v, defVersion, html, css } = digest as Record<string, unknown>
50
+ if (v !== 1 || typeof defVersion !== 'number' || !Number.isFinite(defVersion)) return null
51
+ if (typeof html !== 'string' || !SHA256_HEX.test(html)) return null
52
+ if (typeof css !== 'string' || !SHA256_HEX.test(css)) return null
53
+ return { v: 1, defVersion, html, css }
54
+ }
55
+
56
+ /**
57
+ * The synchronous half of the gate: the digest to hash the face against, or null when the delivered face cannot
58
+ * paint whatever its bytes are (no digest, no face, no def, or a def of another version). The card hashes only when
59
+ * this is non-null, so a delivery without a face never suspends a second time.
60
+ */
61
+ export function deliveredFaceDigest(
62
+ content: CertContent | null | undefined,
63
+ renderDef: CertChromeRenderDef | null,
64
+ face: DeliveredFace | null | undefined,
65
+ ): CertFaceDigest | null {
66
+ if (!face) return null
67
+ const digest = acceptedFaceDigest(content)
68
+ if (!digest || !renderDef || renderDef.v !== digest.defVersion) return null
69
+ return digest
70
+ }
71
+
72
+ /** Lowercase hex sha256 of a string's UTF-8 bytes, via WebCrypto. */
73
+ export async function sha256HexUtf8(text: string): Promise<string> {
74
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
75
+ let hex = ''
76
+ for (const byte of digest) hex += byte.toString(16).padStart(2, '0')
77
+ return hex
78
+ }
79
+
80
+ /** The two digests of a delivered face. */
81
+ export interface DeliveredFaceHashes {
82
+ readonly html: string
83
+ readonly css: string
84
+ }
85
+
86
+ /** Hash both halves. Never rejects: a WebCrypto failure (none in an insecure context) is null, which never matches. */
87
+ export async function hashDeliveredFace(face: DeliveredFace): Promise<DeliveredFaceHashes | null> {
88
+ try {
89
+ const [html, css] = await Promise.all([sha256HexUtf8(face.html), sha256HexUtf8(face.css)])
90
+ return { html, css }
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ /** The asynchronous half's verdict: both halves hash to the signed values. */
97
+ export function deliveredFaceMatches(hashes: DeliveredFaceHashes | null, digest: CertFaceDigest): boolean {
98
+ return hashes !== null && hashes.html === digest.html && hashes.css === digest.css
99
+ }
100
+
101
+ /** The markup the card paints for a matching face: the stylesheet first, then the face. */
102
+ export function deliveredFaceHtml(face: DeliveredFace): string {
103
+ return `<style>${face.css}</style>${face.html}`
104
+ }
105
+
106
+ /** One face's hashing: the pending digest, and its answer once settled. */
107
+ export interface FaceHashMemo {
108
+ readonly pending: Promise<DeliveredFaceHashes | null>
109
+ settled?: { readonly hashes: DeliveredFaceHashes | null }
110
+ }
111
+
112
+ /** How many faces' hashes are kept (insertion order; the oldest goes first). A page or a brand has one or a few. */
113
+ const FACE_HASH_MEMO_MAX = 64
114
+ const faceHashMemo = new Map<string, FaceHashMemo>()
115
+
116
+ /**
117
+ * The memoized hashing of one face, keyed by its CONTENT (both strings), not by the object: the browser parses a seed
118
+ * from the page afresh on each render, and a server's cached read and a seed of the same face share one entry. A
119
+ * failure settles to null and stays settled (a context with no WebCrypto does not gain one).
120
+ */
121
+ export function faceHashesFor(face: DeliveredFace): FaceHashMemo {
122
+ const key = `${face.html.length}:${face.html}${face.css}`
123
+ const known = faceHashMemo.get(key)
124
+ if (known) return known
125
+ const fresh: FaceHashMemo = {
126
+ pending: hashDeliveredFace(face).then((hashes) => {
127
+ fresh.settled = { hashes }
128
+ return hashes
129
+ }),
130
+ }
131
+ if (faceHashMemo.size >= FACE_HASH_MEMO_MAX) {
132
+ const oldest = faceHashMemo.keys().next().value
133
+ if (oldest !== undefined) faceHashMemo.delete(oldest)
134
+ }
135
+ faceHashMemo.set(key, fresh)
136
+ return fresh
137
+ }
138
+
139
+ /** Test seam: forget every face's hashes (a new process). */
140
+ export function forgetFaceHashesForTests(): void {
141
+ faceHashMemo.clear()
142
+ }
@@ -18,6 +18,7 @@
18
18
  import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
19
19
  import { TtlCache } from '../verify/cache.js'
20
20
  import type { CachedDelivery } from '../verify/get-verified-delivery.js'
21
+ import { forgetFaceHashesForTests } from './delivered-face.js'
21
22
  import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
22
23
  import { forgetPageSeedsForTests } from './ssr-seed.js'
23
24
 
@@ -67,10 +68,11 @@ export function cardDeliveryRuntime(): CardDeliveryRuntime {
67
68
 
68
69
  /**
69
70
  * Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
70
- * (a fresh runtime is a fresh page). Returns a restore.
71
+ * (a fresh runtime is a fresh page) and (1.3.0) every delivered face's memoized hashes. Returns a restore.
71
72
  */
72
73
  export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
73
74
  forgetPageSeedsForTests()
75
+ forgetFaceHashesForTests()
74
76
  const previous = runtime
75
77
  const now = overrides.now ?? previous.now
76
78
  runtime = {
@@ -27,7 +27,10 @@
27
27
  * for the entry it renders inside (`builderContext.content.id`) from the CertREV Delivery API on
28
28
  * `baseUrl` (an https certrev.com origin, nothing else), verifies it against the baked
29
29
  * `cert-issuer-1` key, re-judges revocation and expiry at every render, and paints the brand's
30
- * render def served beside it. A revoked, expired, unverifiable or unreachable cert, a marker that
30
+ * render def served beside it. 1.3.0: when the Delivery API also serves a server-rendered face
31
+ * (`face: { html, css }`, unsigned) and the verified envelope signs that face's sha256 digests for the
32
+ * def version served (`content.faceDigest`), an unpinned whole-card block paints that face instead;
33
+ * markup that does not hash to the signed digest is never painted. A revoked, expired, unverifiable or unreachable cert, a marker that
31
34
  * is not exactly that shape, or a missing entry id renders nothing. A block WITHOUT the marker
32
35
  * (every entry exported before 1.1.0) renders from its options exactly as 1.0.3 did, so the
33
36
  * paragraph above still describes it: an editor who deletes the marker gets the options face back,
@@ -48,13 +48,26 @@
48
48
  * server withheld. A record holds no payload, so `parseSeed` never takes one for a seed. A `'none'` record sits on a
49
49
  * `hidden` element with nothing in it.
50
50
  *
51
+ * A DELIVERED FACE RIDES THE SEED (1.3.0), and only when the server painted it: the seed then ends with
52
+ * `face: { html, css }`, the server-rendered markup and stylesheet the Delivery API served beside the envelope, whose
53
+ * hashes the signed `payload.content.faceDigest` binds. A browser repaints that face from the seed only after hashing
54
+ * it against the seed payload's digest itself (`cert-review-card.tsx`); a seed with no `face`, a malformed one, or one
55
+ * whose hashes fail paints what 1.2.1 paints for it. A seed with no face is byte for byte the 1.2.1 seed. The face is
56
+ * on the page twice (painted, and in the seed), which is its page-weight cost.
57
+ *
51
58
  * React-free and browser-only in effect: with no `document` (a server) `pageSeedFor` finds nothing, so a server never
52
59
  * reads or keeps a seed and a multi-tenant render process holds nothing per page.
53
60
  */
54
61
 
55
62
  import { HIDEABLE_FIELDS } from '../components/render-def.js'
56
63
  import type { CertContent, CertPayload, CertSignature } from '../contract/kernel.js'
57
- import { type DesignPin, parseDesignPin, parseRenderDef } from '../verify/get-verified-delivery.js'
64
+ import {
65
+ type DeliveredFace,
66
+ type DesignPin,
67
+ parseDeliveredFace,
68
+ parseDesignPin,
69
+ parseRenderDef,
70
+ } from '../verify/get-verified-delivery.js'
58
71
  import type { CertChromeRenderDef } from './cert-chrome-data.js'
59
72
 
60
73
  export const SEED_ATTRIBUTE = 'data-certrev-envelope'
@@ -72,6 +85,11 @@ export interface DeliverySeed {
72
85
  */
73
86
  readonly signature?: CertSignature
74
87
  readonly renderDef: CertChromeRenderDef | null
88
+ /**
89
+ * 1.3.0: the delivered face the server painted (the file header); absent whenever it painted anything else. On a
90
+ * seed read from the page it is UNVERIFIED until hashed against `payload.content.faceDigest`.
91
+ */
92
+ readonly face?: DeliveredFace
75
93
  }
76
94
 
77
95
  /** A seed as read from the page: the parsed seed and the attribute's exact text (re-rendered verbatim on hydration). */
@@ -183,6 +201,8 @@ export function serializeSeed(seed: DeliverySeed): string {
183
201
  payload: seed.payload,
184
202
  ...(seed.signature ? { signature: seed.signature } : {}),
185
203
  renderDef: seed.renderDef,
204
+ // 1.3.0: last, and only with a delivered face, so a seed without one is the 1.2.1 bytes.
205
+ ...(seed.face ? { face: { html: seed.face.html, css: seed.face.css } } : {}),
186
206
  })
187
207
  }
188
208
 
@@ -214,7 +234,7 @@ export function parseSeed(raw: string): DeliverySeed | null {
214
234
  return null
215
235
  }
216
236
  if (!isRecord(value) || value.v !== 1) return null
217
- const { baseUrl, externalId, design, payload, signature, renderDef } = value
237
+ const { baseUrl, externalId, design, payload, signature, renderDef, face } = value
218
238
  if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
219
239
  if (!isRecord(payload) || !isRecord(payload.subject) || !isRecord(payload.lifecycle) || !isRecord(payload.content)) {
220
240
  return null
@@ -222,6 +242,8 @@ export function parseSeed(raw: string): DeliverySeed | null {
222
242
  // A pin the seed names is judged by the same grammar the card's own pin is; one that fails it is not a seed.
223
243
  const pin = parsedPin(design)
224
244
  if (pin === null) return null
245
+ // 1.3.0: a face that is not two strings is no face; the seed is still a seed (and paints as 1.2.1 paints it).
246
+ const delivered = parseDeliveredFace(face)
225
247
  return {
226
248
  baseUrl,
227
249
  externalId,
@@ -229,6 +251,7 @@ export function parseSeed(raw: string): DeliverySeed | null {
229
251
  payload: payload as unknown as CertPayload,
230
252
  ...(isRecord(signature) ? { signature: signature as unknown as CertSignature } : {}),
231
253
  renderDef: parseRenderDef(renderDef),
254
+ ...(delivered ? { face: delivered } : {}),
232
255
  }
233
256
  }
234
257
 
package/src/index.ts CHANGED
@@ -106,6 +106,7 @@ export {
106
106
  type CertCredential,
107
107
  type CertDeliveryEnvelope,
108
108
  type CertDisplayConfig,
109
+ type CertFaceDigest,
109
110
  type CertLifecycle,
110
111
  type CertPayload,
111
112
  type CertSignature,
@@ -135,6 +136,7 @@ export {
135
136
  export { TtlCache, type TtlCacheOptions } from './verify/cache.js'
136
137
  export {
137
138
  type CachedDelivery,
139
+ type DeliveredFace,
138
140
  type DesignPin,
139
141
  deliveryCacheKey,
140
142
  deliveryUrl,
@@ -142,6 +144,7 @@ export {
142
144
  type GetVerifiedDeliveryOptions,
143
145
  getVerifiedDelivery,
144
146
  invalidateDelivery,
147
+ parseDeliveredFace,
145
148
  parseDesignPin,
146
149
  parseRenderDef,
147
150
  peekVerifiedDelivery,
@@ -36,6 +36,12 @@
36
36
  * card paints its options face instead of nothing. A definitive answer (404/410, a key or signature that does not
37
37
  * verify, a revoked, expired or mismatched artifact) is never `unreachable`. `readVerifiedDelivery` is the
38
38
  * synchronous read that also serves a stale entry while refreshing it.
39
+ *
40
+ * 1.3.0: the response may also carry a server-rendered face as a second UNSIGNED sibling, `face: { html, css }`. It is
41
+ * read the way `renderDef` is (shape-checked, malformed is dropped, never read unless the artifact verified) and cached
42
+ * beside it, and `settleDelivery` hands it out only on a `render` verdict. NOTHING HERE VOUCHES FOR IT: the signature
43
+ * does not cover it. What binds it is the signed `payload.content.faceDigest` (cert-contract 0.6.0), and a caller
44
+ * paints it only after hashing it against that digest, as the Builder card does (`src/builder/delivered-face.ts`).
39
45
  */
40
46
 
41
47
  import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
@@ -52,6 +58,16 @@ import { renderVerdict, verifyArtifact } from '../contract/kernel.js'
52
58
  import { TtlCache } from './cache.js'
53
59
  import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
54
60
 
61
+ /**
62
+ * 1.3.0: the server-rendered face the Delivery API may serve beside the artifact. UNSIGNED and UNVERIFIED as carried
63
+ * here: paint it only when sha256 of each half's UTF-8 bytes equals the signed `payload.content.faceDigest`, and the
64
+ * digest's `defVersion` is the def served with it.
65
+ */
66
+ export interface DeliveredFace {
67
+ readonly html: string
68
+ readonly css: string
69
+ }
70
+
55
71
  /**
56
72
  * What one Delivery read settles to, as cached: a verified payload + its def (and, from 1.2.0, the signature it
57
73
  * verified with, so a server render can hand the signed envelope to the browser), or the reason not.
@@ -63,6 +79,8 @@ export type CachedDelivery =
63
79
  readonly renderDef: CertChromeRenderDef | null
64
80
  /** Absent only in a cache filled by hand (a test, a custom cache); a read always keeps it. */
65
81
  readonly signature?: CertSignature
82
+ /** 1.3.0: the unsigned face sibling, when the response carried a well-formed one; else absent. */
83
+ readonly face?: DeliveredFace
66
84
  }
67
85
  | {
68
86
  readonly kind: 'suppressed'
@@ -80,6 +98,11 @@ export interface VerifiedDelivery {
80
98
  readonly signature?: CertSignature
81
99
  /** 1.2.0: on a suppress verdict, true when CertREV was not reached; else undefined. */
82
100
  readonly unreachable?: true
101
+ /**
102
+ * 1.3.0: on a `render` verdict, the server-rendered face served beside the artifact, when the response carried a
103
+ * well-formed one; else undefined (never on a suppress verdict). UNVERIFIED: see `DeliveredFace`.
104
+ */
105
+ readonly face?: DeliveredFace
83
106
  }
84
107
 
85
108
  export interface GetVerifiedDeliveryOptions {
@@ -230,6 +253,18 @@ export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
230
253
  }
231
254
  }
232
255
 
256
+ /**
257
+ * 1.3.0: the face sibling, shape-checked: an object whose `html` and `css` are both strings, copied to exactly those
258
+ * two keys. Anything else is null, and the card paints exactly what it paints with no face. The markup is NOT judged
259
+ * here; the signed digest judges it at paint time.
260
+ */
261
+ export function parseDeliveredFace(value: unknown): DeliveredFace | null {
262
+ if (!isRecord(value)) return null
263
+ const { html, css } = value
264
+ if (!isString(html) || !isString(css)) return null
265
+ return { html, css }
266
+ }
267
+
233
268
  /** Fetch, parse and cryptographically verify one Delivery response. Never throws. */
234
269
  async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDelivery> {
235
270
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch
@@ -259,8 +294,8 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
259
294
  let verdict: CertVerdict
260
295
  try {
261
296
  // `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
262
- // `payload` + `signature` (or the tombstone's own fields); the `renderDef` sibling is not
263
- // part of what is verified, and is never read unless the artifact verified.
297
+ // `payload` + `signature` (or the tombstone's own fields); the `renderDef` and (1.3.0) `face`
298
+ // siblings are not part of what is verified, and are never read unless the artifact verified.
264
299
  verdict = await verifyArtifact(body as unknown as CertDeliveryArtifact, opts.resolveKid, { ...opts.context })
265
300
  } catch {
266
301
  return suppressed('unknown_key')
@@ -268,11 +303,13 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
268
303
  if (verdict.decision !== 'render') return suppressed(verdict.reason)
269
304
  // A `render` verdict came from an envelope (a tombstone never renders), so `signature` is the one just verified.
270
305
  const signature = isRecord(body.signature) ? (body.signature as unknown as CertSignature) : undefined
306
+ const face = parseDeliveredFace(body.face)
271
307
  return {
272
308
  kind: 'verified',
273
309
  payload: verdict.payload,
274
310
  renderDef: parseRenderDef(body.renderDef),
275
311
  ...(signature ? { signature } : {}),
312
+ ...(face ? { face } : {}),
276
313
  }
277
314
  }
278
315
 
@@ -287,7 +324,12 @@ export function settleDelivery(cached: CachedDelivery, context: RenderContext):
287
324
  }
288
325
  const verdict = renderVerdict(cached.payload, context)
289
326
  if (verdict.decision !== 'render') return { verdict, renderDef: null }
290
- return { verdict, renderDef: cached.renderDef, ...(cached.signature ? { signature: cached.signature } : {}) }
327
+ return {
328
+ verdict,
329
+ renderDef: cached.renderDef,
330
+ ...(cached.signature ? { signature: cached.signature } : {}),
331
+ ...(cached.face ? { face: cached.face } : {}),
332
+ }
291
333
  }
292
334
 
293
335
  /**