From the logic audit: - consent rejects sid-less bearers and requires a LIVE hub session (sessionAlive) before minting codes — a logged-out bearer can no longer mint downstream tokens inside its TTL - refresh rotation race closed: consume returns whether THIS call won; the loser revokes the family (reuse detection now holds under concurrency) - error redirects carry iss (RFC 9207) on error responses too - logout awaits the revocation and no longer returns ok for missing or forged bearers
219 lines
9.6 KiB
TypeScript
219 lines
9.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
|
|
const s3Calls = vi.hoisted(() => ({
|
|
sent: [] as string[],
|
|
ranges: [] as string[],
|
|
sealed: null as Buffer | null,
|
|
}));
|
|
|
|
vi.mock("@aws-sdk/client-s3", () => {
|
|
class FakeCommand {
|
|
constructor(readonly input: unknown, readonly marker: string) {}
|
|
}
|
|
class S3ServiceException extends Error {
|
|
readonly $metadata: { httpStatusCode?: number };
|
|
constructor(message: string, status: number, name: string) {
|
|
super(message);
|
|
this.name = name;
|
|
this.$metadata = { httpStatusCode: status };
|
|
}
|
|
}
|
|
return {
|
|
CreateBucketCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Create"); } },
|
|
HeadObjectCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Head"); } },
|
|
PutObjectCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Put"); } },
|
|
GetObjectCommand: class extends FakeCommand { constructor(input: unknown) { super(input, "Get"); } },
|
|
S3ServiceException,
|
|
S3Client: class {
|
|
constructor(readonly config: unknown) {}
|
|
async send(cmd: FakeCommand): Promise<unknown> {
|
|
s3Calls.sent.push(cmd.marker);
|
|
if (cmd.marker === "Create") throw new S3ServiceException("Access Denied", 403, "AccessDenied");
|
|
if (cmd.marker === "Head") throw new S3ServiceException("Not Found", 404, "NotFound");
|
|
if (cmd.marker === "Get" && s3Calls.sealed !== null) {
|
|
const range = (cmd.input as { Range: string }).Range;
|
|
s3Calls.ranges.push(range);
|
|
const match = /^bytes=(\d+)-(\d+)$/.exec(range)!;
|
|
const body = s3Calls.sealed.subarray(Number(match[1]), Number(match[2]) + 1);
|
|
return { Body: { transformToByteArray: async () => body } };
|
|
}
|
|
return {};
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
import { DIRECT_SEGMENT_BYTES, S3Storage, directSealedSize, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
|
|
|
|
const TEST_KEK = createHash("sha256").update("test-kek").digest();
|
|
|
|
describe("SPENC2 sealed size", () => {
|
|
it.each([
|
|
[1, 168],
|
|
[DIRECT_SEGMENT_BYTES - 1, 135 + DIRECT_SEGMENT_BYTES - 1 + 32],
|
|
[DIRECT_SEGMENT_BYTES, 135 + DIRECT_SEGMENT_BYTES + 32],
|
|
[DIRECT_SEGMENT_BYTES + 1, 135 + DIRECT_SEGMENT_BYTES + 1 + 64],
|
|
[2 * 1024 ** 3, 135 + 2 * 1024 ** 3 + 205 * 32],
|
|
])("computes exact size for %i plaintext bytes", (plain, sealed) => {
|
|
expect(directSealedSize(plain)).toBe(sealed);
|
|
expect(directPlaintextSize(sealed)).toBe(plain);
|
|
});
|
|
|
|
it.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])("rejects invalid plaintext size %s", (size) => {
|
|
expect(() => directSealedSize(size)).toThrow(RangeError);
|
|
});
|
|
});
|
|
|
|
describe("storage key scheme (pure)", () => {
|
|
it("maps content types to extensions, unknown -> bin", () => {
|
|
expect(extensionFor("image/png")).toBe("png");
|
|
expect(extensionFor("video/mp4")).toBe("mp4");
|
|
expect(extensionFor("application/x-weird")).toBe("bin");
|
|
});
|
|
|
|
it("keys are content-addressed and deterministic", () => {
|
|
const hash = sha256(Buffer.from("same bytes"));
|
|
expect(keyForHash(hash, "image/png")).toBe(`sha256/${hash}.png`);
|
|
expect(keyForHash(hash, "video/mp4")).toBe(`sha256/${hash}.mp4`);
|
|
expect(keyForHash(sha256(Buffer.from("same bytes")), "image/png")).toBe(keyForHash(hash, "image/png"));
|
|
expect(keyForHash(sha256(Buffer.from("other")), "image/png")).not.toBe(keyForHash(hash, "image/png"));
|
|
});
|
|
|
|
it("hash is stable across buffer instances", () => {
|
|
expect(sha256(Buffer.from("abc"))).toBe(sha256(Buffer.from("abc")));
|
|
expect(sha256(Buffer.from("abc"))).toHaveLength(64);
|
|
});
|
|
});
|
|
|
|
describe("client-side encryption (pure)", () => {
|
|
it("seal → open roundtrips arbitrary bytes", () => {
|
|
const plaintext = randomBytes(1024 * 64);
|
|
const sealed = seal(plaintext, TEST_KEK);
|
|
expect(open(sealed, TEST_KEK).equals(plaintext)).toBe(true);
|
|
});
|
|
|
|
it("ciphertext never contains plaintext (endpoint sees only sealed frame)", () => {
|
|
const plaintext = Buffer.from("TOPSECRET-media-bytes-123");
|
|
const sealed = seal(plaintext, TEST_KEK);
|
|
expect(sealed.includes(plaintext)).toBe(false);
|
|
});
|
|
|
|
it("fresh CEK per seal → identical plaintext, different ciphertexts (dedup still by key)", () => {
|
|
const plaintext = Buffer.from("identical");
|
|
const a = seal(plaintext, TEST_KEK);
|
|
const b = seal(plaintext, TEST_KEK);
|
|
expect(a.equals(b)).toBe(false);
|
|
expect(open(a, TEST_KEK).equals(open(b, TEST_KEK))).toBe(true);
|
|
});
|
|
|
|
it("tampered ciphertext fails GCM authentication", () => {
|
|
const sealed = seal(Buffer.from("authentic"), TEST_KEK);
|
|
const last = sealed.length - 1;
|
|
sealed[last] = (sealed[last] ?? 0) ^ 0xff;
|
|
expect(() => open(sealed, TEST_KEK)).toThrow();
|
|
});
|
|
|
|
it("wrong KEK cannot open", () => {
|
|
const otherKek = createHash("sha256").update("other-kek").digest();
|
|
const sealed = seal(Buffer.from("private"), TEST_KEK);
|
|
expect(() => open(sealed, otherKek)).toThrow();
|
|
});
|
|
|
|
it("plaintext hash addressing + seal preserves dedup semantics", () => {
|
|
const a = Buffer.from("media");
|
|
const b = Buffer.from("media");
|
|
expect(sha256(a)).toBe(sha256(b));
|
|
expect(keyForHash(sha256(a), "video/mp4")).toBe(keyForHash(sha256(b), "video/mp4"));
|
|
});
|
|
});
|
|
|
|
describe("S3Storage with object-scoped credentials", () => {
|
|
beforeEach(() => { s3Calls.sent.length = 0; });
|
|
|
|
it("stores despite 403 on bucket create (pre-provisioned bucket, least-privilege token)", async () => {
|
|
const storage = new S3Storage({
|
|
endpoint: "https://s3.example.invalid",
|
|
accessKeyId: "scoped-key",
|
|
secretAccessKey: "scoped-secret",
|
|
bucket: "pre-provisioned",
|
|
mediaKek: TEST_KEK,
|
|
});
|
|
const stored = await storage.putContent(Buffer.from("scoped-token-probe"), "text/plain");
|
|
expect(stored.deduplicated).toBe(false);
|
|
expect(s3Calls.sent).toEqual(["Create", "Head", "Put"]);
|
|
});
|
|
});
|
|
|
|
|
|
describe("SPENC2 range reads", () => {
|
|
beforeEach(() => {
|
|
s3Calls.sent.length = 0;
|
|
s3Calls.ranges.length = 0;
|
|
s3Calls.sealed = null;
|
|
});
|
|
|
|
async function fixture(plaintext: Buffer): Promise<S3Storage> {
|
|
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
const storage = new S3Storage({ mediaKek: TEST_KEK, directPrivateKey: privateKey });
|
|
const jwk = (await storage.directPublicJwk())!;
|
|
s3Calls.sealed = sealForDirect(plaintext, jwk);
|
|
expect(directPlaintextSize(s3Calls.sealed.length)).toBe(plaintext.length);
|
|
return storage;
|
|
}
|
|
|
|
it("recovers plaintext size at full-segment boundaries", () => {
|
|
for (const size of [1, DIRECT_SEGMENT_BYTES - 1, DIRECT_SEGMENT_BYTES, DIRECT_SEGMENT_BYTES + 1]) {
|
|
const sealedSize = 135 + size + Math.ceil(size / DIRECT_SEGMENT_BYTES) * 32;
|
|
expect(directPlaintextSize(sealedSize)).toBe(size);
|
|
}
|
|
expect(() => directPlaintextSize(167)).toThrow("invalid SPENC2 sealed size");
|
|
expect(() => directPlaintextSize(135 + DIRECT_SEGMENT_BYTES + 33)).toThrow("invalid SPENC2 sealed size");
|
|
});
|
|
|
|
it("slices mid-segment bounds across three segments without fetching earlier segments", async () => {
|
|
const plaintext = randomBytes(4 * DIRECT_SEGMENT_BYTES);
|
|
const storage = await fixture(plaintext);
|
|
const start = DIRECT_SEGMENT_BYTES + 321;
|
|
const end = 3 * DIRECT_SEGMENT_BYTES + 789;
|
|
const bytes = await storage.readDirectRange("direct/clip.enc", start, end - start);
|
|
expect(bytes.equals(plaintext.subarray(start, end))).toBe(true);
|
|
const stride = DIRECT_SEGMENT_BYTES + 32;
|
|
expect(s3Calls.ranges).toEqual(["bytes=0-134", `bytes=${135 + stride}-${135 + 4 * stride - 1}`]);
|
|
expect(s3Calls.sent).toEqual(["Get", "Get"]);
|
|
});
|
|
|
|
it("clips the sealed fetch at EOF and slices a final partial segment", async () => {
|
|
const plaintext = randomBytes(2 * DIRECT_SEGMENT_BYTES + 1234);
|
|
const storage = await fixture(plaintext);
|
|
const start = DIRECT_SEGMENT_BYTES + 17;
|
|
expect((await storage.readDirectRange("direct/clip.enc", start, plaintext.length - start))
|
|
.equals(plaintext.subarray(start))).toBe(true);
|
|
expect(s3Calls.ranges).toEqual(["bytes=0-134",
|
|
`bytes=${135 + DIRECT_SEGMENT_BYTES + 32}-${135 + 3 * (DIRECT_SEGMENT_BYTES + 32) - 1}`]);
|
|
});
|
|
|
|
it("validates the magic on every read before fetching any segments", async () => {
|
|
const storage = await fixture(Buffer.from("video payload"));
|
|
await storage.readDirectRange("direct/clip.enc", 0, 3);
|
|
s3Calls.sealed![0] = 0;
|
|
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow("magic mismatch");
|
|
expect(s3Calls.ranges).toEqual(["bytes=0-134", `bytes=135-${135 + DIRECT_SEGMENT_BYTES + 32 - 1}`, "bytes=0-134"]);
|
|
});
|
|
|
|
it("refuses tampered ciphertext and truncated segments", async () => {
|
|
const storage = await fixture(Buffer.from("authenticated payload"));
|
|
const last = s3Calls.sealed!.length - 1;
|
|
s3Calls.sealed![last] = (s3Calls.sealed![last] ?? 0) ^ 1;
|
|
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow();
|
|
s3Calls.sealed = s3Calls.sealed!.subarray(0, 140);
|
|
await expect(storage.readDirectRange("direct/clip.enc", 0, 3)).rejects.toThrow("truncated");
|
|
});
|
|
|
|
it("rejects ranges beyond the segment count or final plaintext length", async () => {
|
|
const storage = await fixture(Buffer.from("short"));
|
|
await expect(storage.readDirectRange("direct/clip.enc", DIRECT_SEGMENT_BYTES, 1)).rejects.toThrow(RangeError);
|
|
expect(s3Calls.ranges).toEqual(["bytes=0-134"]);
|
|
await expect(storage.readDirectRange("direct/clip.enc", 4, 5)).rejects.toThrow("object length");
|
|
});
|
|
});
|