clone

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

fix/175-template-quality-pass
lofi / package / schema / encrypted_test.ts
encrypted_test.ts 10.8K
1// Encrypted-column contract: seal/open round-trip, fail-closed without a key,2// label domain separation, tamper and wrong-key refusal, and the stored shape3// (versioned prefix, no plaintext in any trivial encoding).4import {5 clearEncryptedColumnKey,6 clearEncryptedColumnRegistry,7 EncryptedColumnError,8 encryptedColumnsOf,9 encryptedDate,10 encryptedJson,11 encryptedNumber,12 encryptedText,13 isEncryptedColumn,14 matchDecrypted,15 registerEncryptedColumns,16 setEncryptedColumnKey,17} from "./encrypted.ts";18import { paddedLength, padPayload, unpadPayload } from "./padding.ts";19import { xchacha20poly1305 } from "@noble/ciphers/chacha";20import { hkdf } from "@noble/hashes/hkdf";21import { sha256 } from "@noble/hashes/sha2";22import { assert } from "../runtime/test-assert.ts";2324type Transformed = {25 _columnTransforms?: never;26} & { transform?: never };2728// The facade casts transformed columns back to typed builders; tests reach the29// live transform pair through the runtime column object the same way the30// engine does.31function transformOf(column: unknown): {32 to: (value: unknown) => string;33 from: (value: string) => unknown;34} {35 const transform = (column as { _transform?: unknown })._transform ??36 (column as { transform?: unknown }).transform;37 assert(38 typeof transform === "object" && transform !== null,39 `column carries no transform pair: ${JSON.stringify(Object.keys(column as object))}`,40 );41 return transform as { to: (value: unknown) => string; from: (value: string) => unknown };42}4344const key = new Uint8Array(32).map((_, index) => index + 1);45const otherKey = new Uint8Array(32).map((_, index) => 101 + index);4647function withKey(body: () => void): () => void {48 return () => {49 setEncryptedColumnKey(key);50 try {51 body();52 } finally {53 clearEncryptedColumnKey();54 }55 };56}5758function expectFailure(59 run: () => unknown,60 code: EncryptedColumnError["code"],61 context: string,62): void {63 try {64 run();65 } catch (error) {66 assert(67 error instanceof EncryptedColumnError && error.code === code,68 `${context}: expected ${code}, received ${String(error)}`,69 );70 return;71 }72 throw new Error(`${context}: expected ${code}, but the operation succeeded`);73}7475Deno.test(76 "text and json values round-trip and store versioned ciphertext",77 withKey(() => {78 const text = transformOf(encryptedText("t.body"));79 const plaintext = "a private sentence nobody else reads";80 const stored = text.to(plaintext);81 assert(82 stored.startsWith("enc2."),83 `stored value lacks the version prefix: ${stored.slice(0, 12)}`,84 );85 assert(!stored.includes(plaintext), "stored value contains the plaintext");86 assert(!stored.includes(btoa(plaintext)), "stored value contains base64 plaintext");87 assert(text.from(stored) === plaintext, "text round-trip mismatch");88 assert(text.to(plaintext) !== stored, "sealing must be randomized per write");8990 const json = transformOf(encryptedJson<{ note: string }>("t.meta"));91 // A long distinctive marker: short digit runs occur by chance in92 // base64url ciphertext, so the no-plaintext probe needs entropy.93 const value = { note: "distinctive-plaintext-marker" };94 const storedJson = json.to(value);95 assert(96 !storedJson.includes("distinctive-plaintext-marker"),97 "stored json contains plaintext content",98 );99 assert(100 (json.from(storedJson) as { note: string }).note === value.note,101 "json round-trip mismatch",102 );103 }),104);105106Deno.test("without an installed key every operation fails closed", () => {107 clearEncryptedColumnKey();108 const text = transformOf(encryptedText("t.body"));109 expectFailure(() => text.to("value"), "key-missing", "seal without key");110 // A structurally well-formed value: 40 base64url chars decode to 30 bytes,111 // past the nonce-length check, so the missing key is what refuses.112 expectFailure(() => text.from(`enc1.${"A".repeat(40)}`), "key-missing", "open without key");113 expectFailure(114 () => setEncryptedColumnKey(new Uint8Array(16)),115 "key-invalid",116 "short key",117 );118});119120Deno.test(121 "labels domain-separate: ciphertext cannot replay across columns",122 withKey(() => {123 const first = transformOf(encryptedText("a.body"));124 const second = transformOf(encryptedText("b.body"));125 const stored = first.to("value");126 expectFailure(() => second.from(stored), "corrupt", "cross-column replay");127 }),128);129130Deno.test(131 "tampered, truncated, unprefixed, and foreign-key values are refused",132 withKey(() => {133 const text = transformOf(encryptedText("t.body"));134 const stored = text.to("value");135 const flipped = stored.slice(0, -2) + (stored.endsWith("aa") ? "bb" : "aa");136 expectFailure(() => text.from(flipped), "corrupt", "tampered value");137 expectFailure(() => text.from("enc1.AAAA"), "corrupt", "truncated legacy value");138 expectFailure(() => text.from("enc2.AAAA"), "corrupt", "truncated value");139 expectFailure(() => text.from("plaintext"), "corrupt", "unprefixed value");140 setEncryptedColumnKey(otherKey);141 expectFailure(() => text.from(stored), "corrupt", "foreign key");142 }),143);144145Deno.test("padding buckets are floored, self-describing, and reversible", () => {146 assert(paddedLength(1) === 64, "tiny payloads pad to the floor");147 assert(paddedLength(64) === 64, "the floor is inclusive");148 assert(paddedLength(65) > 64, "past the floor the bucket grows");149 for (const size of [0, 1, 60, 61, 500, 70_000]) {150 const payload = new Uint8Array(size).map((_, index) => index % 251);151 const padded = padPayload(payload);152 assert(padded.length === paddedLength(4 + size), `bucket mismatch at ${size}`);153 const recovered = unpadPayload(padded);154 assert(155 recovered.length === size && recovered.every((byte, index) => byte === payload[index]),156 `padding round-trip mismatch at ${size}`,157 );158 }159 const declaresPastEnd = new Uint8Array(64);160 new DataView(declaresPastEnd.buffer).setUint32(0, 61);161 let threw = false;162 try {163 unpadPayload(declaresPastEnd);164 } catch {165 threw = true;166 }167 assert(threw, "a length declared past the buffer must refuse");168});169170Deno.test(171 "stored lengths reveal the size class, not the payload length",172 withKey(() => {173 const text = transformOf(encryptedText("t.body"));174 const short = text.to("a");175 const longer = text.to("a".repeat(40));176 assert(177 short.length === longer.length,178 `payloads in one bucket must store identically: ${short.length} vs ${longer.length}`,179 );180 const pastFloor = text.to("a".repeat(200));181 assert(pastFloor.length > short.length, "a larger bucket must store longer");182 }),183);184185// A legacy value sealed exactly as the pre-padding format wrote it: subkey186// from the same HKDF chain, the unversioned associated data, no padding.187function sealLegacy(label: string, plaintext: string): string {188 const subkey = hkdf(sha256, key, undefined, `lofi:encrypted-column:${label}`, 32);189 const nonce = new Uint8Array(24).map((_, index) => 255 - index);190 const cipher = xchacha20poly1305(191 subkey,192 nonce,193 new TextEncoder().encode(`lofi:encrypted-column:${label}`),194 );195 const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext));196 const bytes = new Uint8Array(24 + ciphertext.length);197 bytes.set(nonce);198 bytes.set(ciphertext, 24);199 let binary = "";200 for (const byte of bytes) binary += String.fromCharCode(byte);201 return "enc1." + btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");202}203204Deno.test(205 "legacy enc1 values still open; prefix flips refuse in both directions",206 withKey(() => {207 const text = transformOf(encryptedText("t.body"));208 const legacy = sealLegacy("t.body", "written before padding existed");209 assert(210 text.from(legacy) === "written before padding existed",211 "legacy value failed to open",212 );213 expectFailure(214 () => text.from("enc2." + legacy.slice("enc1.".length)),215 "corrupt",216 "legacy value replayed under the padded prefix",217 );218 const padded = text.to("value");219 expectFailure(220 () => text.from("enc1." + padded.slice("enc2.".length)),221 "corrupt",222 "padded value replayed under the legacy prefix",223 );224 }),225);226227Deno.test(228 "number and date values round-trip; invalid inputs refuse at the boundary",229 withKey(() => {230 const amount = transformOf(encryptedNumber("t.amount"));231 for (const value of [0, -1.5, 2 ** 40 + 0.5, Number.MAX_SAFE_INTEGER]) {232 const stored = amount.to(value);233 assert(amount.from(stored) === value, `number round-trip mismatch at ${value}`);234 }235 for (const invalid of [Number.NaN, Number.POSITIVE_INFINITY]) {236 let threw = false;237 try {238 amount.to(invalid);239 } catch (error) {240 threw = error instanceof TypeError;241 }242 assert(threw, `non-finite number ${invalid} must refuse with TypeError`);243 }244 expectFailure(245 () => amount.from(transformOf(encryptedText("t.amount")).to("not a number")),246 "corrupt",247 "non-numeric payload",248 );249250 const at = transformOf(encryptedDate("t.at"));251 const moment = new Date("2026-07-19T12:34:56.789Z");252 const storedDate = at.to(moment);253 assert(254 (at.from(storedDate) as Date).getTime() === moment.getTime(),255 "date round-trip mismatch",256 );257 let rejectedInvalidDate = false;258 try {259 at.to(new Date(Number.NaN));260 } catch (error) {261 rejectedInvalidDate = error instanceof TypeError;262 }263 assert(rejectedInvalidDate, "an invalid date must refuse with TypeError");264 }),265);266267Deno.test("the registry records encrypted columns per table and clears", () => {268 clearEncryptedColumnRegistry();269 try {270 registerEncryptedColumns({271 journal: {272 title: { _sqlType: "TEXT" },273 body: encryptedText("journal.body"),274 mood: encryptedNumber("journal.mood"),275 },276 });277 assert(isEncryptedColumn("journal", "body"), "body must register as encrypted");278 assert(isEncryptedColumn("journal", "mood"), "mood must register as encrypted");279 assert(!isEncryptedColumn("journal", "title"), "plaintext column must not register");280 assert(!isEncryptedColumn("elsewhere", "body"), "other tables must not register");281 const registered = encryptedColumnsOf("journal");282 assert(283 registered?.get("body") === "journal.body" && registered?.get("mood") === "journal.mood",284 "labels must be retrievable per column",285 );286 clearEncryptedColumnRegistry();287 assert(!isEncryptedColumn("journal", "body"), "clear must empty the registry");288 } finally {289 clearEncryptedColumnRegistry();290 }291});292293Deno.test("matchDecrypted filters rows on decrypted values", () => {294 const rows = [295 { id: "a", body: "meeting notes" },296 { id: "b", body: "grocery list" },297 ];298 const matched = matchDecrypted(rows, (row) => row.body.includes("notes"));299 assert(matched.length === 1 && matched[0].id === "a", "predicate must select by content");300});