207 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|