Files
suaveplan/packages/networking/drive/drive-plugin-chunks/tests/inline-file-sig.test.ts
2026-05-27 23:47:53 +01:00

207 lines
7.2 KiB
TypeScript

/**
* 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);
});
});