clone

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

main
lofi / package / runtime / envelope_test.ts
envelope_test.ts 5.5K
1// Envelope contract tests: multi-slot seal/open, fall-through past2// unavailable protectors, slot-table tamper detection, purpose binding, and3// untrusted-record validation.4import {5 deviceKeyProtector,6 deviceKeyResolver,7 EnvelopeError,8 type EnvelopeSlot,9 memoryDeviceKeyStore,10 openEnvelope,11 parseSealedEnvelope,12 sealEnvelope,13} from "./envelope.ts";14import { assert } from "./test-assert.ts";1516const payload = new TextEncoder().encode(JSON.stringify({ ticket: "/t/secret-material" }));1718async function expectEnvelopeError(19 run: () => Promise<unknown>,20 code: "locked" | "corrupt",21 context: string,22): Promise<void> {23 try {24 await run();25 } catch (error) {26 assert(27 error instanceof EnvelopeError && error.code === code,28 `${context}: expected ${code}, received ${String(error)}`,29 );30 return;31 }32 throw new Error(`${context}: expected ${code}, but the envelope opened`);33}3435Deno.test("a sealed envelope opens back to the payload through its device key", async () => {36 const store = memoryDeviceKeyStore();37 const sealed = await sealEnvelope("test:purpose", payload, [38 await deviceKeyProtector(store, "k1"),39 ]);40 const opened = await openEnvelope("test:purpose", sealed, deviceKeyResolver(store));41 assert(42 new TextDecoder().decode(opened) === new TextDecoder().decode(payload),43 "the opened payload must equal the sealed payload",44 );45});4647Deno.test("opening falls through unavailable slots to one that answers", async () => {48 const storeA = memoryDeviceKeyStore();49 const storeB = memoryDeviceKeyStore();50 const sealed = await sealEnvelope("test:purpose", payload, [51 await deviceKeyProtector(storeA, "first"),52 await deviceKeyProtector(storeB, "second"),53 ]);54 // Only the second store is available in this context: slot one resolves to55 // null and the open must continue rather than fail.56 const opened = await openEnvelope(57 "test:purpose",58 sealed,59 (slot: EnvelopeSlot) =>60 slot.type === "device-key" && slot.keyId === "second"61 ? storeB.get("second")62 : Promise.resolve(null),63 );64 assert(opened.length === payload.length, "the fallback slot must open the envelope");65});6667Deno.test("an envelope with no available protector reports locked", async () => {68 const store = memoryDeviceKeyStore();69 const sealed = await sealEnvelope("test:purpose", payload, [70 await deviceKeyProtector(store, "k1"),71 ]);72 await expectEnvelopeError(73 () => openEnvelope("test:purpose", sealed, () => Promise.resolve(null)),74 "locked",75 "no protector",76 );77 // A different store's key fails the wrap authentication and must be treated78 // as unavailable, not as corruption.79 await expectEnvelopeError(80 () => openEnvelope("test:purpose", sealed, deviceKeyResolver(memoryDeviceKeyStore())),81 "locked",82 "foreign key",83 );84});8586Deno.test("tampering with the payload or the slot table is corruption", async () => {87 const store = memoryDeviceKeyStore();88 const sealed = await sealEnvelope("test:purpose", payload, [89 await deviceKeyProtector(store, "k1"),90 ]);91 const flipped = {92 ...sealed,93 ciphertext: sealed.ciphertext.startsWith("A")94 ? `B${sealed.ciphertext.slice(1)}`95 : `A${sealed.ciphertext.slice(1)}`,96 };97 await expectEnvelopeError(98 () => openEnvelope("test:purpose", flipped, deviceKeyResolver(store)),99 "corrupt",100 "flipped ciphertext",101 );102 // The slot table is authenticated data: renaming a slot's key id after the103 // fact must not go unnoticed even though the wrap itself still opens.104 const tampered = {105 ...sealed,106 slots: sealed.slots.map((slot) => ({ ...slot, keyId: "impostor" }) as EnvelopeSlot),107 };108 await expectEnvelopeError(109 () =>110 openEnvelope(111 "test:purpose",112 tampered,113 (slot) => (slot.type === "device-key" ? store.get("k1") : Promise.resolve(null)),114 ),115 "corrupt",116 "renamed slot",117 );118});119120Deno.test("an envelope opens only under its own purpose", async () => {121 const store = memoryDeviceKeyStore();122 const sealed = await sealEnvelope("test:purpose", payload, [123 await deviceKeyProtector(store, "k1"),124 ]);125 await expectEnvelopeError(126 () => openEnvelope("test:other", sealed, deviceKeyResolver(store)),127 "corrupt",128 "wrong purpose",129 );130});131132Deno.test("untrusted records validate strictly before any cryptography", () => {133 const invalid: unknown[] = [134 null,135 "text",136 {},137 { v: 2, purpose: "p", slots: [], iv: "a", ciphertext: "b" },138 {139 v: 1,140 purpose: "p",141 slots: [{ type: "unknown", iv: "a", wrappedDek: "b" }],142 iv: "a",143 ciphertext: "b",144 },145 {146 v: 1,147 purpose: "p",148 slots: [{ type: "device-key", iv: "a", wrappedDek: "b" }],149 iv: "a",150 ciphertext: "b",151 },152 {153 v: 1,154 purpose: "p",155 slots: [{ type: "prf", credentialId: "c", iv: "a", wrappedDek: "b" }],156 iv: "a",157 ciphertext: "b",158 },159 ];160 for (const record of invalid) {161 assert(162 parseSealedEnvelope(record) === null,163 `invalid record accepted: ${JSON.stringify(record)}`,164 );165 }166 const valid = {167 v: 1,168 purpose: "p",169 slots: [{ type: "device-key", keyId: "k", iv: "a", wrappedDek: "b" }],170 iv: "a",171 ciphertext: "b",172 };173 assert(parseSealedEnvelope(valid) !== null, "a well-formed record must validate");174});175176Deno.test("sealing with no protector is refused", async () => {177 await expectEnvelopeError(178 () => sealEnvelope("test:purpose", payload, []),179 "corrupt",180 "empty protectors",181 );182});