Initial Commit
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user