clone

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

feat/fix-sync-enrollment-startup
lofi / package / schema / shared_crypto_test.ts
shared_crypto_test.ts 8.1K
1// Shared-field crypto contract: the x25519 identity, the authcrypt wrap2// (forgery and replay refusal), and the encs1 sealed-value codec.3import { x25519 } from "@noble/curves/ed25519";4import {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";16import { assert } from "../runtime/test-assert.ts";1718const aliceSecret = new Uint8Array(32).map((_, index) => index + 1);19const bobSecret = new Uint8Array(32).map((_, index) => 101 + index);20const mallorySecret = new Uint8Array(32).map((_, index) => 201 + index);2122const context: WrapContext = {23 groupTable: "workspaces",24 groupId: "ws-1",25 generation: 1,26 recipientUserId: "user-bob",27 senderUserId: "user-alice",28};2930function 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}4647Deno.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});6162Deno.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});7778Deno.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});105106Deno.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 });116117 // 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 );136137 // 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 );149150 // 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 }171172 // 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});197198Deno.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");215216 // 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});225226Deno.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 });231232 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 );253254 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});