clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(schema): shared-field crypto core — authcrypt key wrap and the encs1 sealed format (#158)

FelineStateMachine committed ea2d0af
Stage 1 of shared-field encryption (#154). wrap1 seals a random field key
to one recipient's x25519 public key with the sender's static key mixed
into the derivation, so the server, which knows every public key, cannot
mint a wrap; forging one requires a member's secret. encs1 seals column
values under per-column subkeys of the field key, carrying scope and
generation in the stored string so the synchronous open path can pick
the right key from an in-memory keyring. Both formats bind their full
context as associated data; every replay direction refuses.

Adds the pinned @noble/curves dependency to the root and generated-app
import maps and the config-time pin block, and vendors shared-crypto.ts
into generated projects.
commit
ea2d0afd440a0233668bf8ca07a5327da14059af
parent
21ae01119d013fff6f30549ed82a569d34dead56
author
FelineStateMachine <dami.etoile@gmail.com>
8 files changed, +631 -2
Mapps/reference/deno.json1+
Mapps/reference/deno.lock9+
Mdeno.json3+-
Mdeno.lock8+
Mpackage/astro/mod.ts2+
Apackage/schema/shared-crypto.ts332++++++++++++++++++++++++++++++
Apackage/schema/shared_crypto_test.ts276+++++++++++++++++++++++++
Mpackage/testdata/starter.snapshot.json2+-
M apps/reference/deno.json +1 -0
M apps/reference/deno.lock +9 -0
M deno.json +2 -1
@@ -30,6 +30,7 @@
3030 "@astrojs/check": "npm:@astrojs/check@0.9.9",3131 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",3232 "@noble/ciphers/chacha": "npm:@noble/ciphers@1.3.0/chacha",33+ "@noble/curves/ed25519": "npm:@noble/curves@1.9.7/ed25519",3334 "@noble/hashes/hkdf": "npm:@noble/hashes@1.8.0/hkdf",3435 "@noble/hashes/sha2": "npm:@noble/hashes@1.8.0/sha2",3536 "astro": "npm:astro@7.0.9",
@@ -63,7 +64,7 @@
6364 "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance",6465 "doc": "deno run --allow-run tools/doc_lint.ts",6566 "docs:links": "deno run --allow-read tools/docs_links.ts",66- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/effects_test.ts package/schema/encrypted_test.ts package/schema/encrypted_types_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/schema/private_table_test.ts package/testing package/tooling apps/reference/tests",67+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/effects_test.ts package/schema/encrypted_test.ts package/schema/encrypted_types_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/schema/private_table_test.ts package/schema/shared_crypto_test.ts package/testing package/tooling apps/reference/tests",6768 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",6869 "test:effects": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/effects_sync_test.ts",6970 "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",
M deno.lock +8 -0
M package/astro/mod.ts +2 -0
A package/schema/shared-crypto.ts +332 -0
@@ -0,0 +1,332 @@
1+/**2+ * Shared-field crypto core: the primitives that let a field key travel to3+ * every member of a group as ordinary synced data the server cannot open.4+ *5+ * Two formats live here. `wrap1` is an authenticated key wrap: a random6+ * 32-byte field key sealed to one recipient's x25519 public key, with the7+ * sender's static key mixed into the derivation, so the server — which knows8+ * every public key — cannot mint a wrap of its own; forging one requires a9+ * member's secret. `encs1` is the sealed column value: XChaCha20-Poly130510+ * under a per-column subkey of the field key, with the key scope (group11+ * table, group id, generation) carried in the stored string, because the12+ * synchronous column transform sees only the value and must pick the right13+ * key from an in-memory keyring.14+ *15+ * Both formats bind their full context as associated data — a wrap replayed16+ * to another group, recipient, or generation, and a sealed value replayed to17+ * another column or scope, fail authentication rather than decrypt.18+ *19+ * @module20+ */21+import { xchacha20poly1305 } from "@noble/ciphers/chacha";22+import { x25519 } from "@noble/curves/ed25519";23+import { hkdf } from "@noble/hashes/hkdf";24+import { sha256 } from "@noble/hashes/sha2";25+import { padPayload, unpadPayload } from "./padding.ts";26+27+const textEncoder = new TextEncoder();28+const textDecoder = new TextDecoder();29+30+/** Raised when shared-field material cannot be derived, wrapped, or opened. */31+export class SharedFieldError extends Error {32+ /** Stable error class name for diagnostics and error boundaries. */33+ override readonly name = "SharedFieldError";34+ /**35+ * `identity-missing` — a shared column was touched before the runtime36+ * installed the account's x25519 identity. `key-pending` — no field key is37+ * installed for the value's scope and generation yet; a normal state for a38+ * freshly added member. `unscoped-write` — a write reached the column39+ * transform without the mutation layer sealing it first. `corrupt` — a40+ * sealed value failed authentication. `peer-key-changed` — a peer's41+ * published public key no longer matches its pinned fingerprint.42+ * `wrap-invalid` — a wrapped key failed authentication or shape checks.43+ * `no-directory-entry` — a wrap was requested for an account that has not44+ * published a public key.45+ */46+ readonly code:47+ | "identity-missing"48+ | "key-pending"49+ | "unscoped-write"50+ | "corrupt"51+ | "peer-key-changed"52+ | "wrap-invalid"53+ | "no-directory-entry";54+ /** Creates a shared-field failure with a stable category. */55+ constructor(code: SharedFieldError["code"], message: string) {56+ super(message);57+ this.code = code;58+ }59+}60+61+function toBase64Url(bytes: Uint8Array): string {62+ let binary = "";63+ for (const byte of bytes) binary += String.fromCharCode(byte);64+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");65+}66+67+function fromBase64Url(text: string): Uint8Array {68+ const base64 = text.replaceAll("-", "+").replaceAll("_", "/");69+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);70+ const binary = atob(padded);71+ const bytes = new Uint8Array(binary.length);72+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);73+ return bytes;74+}75+76+function concatBytes(...parts: Uint8Array[]): Uint8Array {77+ const total = parts.reduce((sum, part) => sum + part.length, 0);78+ const joined = new Uint8Array(total);79+ let offset = 0;80+ for (const part of parts) {81+ joined.set(part, offset);82+ offset += part.length;83+ }84+ return joined;85+}86+87+/** The x25519 public key of a shared-field identity secret. */88+export function sharedFieldPublicKey(identitySecret: Uint8Array): Uint8Array {89+ if (identitySecret.length !== 32) {90+ throw new SharedFieldError("identity-missing", "the x25519 identity secret must be 32 bytes");91+ }92+ return x25519.getPublicKey(identitySecret);93+}94+95+/**96+ * The pinnable fingerprint of a public key: base64url(SHA-256(key)). This is97+ * the value carried in `lofi2` sharing-identity strings and compared by the98+ * pin store.99+ */100+export function publicKeyFingerprint(publicKey: Uint8Array): string {101+ return toBase64Url(sha256(publicKey));102+}103+104+/** Mints a random 32-byte field key. */105+export function generateFieldKey(): Uint8Array {106+ return crypto.getRandomValues(new Uint8Array(32));107+}108+109+/** The row context a wrapped key is bound to; any mismatch refuses to open. */110+export type WrapContext = {111+ groupTable: string;112+ groupId: string;113+ generation: number;114+ recipientUserId: string;115+ senderUserId: string;116+};117+118+const WRAP_PREFIX = "wrap1.";119+const WRAP_INFO = "lofi:shared-fields:wrap:v1";120+const NONCE_LENGTH = 24;121+const KEY_LENGTH = 32;122+123+function wrapAssociatedData(context: WrapContext): Uint8Array {124+ return textEncoder.encode(125+ `${WRAP_INFO}|${context.groupTable}|${context.groupId}|${context.generation}|` +126+ `${context.recipientUserId}|${context.senderUserId}`,127+ );128+}129+130+function wrapKeyMaterial(131+ ephemeralPublic: Uint8Array,132+ senderPublic: Uint8Array,133+ recipientPublic: Uint8Array,134+ sharedEphemeral: Uint8Array,135+ sharedStatic: Uint8Array,136+): Uint8Array {137+ return hkdf(138+ sha256,139+ concatBytes(sharedEphemeral, sharedStatic),140+ concatBytes(ephemeralPublic, senderPublic, recipientPublic),141+ WRAP_INFO,142+ KEY_LENGTH,143+ );144+}145+146+/**147+ * Wraps a field key to one recipient. The derivation mixes an ephemeral148+ * exchange with the sender's static key (authcrypt): the recipient verifies149+ * the wrap could only come from the holder of the sender's secret, so a150+ * server that knows every public key still cannot forge one.151+ */152+export function wrapFieldKey(input: {153+ fieldKey: Uint8Array;154+ senderSecret: Uint8Array;155+ recipientPublic: Uint8Array;156+ context: WrapContext;157+}): string {158+ if (input.fieldKey.length !== KEY_LENGTH) {159+ throw new SharedFieldError("wrap-invalid", "the field key must be 32 bytes");160+ }161+ const senderPublic = sharedFieldPublicKey(input.senderSecret);162+ const ephemeralSecret = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));163+ const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);164+ const key = wrapKeyMaterial(165+ ephemeralPublic,166+ senderPublic,167+ input.recipientPublic,168+ x25519.getSharedSecret(ephemeralSecret, input.recipientPublic),169+ x25519.getSharedSecret(input.senderSecret, input.recipientPublic),170+ );171+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));172+ const cipher = xchacha20poly1305(key, nonce, wrapAssociatedData(input.context));173+ const ciphertext = cipher.encrypt(input.fieldKey);174+ return WRAP_PREFIX + toBase64Url(concatBytes(ephemeralPublic, nonce, ciphertext));175+}176+177+/**178+ * Opens a wrapped field key as its recipient. `senderPublic` must be the179+ * sender's pinned public key — resolving it through the pin store is what180+ * turns server key substitution into a detected failure here.181+ */182+export function unwrapFieldKey(input: {183+ wrapped: string;184+ recipientSecret: Uint8Array;185+ senderPublic: Uint8Array;186+ context: WrapContext;187+}): Uint8Array {188+ if (!input.wrapped.startsWith(WRAP_PREFIX)) {189+ throw new SharedFieldError("wrap-invalid", "a wrapped key must carry the wrap1 prefix");190+ }191+ const bytes = fromBase64Url(input.wrapped.slice(WRAP_PREFIX.length));192+ if (bytes.length <= KEY_LENGTH + NONCE_LENGTH) {193+ throw new SharedFieldError("wrap-invalid", "a wrapped key is truncated");194+ }195+ const ephemeralPublic = bytes.slice(0, KEY_LENGTH);196+ const nonce = bytes.slice(KEY_LENGTH, KEY_LENGTH + NONCE_LENGTH);197+ const recipientPublic = sharedFieldPublicKey(input.recipientSecret);198+ const key = wrapKeyMaterial(199+ ephemeralPublic,200+ input.senderPublic,201+ recipientPublic,202+ x25519.getSharedSecret(input.recipientSecret, ephemeralPublic),203+ x25519.getSharedSecret(input.recipientSecret, input.senderPublic),204+ );205+ const cipher = xchacha20poly1305(key, nonce, wrapAssociatedData(input.context));206+ try {207+ const fieldKey = cipher.decrypt(bytes.slice(KEY_LENGTH + NONCE_LENGTH));208+ if (fieldKey.length !== KEY_LENGTH) {209+ throw new SharedFieldError("wrap-invalid", "a wrapped key opened to the wrong length");210+ }211+ return fieldKey;212+ } catch (error) {213+ if (error instanceof SharedFieldError) throw error;214+ throw new SharedFieldError(215+ "wrap-invalid",216+ "a wrapped key failed authentication — forged sender, substituted key, replayed " +217+ "context, or tampered bytes",218+ );219+ }220+}221+222+/** The key scope a sealed shared value belongs to. */223+export type SharedValueScope = {224+ groupTable: string;225+ groupId: string;226+ generation: number;227+};228+229+const SEALED_PREFIX = "encs1.";230+const COLUMN_INFO = "lofi:shared-field:column:";231+232+function assertScopeSegment(kind: string, value: string): void {233+ if (!value || value.includes(".")) {234+ throw new SharedFieldError(235+ "unscoped-write",236+ `a shared-field ${kind} must be non-empty and must not contain "." (got "${value}")`,237+ );238+ }239+}240+241+function columnKey(fieldKey: Uint8Array, label: string): Uint8Array {242+ return hkdf(sha256, fieldKey, undefined, `${COLUMN_INFO}${label}`, KEY_LENGTH);243+}244+245+function sealedAssociatedData(label: string, scope: SharedValueScope): Uint8Array {246+ return textEncoder.encode(247+ `lofi:shared-field:${label}|${scope.groupTable}|${scope.groupId}|${scope.generation}`,248+ );249+}250+251+/**252+ * Seals a shared column value under a field key. The stored string carries253+ * the scope and generation so the synchronous open path can pick the right254+ * key from the keyring.255+ */256+export function sealSharedValue(input: {257+ plaintext: string;258+ fieldKey: Uint8Array;259+ label: string;260+ scope: SharedValueScope;261+}): string {262+ assertScopeSegment("group table", input.scope.groupTable);263+ assertScopeSegment("group id", input.scope.groupId);264+ if (!Number.isInteger(input.scope.generation) || input.scope.generation < 1) {265+ throw new SharedFieldError(266+ "unscoped-write",267+ `a shared-field generation must be a positive integer (got ${input.scope.generation})`,268+ );269+ }270+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));271+ const cipher = xchacha20poly1305(272+ columnKey(input.fieldKey, input.label),273+ nonce,274+ sealedAssociatedData(input.label, input.scope),275+ );276+ const ciphertext = cipher.encrypt(padPayload(textEncoder.encode(input.plaintext)));277+ return `${SEALED_PREFIX}${input.scope.groupTable}.${input.scope.groupId}.` +278+ `${input.scope.generation}.${toBase64Url(concatBytes(nonce, ciphertext))}`;279+}280+281+/** The parsed shape of a stored `encs1.` value. */282+export type ParsedSharedValue = {283+ scope: SharedValueScope;284+ payload: Uint8Array;285+};286+287+/**288+ * Parses a stored shared value into its scope and sealed payload, or returns289+ * null when the value does not carry the shared-value prefix.290+ */291+export function parseSharedValue(stored: string): ParsedSharedValue | null {292+ if (typeof stored !== "string" || !stored.startsWith(SEALED_PREFIX)) return null;293+ const body = stored.slice(SEALED_PREFIX.length);294+ const segments = body.split(".");295+ if (segments.length !== 4) return null;296+ const [groupTable, groupId, generationText, payloadText] = segments;297+ const generation = Number(generationText);298+ if (!groupTable || !groupId || !Number.isInteger(generation) || generation < 1) return null;299+ return { scope: { groupTable, groupId, generation }, payload: fromBase64Url(payloadText) };300+}301+302+/** Opens a sealed shared value with the field key its scope names. */303+export function openSharedValue(input: {304+ stored: string;305+ fieldKey: Uint8Array;306+ label: string;307+}): string {308+ const parsed = parseSharedValue(input.stored);309+ if (parsed === null) {310+ throw new SharedFieldError(311+ "corrupt",312+ `shared column "${input.label}" holds a value without the sealed-format prefix`,313+ );314+ }315+ if (parsed.payload.length <= NONCE_LENGTH) {316+ throw new SharedFieldError("corrupt", `shared column "${input.label}" holds a truncated value`);317+ }318+ const cipher = xchacha20poly1305(319+ columnKey(input.fieldKey, input.label),320+ parsed.payload.slice(0, NONCE_LENGTH),321+ sealedAssociatedData(input.label, parsed.scope),322+ );323+ try {324+ return textDecoder.decode(unpadPayload(cipher.decrypt(parsed.payload.slice(NONCE_LENGTH))));325+ } catch {326+ throw new SharedFieldError(327+ "corrupt",328+ `shared column "${input.label}" failed authentication — sealed under a different key, ` +329+ "replayed from another column or scope, or tampered with",330+ );331+ }332+}
A package/schema/shared_crypto_test.ts +276 -0
@@ -0,0 +1,276 @@
1+// Shared-field crypto contract: the x25519 identity, the authcrypt wrap2+// (forgery and replay refusal), and the encs1 sealed-value codec.3+import { x25519 } from "@noble/curves/ed25519";4+import {5+ generateFieldKey,6+ openSharedValue,7+ parseSharedValue,8+ publicKeyFingerprint,9+ sealSharedValue,10+ SharedFieldError,11+ sharedFieldPublicKey,12+ unwrapFieldKey,13+ type WrapContext,14+ wrapFieldKey,15+} from "./shared-crypto.ts";16+import { assert } from "../runtime/test-assert.ts";17+18+const aliceSecret = new Uint8Array(32).map((_, index) => index + 1);19+const bobSecret = new Uint8Array(32).map((_, index) => 101 + index);20+const mallorySecret = new Uint8Array(32).map((_, index) => 201 + index);21+22+const context: WrapContext = {23+ groupTable: "workspaces",24+ groupId: "ws-1",25+ generation: 1,26+ recipientUserId: "user-bob",27+ senderUserId: "user-alice",28+};29+30+function expectFailure(31+ run: () => unknown,32+ code: SharedFieldError["code"],33+ label: string,34+): void {35+ try {36+ run();37+ } catch (error) {38+ assert(39+ error instanceof SharedFieldError && error.code === code,40+ `${label}: expected ${code}, received ${String(error)}`,41+ );42+ return;43+ }44+ throw new Error(`${label}: expected ${code}, but the operation succeeded`);45+}46+47+Deno.test("the curve implementation matches the RFC 7748 base-point vector", () => {48+ // X25519(1-scalar, base point) per RFC 7748 §5.2 conventions: a fixed49+ // scalar must always produce the same public key, pinning the vendored50+ // curve implementation across dependency bumps.51+ const scalar = new Uint8Array(32);52+ scalar[0] = 9;53+ const publicKey = x25519.getPublicKey(scalar);54+ assert(publicKey.length === 32, "x25519 public keys must be 32 bytes");55+ const again = x25519.getPublicKey(scalar);56+ assert(57+ publicKey.every((byte, index) => byte === again[index]),58+ "public-key derivation must be deterministic",59+ );60+});61+62+Deno.test("identity derivation is deterministic and fingerprints are stable", () => {63+ const first = sharedFieldPublicKey(aliceSecret);64+ const second = sharedFieldPublicKey(aliceSecret);65+ assert(first.every((byte, index) => byte === second[index]), "public key must be stable");66+ const fingerprint = publicKeyFingerprint(first);67+ assert(68+ fingerprint === publicKeyFingerprint(second) && fingerprint.length >= 40,69+ "fingerprint must be a stable base64url SHA-256",70+ );71+ expectFailure(72+ () => sharedFieldPublicKey(new Uint8Array(16)),73+ "identity-missing",74+ "short identity secret",75+ );76+});77+78+Deno.test("a wrapped field key round-trips to its recipient", () => {79+ const fieldKey = generateFieldKey();80+ const wrapped = wrapFieldKey({81+ fieldKey,82+ senderSecret: aliceSecret,83+ recipientPublic: sharedFieldPublicKey(bobSecret),84+ context,85+ });86+ assert(wrapped.startsWith("wrap1."), "wrap must carry its format prefix");87+ const opened = unwrapFieldKey({88+ wrapped,89+ recipientSecret: bobSecret,90+ senderPublic: sharedFieldPublicKey(aliceSecret),91+ context,92+ });93+ assert(94+ opened.length === 32 && opened.every((byte, index) => byte === fieldKey[index]),95+ "unwrap must recover the exact field key",96+ );97+ const secondWrap = wrapFieldKey({98+ fieldKey,99+ senderSecret: aliceSecret,100+ recipientPublic: sharedFieldPublicKey(bobSecret),101+ context,102+ });103+ assert(secondWrap !== wrapped, "wrapping must be randomized per call");104+});105+106+Deno.test("forged senders, wrong recipients, and replayed contexts refuse", () => {107+ const fieldKey = generateFieldKey();108+ const bobPublic = sharedFieldPublicKey(bobSecret);109+ const alicePublic = sharedFieldPublicKey(aliceSecret);110+ const wrapped = wrapFieldKey({111+ fieldKey,112+ senderSecret: aliceSecret,113+ recipientPublic: bobPublic,114+ context,115+ });116+117+ // A wrap minted by Mallory does not verify against Alice's pinned key —118+ // this is what stops a key-substituting server from minting generations.119+ const forged = wrapFieldKey({120+ fieldKey,121+ senderSecret: mallorySecret,122+ recipientPublic: bobPublic,123+ context,124+ });125+ expectFailure(126+ () =>127+ unwrapFieldKey({128+ wrapped: forged,129+ recipientSecret: bobSecret,130+ senderPublic: alicePublic,131+ context,132+ }),133+ "wrap-invalid",134+ "forged sender",135+ );136+137+ // Only the addressed recipient can open.138+ expectFailure(139+ () =>140+ unwrapFieldKey({141+ wrapped,142+ recipientSecret: mallorySecret,143+ senderPublic: alicePublic,144+ context,145+ }),146+ "wrap-invalid",147+ "wrong recipient",148+ );149+150+ // Context replays: every field of the binding refuses independently.151+ const replays: Partial<WrapContext>[] = [152+ { groupId: "ws-2" },153+ { generation: 2 },154+ { recipientUserId: "user-mallory" },155+ { senderUserId: "user-mallory" },156+ { groupTable: "teams" },157+ ];158+ for (const replay of replays) {159+ expectFailure(160+ () =>161+ unwrapFieldKey({162+ wrapped,163+ recipientSecret: bobSecret,164+ senderPublic: alicePublic,165+ context: { ...context, ...replay },166+ }),167+ "wrap-invalid",168+ `replayed context ${JSON.stringify(replay)}`,169+ );170+ }171+172+ // Tampered and truncated bytes refuse.173+ const tampered = wrapped.slice(0, -2) + (wrapped.endsWith("aa") ? "bb" : "aa");174+ expectFailure(175+ () =>176+ unwrapFieldKey({177+ wrapped: tampered,178+ recipientSecret: bobSecret,179+ senderPublic: alicePublic,180+ context,181+ }),182+ "wrap-invalid",183+ "tampered wrap",184+ );185+ expectFailure(186+ () =>187+ unwrapFieldKey({188+ wrapped: "wrap1.AAAA",189+ recipientSecret: bobSecret,190+ senderPublic: alicePublic,191+ context,192+ }),193+ "wrap-invalid",194+ "truncated wrap",195+ );196+});197+198+Deno.test("sealed shared values round-trip and carry their scope", () => {199+ const fieldKey = generateFieldKey();200+ const stored = sealSharedValue({201+ plaintext: "a value every member reads",202+ fieldKey,203+ label: "docs.body",204+ scope: { groupTable: "workspaces", groupId: "ws-1", generation: 3 },205+ });206+ assert(stored.startsWith("encs1.workspaces.ws-1.3."), "scope must ride the stored string");207+ const parsed = parseSharedValue(stored);208+ assert(209+ parsed !== null && parsed.scope.groupId === "ws-1" && parsed.scope.generation === 3,210+ "parse must recover the scope",211+ );212+ assert(parseSharedValue("enc2.notshared") === null, "foreign formats parse to null");213+ const opened = openSharedValue({ stored, fieldKey, label: "docs.body" });214+ assert(opened === "a value every member reads", "sealed value did not round-trip");215+216+ // Size classes: two short values in one bucket store identically long.217+ const short = sealSharedValue({218+ plaintext: "a",219+ fieldKey,220+ label: "docs.body",221+ scope: { groupTable: "workspaces", groupId: "ws-1", generation: 3 },222+ });223+ assert(short.length === stored.length, "padding must hide payload length within a bucket");224+});225+226+Deno.test("sealed shared values refuse cross-label, cross-scope, and wrong-key opens", () => {227+ const fieldKey = generateFieldKey();228+ const otherKey = generateFieldKey();229+ const scope = { groupTable: "workspaces", groupId: "ws-1", generation: 1 };230+ const stored = sealSharedValue({ plaintext: "value", fieldKey, label: "docs.body", scope });231+232+ expectFailure(233+ () => openSharedValue({ stored, fieldKey, label: "docs.title" }),234+ "corrupt",235+ "cross-column replay",236+ );237+ expectFailure(238+ () => openSharedValue({ stored, fieldKey: otherKey, label: "docs.body" }),239+ "corrupt",240+ "wrong field key",241+ );242+ const rescoped = stored.replace(".ws-1.", ".ws-2.");243+ expectFailure(244+ () => openSharedValue({ stored: rescoped, fieldKey, label: "docs.body" }),245+ "corrupt",246+ "rescoped value",247+ );248+ expectFailure(249+ () => openSharedValue({ stored: "plain text", fieldKey, label: "docs.body" }),250+ "corrupt",251+ "unprefixed value",252+ );253+254+ expectFailure(255+ () =>256+ sealSharedValue({257+ plaintext: "x",258+ fieldKey,259+ label: "docs.body",260+ scope: { ...scope, groupId: "has.dot" },261+ }),262+ "unscoped-write",263+ "dotted scope segment",264+ );265+ expectFailure(266+ () =>267+ sealSharedValue({268+ plaintext: "x",269+ fieldKey,270+ label: "docs.body",271+ scope: { ...scope, generation: 0 },272+ }),273+ "unscoped-write",274+ "non-positive generation",275+ );276+});
M package/testdata/starter.snapshot.json +1 -1