/** * DTP-25 — `FileLocation.storageHints` `kind: 'inline'` carries a sig * over `fileId ‖ bytes`. Verified by the three required spec test * names plus tamper-detection cases. */ import { getPublicKey, randomPrivateKey, } from "@suaveplan/crypto-curve/ed25519"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { CHUNKS_CODES, ChunksError, inlineSigningInput, isLegacyInlineHint, type LegacyInlineHint, signInlineHint, upgradeLegacyInlineHint, verifyInlineHint, } from "../src/index.js"; function makeFileId(seed: number): Uint8Array { const id = new Uint8Array(32); for (let i = 0; i < 32; i++) id[i] = (seed + i) & 0xff; return id; } describe("DTP-25 — inline-file sig", () => { it("inline file with valid sig reads successfully", { timeout: 5000, }, () => { const sk = randomPrivateKey(); const pk = getPublicKey(sk); const fileId = makeFileId(0x10); const bytes = new TextEncoder().encode("hello inline world"); const hint = signInlineHint(fileId, bytes, sk); const out = verifyInlineHint(hint, fileId, pk); expect(out).toEqual(bytes); expect(hint.kind).toBe("inline"); expect(hint.sig).toBeInstanceOf(Uint8Array); expect(hint.sig.byteLength).toBe(64); }); it("inline file with bad sig throws INLINE_SIG_INVALID", { timeout: 5000, }, () => { const sk = randomPrivateKey(); const pk = getPublicKey(sk); const fileId = makeFileId(0x20); const bytes = new TextEncoder().encode("payload"); const hint = signInlineHint(fileId, bytes, sk); // Flip a bit in the signature. const tamperedSig = new Uint8Array(hint.sig); tamperedSig[0] ^= 0x01; const tampered = { ...hint, sig: tamperedSig }; try { verifyInlineHint(tampered, fileId, pk); throw new Error("expected INLINE_SIG_INVALID"); } catch (err) { expect(err).toBeInstanceOf(ChunksError); expect((err as ChunksError).code).toBe( CHUNKS_CODES.INLINE_SIG_INVALID ); } }); it("old-format inline reads with warning AND rewrites with sig on next write", { timeout: 5000, }, () => { const sk = randomPrivateKey(); const pk = getPublicKey(sk); const fileId = makeFileId(0x30); const bytes = new TextEncoder().encode("legacy bytes"); const legacy: LegacyInlineHint = { kind: "inline", bytes }; // Legacy detection expect(isLegacyInlineHint(legacy)).toBe(true); // Simulate the warning channel a real drive would emit on read let warningCount = 0; const onWarning = () => { warningCount++; }; if (isLegacyInlineHint(legacy)) { onWarning(); } expect(warningCount).toBe(1); // Rewrite-with-sig on next write const upgraded = upgradeLegacyInlineHint(legacy, fileId, sk); expect(isLegacyInlineHint(upgraded)).toBe(false); expect(upgraded.bytes).toEqual(bytes); expect(upgraded.sig.byteLength).toBe(64); // Upgraded hint verifies cleanly under the same key const verified = verifyInlineHint(upgraded, fileId, pk); expect(verified).toEqual(bytes); }); it("sig bound to fileId — replay onto a different fileId rejects", { timeout: 5000, }, () => { const sk = randomPrivateKey(); const pk = getPublicKey(sk); const fileIdA = makeFileId(0x40); const fileIdB = makeFileId(0x41); const bytes = new TextEncoder().encode("same bytes, different file"); const hint = signInlineHint(fileIdA, bytes, sk); try { verifyInlineHint(hint, fileIdB, pk); throw new Error("expected INLINE_SIG_INVALID"); } catch (err) { expect((err as ChunksError).code).toBe( CHUNKS_CODES.INLINE_SIG_INVALID ); } }); it("byte tamper rejects with INLINE_SIG_INVALID", { timeout: 5000 }, () => { const sk = randomPrivateKey(); const pk = getPublicKey(sk); const fileId = makeFileId(0x50); const bytes = new TextEncoder().encode("original"); const hint = signInlineHint(fileId, bytes, sk); const tamperedBytes = new TextEncoder().encode("modified"); const tampered = { ...hint, bytes: tamperedBytes }; try { verifyInlineHint(tampered, fileId, pk); throw new Error("expected INLINE_SIG_INVALID"); } catch (err) { expect((err as ChunksError).code).toBe( CHUNKS_CODES.INLINE_SIG_INVALID ); } }); it("rejects under a different public key", { timeout: 5000 }, () => { const skA = randomPrivateKey(); const skB = randomPrivateKey(); const pkB = getPublicKey(skB); const fileId = makeFileId(0x60); const bytes = new TextEncoder().encode("signed by A"); const hint = signInlineHint(fileId, bytes, skA); expect(() => verifyInlineHint(hint, fileId, pkB)).toThrow(ChunksError); }); it("inlineSigningInput is the canonical fileId ‖ bytes concatenation", { timeout: 5000, }, () => { const fileId = new Uint8Array([1, 2, 3, 4]); const bytes = new Uint8Array([0xaa, 0xbb, 0xcc]); const input = inlineSigningInput(fileId, bytes); expect(input.byteLength).toBe(7); expect(Array.from(input)).toEqual([1, 2, 3, 4, 0xaa, 0xbb, 0xcc]); }); it("verify catch arm wraps a throwing verifier", { timeout: 5000 }, () => { const sk = randomPrivateKey(); const pk = getPublicKey(sk); const fileId = makeFileId(0x70); const bytes = new TextEncoder().encode("ok"); const hint = signInlineHint(fileId, bytes, sk); const throwingVerify = () => { throw new Error("native verifier exploded"); }; try { verifyInlineHint(hint, fileId, pk, throwingVerify); throw new Error("expected INLINE_SIG_INVALID"); } catch (err) { expect(err).toBeInstanceOf(ChunksError); const e = err as ChunksError; expect(e.code).toBe(CHUNKS_CODES.INLINE_SIG_INVALID); expect(e.message).toContain("raised"); expect(e.cause).toBeInstanceOf(Error); expect((e.cause as Error).message).toBe("native verifier exploded"); } }); it("isLegacyInlineHint distinguishes signed from legacy hints", { timeout: 5000, }, () => { const sk = randomPrivateKey(); const fileId = makeFileId(0x80); const bytes = new TextEncoder().encode("data"); const signed = signInlineHint(fileId, bytes, sk); const legacy: LegacyInlineHint = { kind: "inline", bytes }; expect(isLegacyInlineHint(signed)).toBe(false); expect(isLegacyInlineHint(legacy)).toBe(true); // Object literally has sig: undefined — also treated as legacy. const ambiguous = { kind: "inline" as const, bytes, sig: undefined }; expect( isLegacyInlineHint(ambiguous as unknown as LegacyInlineHint) ).toBe(true); }); });