fix: logic-audit P0/P1 wave — CORS PUT, chunked cap, presign checksums, logout await, refresh rotation race
From the logic audit (HEAD 04f5f7e):
- P0 CORS: enableCors methods lacked PUT — browsers could not chunk-upload
at all (cross-origin preflight refused)
- P0 chunked: validateUpload enforced the 2 GiB whole-body cap on chunked
completes, so every >2 GiB chunked upload failed after full upload
- P1 presignPut: the SDK's flexible-checksum default added an
x-amz-checksum-crc32 header browsers cannot reproduce; S3Client now uses
requestChecksumCalculation WHEN_REQUIRED
- P1 refresh rotation race: concurrent refreshes both succeeded (consume
ignored rowCount) forking the token family; consume now returns whether
it won and the loser revokes the chain
- P2 logout: awaits the revocation and ignores missing/forged bearers
honestly
This commit is contained in:
parent
e475402b4d
commit
b24ebc5862
19 changed files with 1636 additions and 101 deletions
|
|
@ -1,7 +1,11 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
||||
|
||||
const s3Calls = vi.hoisted(() => ({ sent: [] as string[] }));
|
||||
const s3Calls = vi.hoisted(() => ({
|
||||
sent: [] as string[],
|
||||
ranges: [] as string[],
|
||||
sealed: null as Buffer | null,
|
||||
}));
|
||||
|
||||
vi.mock("@aws-sdk/client-s3", () => {
|
||||
class FakeCommand {
|
||||
|
|
@ -27,13 +31,20 @@ vi.mock("@aws-sdk/client-s3", () => {
|
|||
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 { S3Storage, extensionFor, keyForHash, open, seal, sha256 } from "./storage.js";
|
||||
import { DIRECT_SEGMENT_BYTES, S3Storage, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js";
|
||||
|
||||
const TEST_KEK = createHash("sha256").update("test-kek").digest();
|
||||
|
||||
|
|
@ -116,3 +127,76 @@ describe("S3Storage with object-scoped credentials", () => {
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue