Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions tests/aws/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AuditQueuePoller } from "../../aws/audit-poller.js";
import { LocalAssetsFetcher } from "../../aws/local-assets.js";
import { AwsS3Bucket } from "../../aws/s3-bucket.js";
import { archiveAuditBatch, AwsSqsQueue } from "../../aws/sqs-queue.js";
import { OAuthVault } from "../../worker/vault.js";

test("S3 adapter preserves bodies and metadata", async () => {
const client = new FakeS3Client();
Expand All @@ -35,6 +36,24 @@ test("S3 adapter preserves bodies and metadata", async () => {
assert.equal(client.lastPut?.ServerSideEncryption, "aws:kms");
});

test("vault caps S3 credential reads without capping the general bucket adapter", async () => {
const client = new FakeS3Client();
const bucket = new AwsS3Bucket(client as unknown as S3Client, "test-bucket");
const huge = "x".repeat(64 * 1024);
await bucket.put("oauth/conn.json", huge);

const object = await bucket.get("oauth/conn.json");
assert.ok(object);
assert.equal(await object.text(), huge);

const masterKey = Buffer.alloc(32, 7).toString("base64url");
const vault = new OAuthVault(bucket, masterKey);
await assert.rejects(
() => vault.get("oauth/conn.json", "conn", "principal", "github"),
/envelope is too large/u,
);
});

test("SQS adapter serializes JSON and audit consumer reports partial failures", async () => {
const client = new FakeSqsClient();
const queue = new AwsSqsQueue(client as unknown as SQSClient, "https://sqs.example/test");
Expand Down
93 changes: 93 additions & 0 deletions tests/vault.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import { OAuthVault } from "../worker/vault.js";

const masterKey = Buffer.alloc(32, 7).toString("base64url");

test("vault get rejects oversized envelopes before reading the object body", async () => {
let textCalls = 0;
const bucket = {
async get() {
return {
size: 1024 * 1024,
async text() {
textCalls += 1;
return "x".repeat(1024 * 1024);
},
};
},
async put() {},
async delete() {},
};
const vault = new OAuthVault(bucket as never, masterKey);
await assert.rejects(
() => vault.get("oauth/conn.json", "conn", "principal", "github"),
/envelope is too large/u,
);
assert.equal(textCalls, 0);
});

test("vault get rejects a huge body when object size is missing", async () => {
const payload = "x".repeat(64 * 1024);
const bucket = {
async get() {
return {
size: 0,
async text() {
return payload;
},
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(payload));
controller.close();
},
}),
};
},
async put() {},
async delete() {},
};
const vault = new OAuthVault(bucket as never, masterKey);
await assert.rejects(
() => vault.get("oauth/conn.json", "conn", "principal", "github"),
/envelope is too large/u,
);
});

test("vault put and get round-trip a max-size secret", async () => {
const store = new Map<string, string>();
const bucket = {
async get(key: string) {
const value = store.get(key);
if (value === undefined) return null;
const bytes = new TextEncoder().encode(value);
return {
size: bytes.byteLength,
async text() {
return value;
},
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
}),
};
},
async put(key: string, value: string) {
store.set(key, value);
},
async delete(key: string) {
store.delete(key);
},
};
const vault = new OAuthVault(bucket as never, masterKey);
const secret = "s".repeat(16 * 1024);
const key = await vault.put("conn", "principal", "github", secret);
assert.equal(key, "oauth/conn.json");
assert.equal(await vault.get(key, "conn", "principal", "github"), secret);
await assert.rejects(
() => vault.put("conn", "principal", "github", `${secret}x`),
/OAuth secret is invalid/u,
);
});
63 changes: 59 additions & 4 deletions worker/vault.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
type Envelope = { v: 1; iv: string; ciphertext: string; createdAt: string };
type VaultObject = {
readonly size: number;
text(): Promise<string>;
readonly body?: ReadableStream<Uint8Array>;
};
type VaultBucket = {
get(key: string): Promise<VaultObject | null>;
put(key: string, value: string, options?: {
httpMetadata?: { contentType?: string };
customMetadata?: Record<string, string>;
}): Promise<unknown>;
delete(key: string): Promise<unknown>;
};
const encoder = new TextEncoder();
const maxSecretBytes = 16 * 1024;
const maxEnvelopeBytes = 32 * 1024;

export class OAuthVault {
readonly #bucket: R2Bucket;
readonly #bucket: VaultBucket;
readonly #masterKey: string;

constructor(bucket: R2Bucket, masterKey: string) {
constructor(bucket: VaultBucket, masterKey: string) {
this.#bucket = bucket;
this.#masterKey = masterKey;
}

async put(connectionId: string, principalId: string, provider: string, secret: string): Promise<string> {
if (!secret || encoder.encode(secret).byteLength > 16 * 1024) throw new Error("OAuth secret is invalid");
if (!secret || encoder.encode(secret).byteLength > maxSecretBytes) throw new Error("OAuth secret is invalid");
const key = vaultKey(connectionId);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: source(iv), additionalData: source(aad(connectionId, principalId, provider)) }, await this.#key(), source(encoder.encode(secret)));
Expand All @@ -24,7 +39,8 @@ export class OAuthVault {
if (vaultObjectKey !== vaultKey(connectionId)) throw new Error("vault key does not match connection");
const object = await this.#bucket.get(vaultObjectKey);
if (!object) throw new Error("OAuth credential is unavailable");
const envelope = JSON.parse(await object.text()) as Envelope;
if (object.size > maxEnvelopeBytes) throw new Error("OAuth credential envelope is too large");
const envelope = JSON.parse(await readCappedText(object, maxEnvelopeBytes)) as Envelope;
if (envelope.v !== 1) throw new Error("OAuth credential envelope is unsupported");
try {
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: source(decode(envelope.iv)), additionalData: source(aad(connectionId, principalId, provider)) }, await this.#key(), source(decode(envelope.ciphertext)));
Expand All @@ -49,3 +65,42 @@ function decode(value: string): Uint8Array {
return new Uint8Array(Buffer.from(value, "base64url"));
}
function source(value: Uint8Array): ArrayBuffer { return Uint8Array.from(value).buffer; }

async function readCappedText(
object: { text(): Promise<string>; body?: ReadableStream<Uint8Array> },
maxBytes: number,
): Promise<string> {
if (object.body) {
return new TextDecoder().decode(await readCappedBytes(object.body, maxBytes));
}
const text = await object.text();
if (encoder.encode(text).byteLength > maxBytes) throw new Error("OAuth credential envelope is too large");
return text;
}

async function readCappedBytes(body: ReadableStream<Uint8Array>, maxBytes: number): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let size = 0;
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel().catch(() => undefined);
throw new Error("OAuth credential envelope is too large");
}
chunks.push(value);
}
} finally {
try { reader.releaseLock(); } catch { /* cancel() already released the lock */ }
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}