From 2821eb99e55a80eacc323d5592941892f7262613 Mon Sep 17 00:00:00 2001 From: Kazuno Fukuda <4kz12zz@gmail.com> Date: Sun, 12 Jul 2026 18:04:35 +0900 Subject: [PATCH] feat(api): harden credential hashing with HMAC-SHA-256 Store API token and endpoint secret credentials as peppered HMAC-SHA-256 hashes and verify them with constant-time comparisons. Require the credential pepper in deployed environments and thread it through authentication and secret-management flows. Explicitly invalidate unsupported stored credential formats during migration while retaining active endpoint-secret guard rows so private ingest remains fail-closed. Preserve legacy bearer token ID parsing for existing tokens. Add migration, authentication, routing, repository, rate-limit, and smoke coverage, plus deployment guidance for provisioning and replacing credentials. --- .../0002_invalidate_non_hmac_credentials.sql | 16 ++ .../credential-hash-migration.test.ts | 102 +++++++++++++ apps/api/src/app.test.ts | 30 +++- apps/api/src/app.ts | 4 + apps/api/src/application/auth.test.ts | 138 ++++++++++++++++++ apps/api/src/application/auth.ts | 79 +++++++++- .../src/application/credential-hash.test.ts | 126 ++++++++++++++++ apps/api/src/application/credential-hash.ts | 129 ++++++++++++++++ apps/api/src/application/endpoints.ts | 48 +++--- apps/api/src/application/event-stream.ts | 4 +- apps/api/src/application/events.ts | 10 +- apps/api/src/application/ingest.test.ts | 7 +- apps/api/src/application/ingest.ts | 3 +- apps/api/src/application/ingest/phases.ts | 24 +-- apps/api/src/application/rate-limit.test.ts | 17 ++- apps/api/src/application/rate-limit.ts | 2 + apps/api/src/application/tokens.ts | 27 +++- apps/api/src/container.ts | 5 + apps/api/src/domain/ports.ts | 8 +- apps/api/src/infrastructure/d1/index.test.ts | 10 +- .../src/infrastructure/d1/token-repository.ts | 20 ++- .../api/src/infrastructure/in-memory/index.ts | 25 ++-- apps/api/src/presentation/rate-limit.ts | 7 +- .../src/presentation/routes/endpoints.test.ts | 64 +++++--- apps/api/src/presentation/routes/endpoints.ts | 8 + apps/api/src/presentation/routes/events.ts | 5 + .../src/presentation/routes/ingest.test.ts | 25 ++-- apps/api/src/presentation/routes/ingest.ts | 2 + apps/api/src/presentation/routes/mcp.test.ts | 21 ++- apps/api/src/presentation/routes/mcp.ts | 8 + .../presentation/routes/rate-limit.test.ts | 24 ++- .../src/presentation/routes/tokens.test.ts | 55 ++++--- apps/api/src/presentation/routes/tokens.ts | 5 +- apps/api/src/testing/helpers.ts | 25 +++- apps/api/vitest.config.ts | 2 +- apps/api/wrangler.toml | 1 + biome.json | 9 +- docs/api-deployment.md | 58 +++++++- infra/cloudflare/main.tf | 9 ++ .../tests/worker_secrets.tftest.hcl | 49 +++++++ just/cloudflare.just | 1 + tests/smoke/helpers.ts | 2 +- 42 files changed, 1055 insertions(+), 159 deletions(-) create mode 100644 apps/api/migrations/0002_invalidate_non_hmac_credentials.sql create mode 100644 apps/api/migrations/credential-hash-migration.test.ts create mode 100644 apps/api/src/application/auth.test.ts create mode 100644 apps/api/src/application/credential-hash.test.ts create mode 100644 apps/api/src/application/credential-hash.ts create mode 100644 infra/cloudflare/tests/worker_secrets.tftest.hcl diff --git a/apps/api/migrations/0002_invalidate_non_hmac_credentials.sql b/apps/api/migrations/0002_invalidate_non_hmac_credentials.sql new file mode 100644 index 0000000..32e5dc3 --- /dev/null +++ b/apps/api/migrations/0002_invalidate_non_hmac_credentials.sql @@ -0,0 +1,16 @@ +-- This pre-release migration intentionally invalidates credential formats that +-- cannot be verified with the required server-side pepper. +DELETE FROM endpoint_secrets +WHERE status != 'active' +AND NOT ( + length(secret_hash) = 76 + AND substr(secret_hash, 1, 12) = 'hmac-sha256$' + AND substr(secret_hash, 13) NOT GLOB '*[^0-9a-f]*' +); + +DELETE FROM tokens +WHERE NOT ( + length(token_hash) = 76 + AND substr(token_hash, 1, 12) = 'hmac-sha256$' + AND substr(token_hash, 13) NOT GLOB '*[^0-9a-f]*' +); diff --git a/apps/api/migrations/credential-hash-migration.test.ts b/apps/api/migrations/credential-hash-migration.test.ts new file mode 100644 index 0000000..a0ea300 --- /dev/null +++ b/apps/api/migrations/credential-hash-migration.test.ts @@ -0,0 +1,102 @@ +import { readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; + +import { afterEach, describe, expect, it } from "vitest"; + +const readMigration = (name: string) => + readFileSync(new URL(name, import.meta.url), "utf8"); + +describe("credential hash migration", () => { + let database: DatabaseSync | undefined; + + afterEach(() => { + database?.close(); + }); + + it("invalidates non-HMAC credentials without leaving private ingest unguarded", () => { + database = new DatabaseSync(":memory:"); + database.exec(readMigration("0001_initial_schema.sql")); + database.exec(` + INSERT INTO endpoints ( + id, account_id, name, mode, status, public_read, event_count, + event_limit, expires_at, created_at, updated_at + ) VALUES ( + 'ep_migration', 'acc_mvp', 'migration', 'private', 'active', 0, 1, + 1000, '2026-07-12T12:00:00.000Z', '2026-07-05T12:00:00.000Z', + '2026-07-05T12:00:00.000Z' + ); + + INSERT INTO events ( + id, endpoint_id, received_at, method, ingest_path, request_path, + query_json, allowlist_headers_json, sensitive_header_names_json, + content_type, content_length, user_agent, body_size, body_sha256, + body_r2_key, request_r2_key, secret_verification_status, + matched_secret_id, created_at + ) VALUES ( + 'evt_migration', 'ep_migration', '2026-07-05T12:00:00.000Z', 'POST', + '/ep_migration', '/', '{}', '{}', '[]', NULL, NULL, NULL, 0, 'body-hash', + 'events/body.raw', 'events/request.json', 'matched', 'sec_legacy_sha', + '2026-07-05T12:00:00.000Z' + ); + `); + + const insertToken = database.prepare(` + INSERT INTO tokens ( + id, account_id, name, token_hash, status, created_at, + last_used_at, revoked_at + ) VALUES (?, 'acc_mvp', NULL, ?, 'active', '2026-07-05T12:00:00.000Z', NULL, NULL) + `); + insertToken.run("tok_legacy_sha", "a".repeat(64)); + insertToken.run( + "tok_pbkdf2", + `pbkdf2-sha256$600000$${"b".repeat(32)}$${"c".repeat(64)}`, + ); + insertToken.run("tok_malformed", `hmac-sha256$${"G".repeat(64)}`); + insertToken.run("tok_hmac", `hmac-sha256$${"d".repeat(64)}`); + + const insertEndpointSecret = database.prepare(` + INSERT INTO endpoint_secrets ( + id, endpoint_id, secret_hash, status, created_at, + last_used_at, revoked_at + ) VALUES (?, 'ep_migration', ?, ?, '2026-07-05T12:00:00.000Z', NULL, NULL) + `); + insertEndpointSecret.run("sec_legacy_sha", "e".repeat(64), "active"); + insertEndpointSecret.run( + "sec_malformed", + "not-a-credential-hash", + "active", + ); + insertEndpointSecret.run("sec_revoked_legacy", "1".repeat(64), "revoked"); + insertEndpointSecret.run( + "sec_hmac", + `hmac-sha256$${"f".repeat(64)}`, + "active", + ); + + database.exec(readMigration("0002_invalidate_non_hmac_credentials.sql")); + + expect(database.prepare("SELECT id FROM tokens ORDER BY id").all()).toEqual( + [{ id: "tok_hmac" }], + ); + expect( + database.prepare("SELECT id FROM endpoint_secrets ORDER BY id").all(), + ).toEqual([ + { id: "sec_hmac" }, + { id: "sec_legacy_sha" }, + { id: "sec_malformed" }, + ]); + expect( + database + .prepare( + "SELECT COUNT(*) AS count FROM endpoint_secrets WHERE endpoint_id = 'ep_migration' AND status = 'active'", + ) + .get(), + ).toEqual({ count: 3 }); + expect(database.prepare("SELECT id FROM endpoints").all()).toEqual([ + { id: "ep_migration" }, + ]); + expect(database.prepare("SELECT id FROM events").all()).toEqual([ + { id: "evt_migration" }, + ]); + }); +}); diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index a28484b..71feb81 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -114,7 +114,7 @@ describe("API Worker", () => { error: { code: "internal_error", message: - "Required runtime bindings are not configured: DB, REQUEST_BODIES, ABUSE_IP_RATE_LIMITER, INGEST_ENDPOINT_RATE_LIMITER, ENDPOINT_CREATION_RATE_LIMITER, AUTH_RATE_LIMITER, MCP_RATE_LIMITER, WRITE_RATE_LIMITER, SSE_RATE_LIMITER.", + "Required runtime bindings are not configured: DB, REQUEST_BODIES, BARESTASH_CREDENTIAL_PEPPER, ABUSE_IP_RATE_LIMITER, INGEST_ENDPOINT_RATE_LIMITER, ENDPOINT_CREATION_RATE_LIMITER, AUTH_RATE_LIMITER, MCP_RATE_LIMITER, WRITE_RATE_LIMITER, SSE_RATE_LIMITER.", }, }); expect(error).toHaveBeenCalledWith( @@ -123,6 +123,7 @@ describe("API Worker", () => { missing_bindings: [ "DB", "REQUEST_BODIES", + "BARESTASH_CREDENTIAL_PEPPER", "ABUSE_IP_RATE_LIMITER", "INGEST_ENDPOINT_RATE_LIMITER", "ENDPOINT_CREATION_RATE_LIMITER", @@ -150,8 +151,30 @@ describe("API Worker", () => { }); it.each([ - [{ DB: {} as D1Database, ...configuredRateLimiters }, "REQUEST_BODIES"], - [{ REQUEST_BODIES: {} as R2Bucket, ...configuredRateLimiters }, "DB"], + [ + { + DB: {} as D1Database, + BARESTASH_CREDENTIAL_PEPPER: "test-pepper", + ...configuredRateLimiters, + }, + "REQUEST_BODIES", + ], + [ + { + REQUEST_BODIES: {} as R2Bucket, + BARESTASH_CREDENTIAL_PEPPER: "test-pepper", + ...configuredRateLimiters, + }, + "DB", + ], + [ + { + DB: {} as D1Database, + REQUEST_BODIES: {} as R2Bucket, + ...configuredRateLimiters, + }, + "BARESTASH_CREDENTIAL_PEPPER", + ], ])("identifies an individually missing binding", async (env, missing) => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); const app = createApiApp(); @@ -186,6 +209,7 @@ describe("API Worker", () => { const env: Record = { DB: {} as D1Database, REQUEST_BODIES: {} as R2Bucket, + BARESTASH_CREDENTIAL_PEPPER: "test-pepper", ...configuredRateLimiters, }; delete env[missing]; diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9016c82..7a1c75a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -68,9 +68,13 @@ export function createApiApp(options: CreateApiAppOptions = {}): Hono { return; } + const credentialPepper = context.env?.BARESTASH_CREDENTIAL_PEPPER; const missingBindings = [ context.env?.DB === undefined ? "DB" : undefined, context.env?.REQUEST_BODIES === undefined ? "REQUEST_BODIES" : undefined, + credentialPepper === undefined || credentialPepper === "" + ? "BARESTASH_CREDENTIAL_PEPPER" + : undefined, context.env?.BARESTASH_API_HOSTNAME !== undefined && context.env?.BARESTASH_INGEST_HOSTNAME === undefined ? "BARESTASH_INGEST_HOSTNAME" diff --git a/apps/api/src/application/auth.test.ts b/apps/api/src/application/auth.test.ts new file mode 100644 index 0000000..97d188a --- /dev/null +++ b/apps/api/src/application/auth.test.ts @@ -0,0 +1,138 @@ +import { + assertStoredTokenId, + type TokenId, + testTokenId, +} from "@barestash/shared"; +import { describe, expect, it } from "vitest"; + +import type { TokenRepository } from "../domain/ports.js"; +import { hasBootstrapAuth, verifyRequestAuthentication } from "./auth.js"; +import { hashCredential } from "./credential-hash.js"; + +const TEST_BOOTSTRAP_TOKEN = "bootstrap-secret-for-local-staging-tests-ok"; + +describe("hasBootstrapAuth", () => { + it("rejects configured bootstrap tokens shorter than the minimum length", () => { + expect( + hasBootstrapAuth( + true, + TEST_BOOTSTRAP_TOKEN, + "too-short-for-bootstrap-token", + ), + ).toBe(false); + }); + + it("accepts matching bootstrap tokens with constant-time comparison", () => { + expect( + hasBootstrapAuth(true, TEST_BOOTSTRAP_TOKEN, TEST_BOOTSTRAP_TOKEN), + ).toBe(true); + expect( + hasBootstrapAuth(true, `${TEST_BOOTSTRAP_TOKEN}x`, TEST_BOOTSTRAP_TOKEN), + ).toBe(false); + }); +}); + +describe("verifyRequestAuthentication", () => { + it("authenticates PAT bearer tokens by token id and derived secret hash", async () => { + const tokenId = testTokenId("auth"); + const secretSuffix = "abcdefghijklmnopqrstuvwxyz012345"; + const bearerToken = `bst_pat_${tokenId.slice("tok_".length)}_${secretSuffix}`; + const tokenHash = await hashCredential(secretSuffix, { pepper: "" }); + const tokenRepository = { + async findActiveTokenById() { + return { + id: tokenId, + name: "test token", + status: "active" as const, + created_at: "2026-07-05T12:00:00.000Z", + last_used_at: null, + revoked_at: null, + account_id: "acc_mvp", + token_hash: tokenHash, + }; + }, + } as unknown as TokenRepository; + + await expect( + verifyRequestAuthentication(`Bearer ${bearerToken}`, tokenRepository, { + pepper: "", + }), + ).resolves.toEqual({ + kind: "ok", + value: { + accountId: "acc_mvp", + tokenId, + }, + }); + }); + + it("rejects bearer tokens with an invalid secret hash", async () => { + const tokenId = testTokenId("invalid"); + const secretSuffix = "abcdefghijklmnopqrstuvwxyz012345"; + const bearerToken = `bst_pat_${tokenId.slice("tok_".length)}_${secretSuffix}`; + const tokenRepository = { + async findActiveTokenById() { + return { + id: tokenId, + name: "test token", + status: "active" as const, + created_at: "2026-07-05T12:00:00.000Z", + last_used_at: null, + revoked_at: null, + account_id: "acc_mvp", + token_hash: await hashCredential("different-secret", { pepper: "" }), + }; + }, + } as unknown as TokenRepository; + + await expect( + verifyRequestAuthentication(`Bearer ${bearerToken}`, tokenRepository, { + pepper: "", + }), + ).resolves.toEqual({ + kind: "error", + code: "not_authenticated", + message: "Authentication is required.", + status: 401, + }); + }); + + it.each([ + "-", + "_", + ])("authenticates stored PAT ids containing a legacy %s suffix character", async (legacyCharacter) => { + const tokenIdSuffix = `${"A".repeat(23)}${legacyCharacter}`; + const tokenId = assertStoredTokenId(`tok_${tokenIdSuffix}`); + const secretSuffix = "abcdefghijklmnopqrstuvwxyz012345"; + const bearerToken = `bst_pat_${tokenIdSuffix}_${secretSuffix}`; + const tokenHash = await hashCredential(secretSuffix, { pepper: "" }); + const tokenRepository = { + async findActiveTokenById(candidateTokenId: TokenId) { + expect(candidateTokenId).toBe(tokenId); + + return { + id: tokenId, + name: "legacy id token", + status: "active" as const, + created_at: "2026-07-05T12:00:00.000Z", + last_used_at: null, + revoked_at: null, + account_id: "acc_mvp", + token_hash: tokenHash, + }; + }, + } as unknown as TokenRepository; + + await expect( + verifyRequestAuthentication(`Bearer ${bearerToken}`, tokenRepository, { + pepper: "", + }), + ).resolves.toEqual({ + kind: "ok", + value: { + accountId: "acc_mvp", + tokenId, + }, + }); + }); +}); diff --git a/apps/api/src/application/auth.ts b/apps/api/src/application/auth.ts index 101daa8..46baf55 100644 --- a/apps/api/src/application/auth.ts +++ b/apps/api/src/application/auth.ts @@ -1,5 +1,19 @@ +import { + BEARER_TOKEN_SECRET_LENGTH, + ID_PREFIXES, + isStoredTokenId, + TOKEN_ID_SUFFIX_LENGTH, + type TokenId, +} from "@barestash/shared"; + import type { TokenRepository } from "../domain/ports.js"; import type { AuthenticatedAccount } from "../domain/token.js"; +import { + type CredentialHashOptions, + isStrongBootstrapToken, + timingSafeEqualString, + verifyCredential, +} from "./credential-hash.js"; import { err, ok, type UseCaseResult } from "./result.js"; export async function sha256Hex(bytes: Uint8Array): Promise { @@ -10,14 +24,47 @@ export async function sha256Hex(bytes: Uint8Array): Promise { .join(""); } +export type AuthenticationOptions = CredentialHashOptions; + +export type CredentialPepperDeps = { + credentialPepper?: string; +}; + +type ParsedPatAuthenticationToken = { + tokenId: TokenId; + secret: string; +}; + +const PAT_AUTHENTICATION_TOKEN_PATTERN = new RegExp( + `^bst_pat_([A-Za-z0-9_-]{${TOKEN_ID_SUFFIX_LENGTH}})_([A-Za-z0-9]{${BEARER_TOKEN_SECRET_LENGTH}})$`, +); + +function parsePatAuthenticationToken( + value: string, +): ParsedPatAuthenticationToken | null { + const match = value.match(PAT_AUTHENTICATION_TOKEN_PATTERN); + const tokenIdSuffix = match?.[1]; + const secret = match?.[2]; + + if (tokenIdSuffix === undefined || secret === undefined) { + return null; + } + + const tokenId = `${ID_PREFIXES.token}${tokenIdSuffix}`; + + return isStoredTokenId(tokenId) ? { tokenId, secret } : null; +} + export async function authenticateRequest( authorizationHeader: string | null, tokenRepository: TokenRepository, now: Date, + options: AuthenticationOptions = {}, ): Promise> { const authenticated = await verifyRequestAuthentication( authorizationHeader, tokenRepository, + options, ); if (authenticated.kind === "error") { @@ -35,6 +82,7 @@ export async function authenticateRequest( export async function verifyRequestAuthentication( authorizationHeader: string | null, tokenRepository: TokenRepository, + options: AuthenticationOptions = {}, ): Promise> { const match = authorizationHeader?.match(/^Bearer\s+(.+)$/i); @@ -42,13 +90,34 @@ export async function verifyRequestAuthentication( return err("not_authenticated", "Authentication is required.", 401); } - const tokenHash = await sha256Hex(new TextEncoder().encode(match[1])); - const token = await tokenRepository.findActiveTokenByHash(tokenHash); + const bearerToken = match[1]; + + if (bearerToken === undefined) { + return err("not_authenticated", "Authentication is required.", 401); + } + + const parsed = parsePatAuthenticationToken(bearerToken); + + if (parsed === null) { + return err("not_authenticated", "Authentication is required.", 401); + } + + const token = await tokenRepository.findActiveTokenById(parsed.tokenId); if (token === null) { return err("not_authenticated", "Authentication is required.", 401); } + const verified = await verifyCredential( + parsed.secret, + token.token_hash, + options, + ); + + if (!verified) { + return err("not_authenticated", "Authentication is required.", 401); + } + return ok({ accountId: token.account_id, tokenId: token.id, @@ -62,8 +131,8 @@ export function hasBootstrapAuth( ): boolean { return ( bootstrapTokenEnabled && - configuredBootstrapToken !== undefined && - configuredBootstrapToken.length > 0 && - bootstrapTokenHeader === configuredBootstrapToken + isStrongBootstrapToken(configuredBootstrapToken) && + bootstrapTokenHeader !== null && + timingSafeEqualString(bootstrapTokenHeader, configuredBootstrapToken) ); } diff --git a/apps/api/src/application/credential-hash.test.ts b/apps/api/src/application/credential-hash.test.ts new file mode 100644 index 0000000..154fb60 --- /dev/null +++ b/apps/api/src/application/credential-hash.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { + hashCredential, + timingSafeEqual, + timingSafeEqualString, + verifyCredential, +} from "./credential-hash.js"; + +const TEST_PEPPER = "test-pepper"; + +describe("timingSafeEqual", () => { + it("returns true for equal byte arrays", () => { + const left = new Uint8Array([1, 2, 3, 4]); + const right = new Uint8Array([1, 2, 3, 4]); + + expect(timingSafeEqual(left, right)).toBe(true); + }); + + it("returns false for different byte arrays of the same length", () => { + const left = new Uint8Array([1, 2, 3, 4]); + const right = new Uint8Array([1, 2, 3, 5]); + + expect(timingSafeEqual(left, right)).toBe(false); + }); + + it("returns false for different-length byte arrays", () => { + expect( + timingSafeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3])), + ).toBe(false); + }); +}); + +describe("timingSafeEqualString", () => { + it("returns true for equal strings", () => { + expect( + timingSafeEqualString( + "bootstrap-secret-for-local-staging-tests-ok", + "bootstrap-secret-for-local-staging-tests-ok", + ), + ).toBe(true); + }); + + it("returns false for different strings", () => { + expect( + timingSafeEqualString( + "bootstrap-secret-for-local-staging-tests-ok", + "bootstrap-secreX", + ), + ).toBe(false); + }); + + it("returns false for different-length strings", () => { + expect(timingSafeEqualString("short", "longer-value")).toBe(false); + }); +}); + +describe("hashCredential", () => { + it("returns a self-describing hmac-sha256 hash string", async () => { + const stored = await hashCredential("secret-value", { + pepper: TEST_PEPPER, + }); + + expect(stored).toMatch(/^hmac-sha256\$[0-9a-f]{64}$/); + }); + + it("generates the same stored hash for the same secret and pepper", async () => { + const first = await hashCredential("secret-value", { pepper: TEST_PEPPER }); + const second = await hashCredential("secret-value", { + pepper: TEST_PEPPER, + }); + + expect(first).toBe(second); + }); + + it("generates different stored hashes when the pepper differs", async () => { + const first = await hashCredential("secret-value", { pepper: TEST_PEPPER }); + const second = await hashCredential("secret-value", { + pepper: "other-pepper", + }); + + expect(first).not.toBe(second); + }); +}); + +describe("verifyCredential", () => { + it("accepts secrets that match the stored hash", async () => { + const stored = await hashCredential("application-secret", { + pepper: TEST_PEPPER, + }); + + await expect( + verifyCredential("application-secret", stored, { pepper: TEST_PEPPER }), + ).resolves.toBe(true); + }); + + it("rejects secrets that do not match the stored hash", async () => { + const stored = await hashCredential("application-secret", { + pepper: TEST_PEPPER, + }); + + await expect( + verifyCredential("wrong-secret", stored, { pepper: TEST_PEPPER }), + ).resolves.toBe(false); + }); + + it("rejects verification when the pepper differs", async () => { + const stored = await hashCredential("application-secret", { + pepper: TEST_PEPPER, + }); + + await expect( + verifyCredential("application-secret", stored, { + pepper: "other-pepper", + }), + ).resolves.toBe(false); + }); + + it("rejects malformed stored hashes", async () => { + await expect( + verifyCredential("application-secret", "not-a-valid-hash", { + pepper: TEST_PEPPER, + }), + ).resolves.toBe(false); + }); +}); diff --git a/apps/api/src/application/credential-hash.ts b/apps/api/src/application/credential-hash.ts new file mode 100644 index 0000000..782dbc4 --- /dev/null +++ b/apps/api/src/application/credential-hash.ts @@ -0,0 +1,129 @@ +export const CREDENTIAL_HASH_ALGORITHM = "hmac-sha256"; +export const CREDENTIAL_HASH_BYTES = 32; +export const BOOTSTRAP_TOKEN_MIN_LENGTH = 32; + +// HMAC requires a non-empty key. Empty pepper is only permitted in +// allowInMemoryStorage tests; production requests fail closed without +// BARESTASH_CREDENTIAL_PEPPER via app middleware. +const EMPTY_PEPPER_HMAC_KEY = "barestash-empty-pepper-dev-only"; + +export type CredentialHashOptions = { + pepper?: string; +}; + +export function timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) { + return false; + } + + let mismatch = 0; + + for (let index = 0; index < left.length; index += 1) { + mismatch |= (left[index] ?? 0) ^ (right[index] ?? 0); + } + + return mismatch === 0; +} + +export function timingSafeEqualString(left: string, right: string): boolean { + const encoder = new TextEncoder(); + const leftBytes = encoder.encode(left); + const rightBytes = encoder.encode(right); + + return timingSafeEqual(leftBytes, rightBytes); +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function hexToBytes(value: string): Uint8Array | null { + if (value.length % 2 !== 0 || !/^[0-9a-f]+$/.test(value)) { + return null; + } + + const bytes = new Uint8Array(value.length / 2); + + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + + return bytes; +} + +async function deriveCredentialHash( + secret: string, + options: CredentialHashOptions = {}, +): Promise { + const pepper = options.pepper ?? ""; + const keyMaterial = pepper === "" ? EMPTY_PEPPER_HMAC_KEY : pepper; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(keyMaterial), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(secret), + ); + + return new Uint8Array(signature); +} + +function parseStoredCredentialHash(storedHash: string): Uint8Array | null { + const parts = storedHash.split("$"); + + if (parts.length !== 2) { + return null; + } + + const [algorithm, hashHex] = parts; + + if (algorithm !== CREDENTIAL_HASH_ALGORITHM) { + return null; + } + + const hash = hexToBytes(hashHex ?? ""); + + if (hash === null || hash.length !== CREDENTIAL_HASH_BYTES) { + return null; + } + + return hash; +} + +export async function hashCredential( + secret: string, + options: CredentialHashOptions = {}, +): Promise { + const derived = await deriveCredentialHash(secret, options); + + return `${CREDENTIAL_HASH_ALGORITHM}$${bytesToHex(derived)}`; +} + +export async function verifyCredential( + secret: string, + storedHash: string, + options: CredentialHashOptions = {}, +): Promise { + const parsed = parseStoredCredentialHash(storedHash); + + if (parsed === null) { + return false; + } + + const derived = await deriveCredentialHash(secret, options); + + return timingSafeEqual(derived, parsed); +} + +export function isStrongBootstrapToken( + token: string | undefined, +): token is string { + return token !== undefined && token.length >= BOOTSTRAP_TOKEN_MIN_LENGTH; +} diff --git a/apps/api/src/application/endpoints.ts b/apps/api/src/application/endpoints.ts index 6276a5b..1b96654 100644 --- a/apps/api/src/application/endpoints.ts +++ b/apps/api/src/application/endpoints.ts @@ -22,7 +22,8 @@ import type { RequestBodyStore, TokenRepository, } from "../domain/ports.js"; -import { authenticateRequest, sha256Hex } from "./auth.js"; +import { authenticateRequest, type CredentialPepperDeps } from "./auth.js"; +import { hashCredential } from "./credential-hash.js"; import { deletePrivateEndpointData, PrivateEndpointEventReadError, @@ -30,7 +31,7 @@ import { } from "./delete-private-endpoint-data.js"; import { err, ok, type UseCaseResult } from "./result.js"; -export type CreateEndpointDeps = { +export type CreateEndpointDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; now: Date; @@ -49,6 +50,7 @@ export async function createEndpoint( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { @@ -104,7 +106,7 @@ export async function createEndpoint( } } -export type ListEndpointsDeps = { +export type ListEndpointsDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; now: Date; @@ -120,6 +122,7 @@ export async function listEndpoints( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { @@ -145,7 +148,7 @@ export async function listEndpoints( }); } -export type ShowEndpointDeps = { +export type ShowEndpointDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; now: Date; @@ -179,6 +182,7 @@ export async function showEndpoint( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { @@ -243,14 +247,16 @@ export async function showEndpoint( }); } -async function resolveOwnedPrivateEndpoint(deps: { - endpointRepository: EndpointRepository; - tokenRepository: TokenRepository; - now: Date; - authorizationHeader: string | null; - endpointId: EndpointId; - notAuthenticatedMessage: string; -}): Promise< +async function resolveOwnedPrivateEndpoint( + deps: CredentialPepperDeps & { + endpointRepository: EndpointRepository; + tokenRepository: TokenRepository; + now: Date; + authorizationHeader: string | null; + endpointId: EndpointId; + notAuthenticatedMessage: string; + }, +): Promise< UseCaseResult<{ endpoint: StoredEndpoint; accountId: import("../domain/endpoint.js").AccountId; @@ -284,6 +290,7 @@ async function resolveOwnedPrivateEndpoint(deps: { deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { @@ -313,7 +320,7 @@ async function resolveOwnedPrivateEndpoint(deps: { return ok({ endpoint, accountId: authenticated.value.accountId }); } -export type CreateEndpointSecretDeps = { +export type CreateEndpointSecretDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; endpointSecretRepository: EndpointSecretRepository; tokenRepository: TokenRepository; @@ -333,6 +340,7 @@ export async function createEndpointSecret( now: deps.now, authorizationHeader: deps.authorizationHeader, endpointId: deps.endpointId, + credentialPepper: deps.credentialPepper, notAuthenticatedMessage: "Authentication is required to manage endpoint secrets.", }); @@ -342,7 +350,9 @@ export async function createEndpointSecret( } const secret = deps.makeEndpointSecret(); - const secretHash = await sha256Hex(new TextEncoder().encode(secret)); + const secretHash = await hashCredential(secret, { + pepper: deps.credentialPepper ?? "", + }); try { const endpointSecret = @@ -362,7 +372,7 @@ export async function createEndpointSecret( } } -export type ListEndpointSecretsDeps = { +export type ListEndpointSecretsDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; endpointSecretRepository: EndpointSecretRepository; tokenRepository: TokenRepository; @@ -380,6 +390,7 @@ export async function listEndpointSecrets( now: deps.now, authorizationHeader: deps.authorizationHeader, endpointId: deps.endpointId, + credentialPepper: deps.credentialPepper, notAuthenticatedMessage: "Authentication is required to manage endpoint secrets.", }); @@ -414,6 +425,7 @@ export async function revokeEndpointSecret( now: deps.now, authorizationHeader: deps.authorizationHeader, endpointId: deps.endpointId, + credentialPepper: deps.credentialPepper, notAuthenticatedMessage: "Authentication is required to manage endpoint secrets.", }); @@ -443,7 +455,7 @@ export async function revokeEndpointSecret( } } -export type DeleteEndpointDeps = { +export type DeleteEndpointDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; endpointSecretRepository: EndpointSecretRepository; eventRepository: EventRepository; @@ -490,6 +502,7 @@ export async function deleteEndpoint( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { @@ -576,7 +589,7 @@ export async function deleteEndpoint( }); } -export type ResolveReadableEndpointDeps = { +export type ResolveReadableEndpointDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; now: Date; @@ -609,6 +622,7 @@ export async function resolveReadableEndpoint( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { diff --git a/apps/api/src/application/event-stream.ts b/apps/api/src/application/event-stream.ts index 7577853..023315f 100644 --- a/apps/api/src/application/event-stream.ts +++ b/apps/api/src/application/event-stream.ts @@ -12,10 +12,11 @@ import type { RequestBodyStore, TokenRepository, } from "../domain/ports.js"; +import type { CredentialPepperDeps } from "./auth.js"; import { resolveReadableEndpoint } from "./endpoints.js"; import { err, ok, type UseCaseResult } from "./result.js"; -export type OpenEventStreamDeps = { +export type OpenEventStreamDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; eventRepository: EventRepository; @@ -40,6 +41,7 @@ export async function openEventStream( now: deps.now, authorizationHeader: deps.authorizationHeader, endpointId: deps.endpointId, + credentialPepper: deps.credentialPepper, privateMessage: "Authentication is required to read private endpoint events.", }); diff --git a/apps/api/src/application/events.ts b/apps/api/src/application/events.ts index 7d5924b..3cb2bbf 100644 --- a/apps/api/src/application/events.ts +++ b/apps/api/src/application/events.ts @@ -19,10 +19,11 @@ import type { RequestBodyStore, TokenRepository, } from "../domain/ports.js"; +import type { CredentialPepperDeps } from "./auth.js"; import { resolveReadableEndpoint } from "./endpoints.js"; import { err, ok, type UseCaseResult } from "./result.js"; -export type ListEventsDeps = { +export type ListEventsDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; eventRepository: EventRepository; @@ -43,6 +44,7 @@ export async function listEvents( now: deps.now, authorizationHeader: deps.authorizationHeader, endpointId: deps.endpointId, + credentialPepper: deps.credentialPepper, privateMessage: "Authentication is required to read private endpoint events.", }); @@ -81,7 +83,7 @@ export async function listEvents( }); } -export type GetEventDetailDeps = { +export type GetEventDetailDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; eventRepository: EventRepository; @@ -112,6 +114,7 @@ export async function getEventDetail( now: deps.now, authorizationHeader: deps.authorizationHeader, endpointId: event.endpoint_id, + credentialPepper: deps.credentialPepper, privateMessage: "Authentication is required to read private endpoint events.", }); @@ -149,7 +152,7 @@ export async function getEventDetail( return ok(eventDetailFromInsert(event, envelope)); } -export type GetEventBodyDeps = { +export type GetEventBodyDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; tokenRepository: TokenRepository; eventRepository: EventRepository; @@ -187,6 +190,7 @@ export async function getEventBody( now: deps.now, authorizationHeader: deps.authorizationHeader, endpointId: event.endpoint_id, + credentialPepper: deps.credentialPepper, privateMessage: "Authentication is required to read private endpoint events.", }); diff --git a/apps/api/src/application/ingest.test.ts b/apps/api/src/application/ingest.test.ts index ff145ea..b0dfd2a 100644 --- a/apps/api/src/application/ingest.test.ts +++ b/apps/api/src/application/ingest.test.ts @@ -9,10 +9,10 @@ import type { } from "../domain/ports.js"; import { fixedNow, + hashCredentialForTest, makeTemporaryEndpointRepository, RecordingEventRepository, RecordingRequestBodyStore, - sha256Hex, } from "../testing/helpers.js"; import { type IngestDeps, ingestRequest } from "./ingest.js"; @@ -160,7 +160,7 @@ describe("ingestRequest", () => { { id: "sec_application", endpoint_id: "ep_01JDEF" as EndpointId, - secret_hash: await sha256Hex(new TextEncoder().encode(rawSecret)), + secret_hash: await hashCredentialForTest(rawSecret), status: "active" as const, created_at: fixedNow.toISOString(), last_used_at: null, @@ -175,7 +175,8 @@ describe("ingestRequest", () => { method: "POST", headers: { [BARESTASH_SECRET_HEADER]: rawSecret, - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", authorization: "Bearer provider-token", }, body: "private payload", diff --git a/apps/api/src/application/ingest.ts b/apps/api/src/application/ingest.ts index b1406f6..c6e63ac 100644 --- a/apps/api/src/application/ingest.ts +++ b/apps/api/src/application/ingest.ts @@ -7,6 +7,7 @@ import type { EventStreamCoordinator, RequestBodyStore, } from "../domain/ports.js"; +import type { CredentialPepperDeps } from "./auth.js"; import { CompensationStack } from "./ingest/compensation.js"; import { IngestPhaseError, @@ -19,7 +20,7 @@ import { } from "./ingest/phases.js"; import { err, ok, type UseCaseResult } from "./result.js"; -export type IngestDeps = { +export type IngestDeps = CredentialPepperDeps & { endpointRepository: EndpointRepository; endpointSecretRepository: EndpointSecretRepository; eventRepository: EventRepository; diff --git a/apps/api/src/application/ingest/phases.ts b/apps/api/src/application/ingest/phases.ts index 950780a..dc7d796 100644 --- a/apps/api/src/application/ingest/phases.ts +++ b/apps/api/src/application/ingest/phases.ts @@ -22,6 +22,7 @@ import { } from "../../domain/event.js"; import type { EndpointSecretRepository } from "../../domain/ports.js"; import { sha256Hex } from "../auth.js"; +import { verifyCredential } from "../credential-hash.js"; import type { IngestDeps } from "../ingest.js"; import { err, type UseCaseError } from "../result.js"; import type { CompensationStack } from "./compensation.js"; @@ -299,14 +300,19 @@ export async function verifySecret( ); } - const providedSecretHash = await sha256Hex( - new TextEncoder().encode(providedSecret), - ); - const matchedSecret = activeSecrets.find( - (secret) => secret.secret_hash === providedSecretHash, - ); + let matchedSecretId: import("@barestash/shared").SecretId | null = null; + + for (const secret of activeSecrets) { + const matches = await verifyCredential(providedSecret, secret.secret_hash, { + pepper: deps.credentialPepper ?? "", + }); + + if (matches) { + matchedSecretId = secret.id; + } + } - if (matchedSecret === undefined) { + if (matchedSecretId === null) { fail( "invalid_ingest_secret", "Webhook rejected: invalid x-barestash-secret.", @@ -316,7 +322,7 @@ export async function verifySecret( try { await deps.endpointSecretRepository.updateEndpointSecretLastUsed( - matchedSecret.id, + matchedSecretId, deps.getNow().toISOString(), ); } catch { @@ -327,7 +333,7 @@ export async function verifySecret( ); } - return { status: "matched", matchedSecretId: matchedSecret.id }; + return { status: "matched", matchedSecretId }; } export async function persistRawRequest( diff --git a/apps/api/src/application/rate-limit.test.ts b/apps/api/src/application/rate-limit.test.ts index b94181a..83225a3 100644 --- a/apps/api/src/application/rate-limit.test.ts +++ b/apps/api/src/application/rate-limit.test.ts @@ -1,6 +1,8 @@ +import { testTokenId } from "@barestash/shared"; import { describe, expect, it, vi } from "vitest"; import type { TokenRepository } from "../domain/ports.js"; +import { hashCredential } from "./credential-hash.js"; import { checkRateLimit, clientIpRateLimitKey, @@ -47,34 +49,39 @@ describe("rate limiting", () => { }); it("uses only verified token IDs and falls back to IP for invalid credentials", async () => { + const tokenId = testTokenId("verified"); + const secretSuffix = "abcdefghijklmnopqrstuvwxyz012345"; + const bearerToken = `bst_pat_${tokenId.slice("tok_".length)}_${secretSuffix}`; + const tokenHash = await hashCredential(secretSuffix, { pepper: "" }); const request = new Request("https://api.example.com", { headers: { - authorization: "Bearer bst_super_secret", + authorization: `Bearer ${bearerToken}`, "cf-connecting-ip": "203.0.113.8", }, }); const validRepository = { - async findActiveTokenByHash() { + async findActiveTokenById() { return { - id: "tok_verified", + id: tokenId, account_id: "acct_mvp", name: null, status: "active", created_at: "2026-07-05T12:00:00.000Z", last_used_at: null, revoked_at: null, + token_hash: tokenHash, }; }, } as unknown as TokenRepository; const invalidRepository = { - async findActiveTokenByHash() { + async findActiveTokenById() { return null; }, } as unknown as TokenRepository; await expect( verifiedRequestActorRateLimitKey(request, validRepository), - ).resolves.toBe("token:tok_verified"); + ).resolves.toBe(`token:${tokenId}`); await expect( verifiedRequestActorRateLimitKey(request, invalidRepository), ).resolves.toBe("ip:203.0.113.8"); diff --git a/apps/api/src/application/rate-limit.ts b/apps/api/src/application/rate-limit.ts index 0c8de85..5569fe2 100644 --- a/apps/api/src/application/rate-limit.ts +++ b/apps/api/src/application/rate-limit.ts @@ -23,10 +23,12 @@ export function clientIpRateLimitKey(request: Request): string { export async function verifiedRequestActorRateLimitKey( request: Request, tokenRepository: TokenRepository, + credentialPepper = "", ): Promise { const authenticated = await verifyRequestAuthentication( request.headers.get("authorization"), tokenRepository, + { pepper: credentialPepper }, ); return authenticated.kind === "ok" diff --git a/apps/api/src/application/tokens.ts b/apps/api/src/application/tokens.ts index 3f6a959..d22504d 100644 --- a/apps/api/src/application/tokens.ts +++ b/apps/api/src/application/tokens.ts @@ -4,14 +4,20 @@ import type { TokenListResponse, TokenMetadata, } from "@barestash/shared"; +import { parseBearerTokenString } from "@barestash/shared"; import { type AccountId, MVP_ACCOUNT_ID } from "../domain/endpoint.js"; import type { TokenRepository } from "../domain/ports.js"; import type { CreateTokenInput } from "../domain/token.js"; -import { authenticateRequest, hasBootstrapAuth, sha256Hex } from "./auth.js"; +import { + authenticateRequest, + type CredentialPepperDeps, + hasBootstrapAuth, +} from "./auth.js"; +import { hashCredential } from "./credential-hash.js"; import { err, ok, type UseCaseResult } from "./result.js"; -export type CreateTokenDeps = { +export type CreateTokenDeps = CredentialPepperDeps & { tokenRepository: TokenRepository; now: Date; makeTokenId: () => import("@barestash/shared").TokenId; @@ -40,6 +46,7 @@ export async function createToken( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { @@ -55,7 +62,15 @@ export async function createToken( const tokenId = deps.makeTokenId(); const secret = deps.makeTokenSecret(tokenId); - const tokenHash = await sha256Hex(new TextEncoder().encode(secret)); + const parsedSecret = parseBearerTokenString(secret); + + if (parsedSecret === null) { + return err("internal_error", "Failed to create token metadata.", 500); + } + + const tokenHash = await hashCredential(parsedSecret.secret, { + pepper: deps.credentialPepper ?? "", + }); try { const tokenInput: CreateTokenInput = { @@ -86,7 +101,7 @@ export async function createToken( } } -export type ListTokensDeps = { +export type ListTokensDeps = CredentialPepperDeps & { tokenRepository: TokenRepository; now: Date; authorizationHeader: string | null; @@ -100,6 +115,7 @@ export async function listTokens( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { @@ -119,7 +135,7 @@ export async function listTokens( }); } -export type RevokeTokenDeps = { +export type RevokeTokenDeps = CredentialPepperDeps & { tokenRepository: TokenRepository; now: Date; authorizationHeader: string | null; @@ -133,6 +149,7 @@ export async function revokeToken( deps.authorizationHeader, deps.tokenRepository, deps.now, + { pepper: deps.credentialPepper ?? "" }, ); if (authenticated.kind === "error") { diff --git a/apps/api/src/container.ts b/apps/api/src/container.ts index c0f4240..54dbdf1 100644 --- a/apps/api/src/container.ts +++ b/apps/api/src/container.ts @@ -43,6 +43,7 @@ export type Bindings = { BARESTASH_BOOTSTRAP_TOKEN?: string; BARESTASH_BOOTSTRAP_TOKEN_ENABLED?: string; BARESTASH_ENVIRONMENT?: string; + BARESTASH_CREDENTIAL_PEPPER?: string; BARESTASH_API_HOSTNAME?: string; BARESTASH_INGEST_HOSTNAME?: string; ABUSE_IP_RATE_LIMITER?: RateLimit; @@ -259,3 +260,7 @@ export function getTokenRepository( return new D1TokenRepository(context.env.DB); } + +export function getCredentialPepper(context: { env?: Bindings }): string { + return context.env?.BARESTASH_CREDENTIAL_PEPPER ?? ""; +} diff --git a/apps/api/src/domain/ports.ts b/apps/api/src/domain/ports.ts index 16a4090..70f5808 100644 --- a/apps/api/src/domain/ports.ts +++ b/apps/api/src/domain/ports.ts @@ -95,9 +95,11 @@ export type TokenRepository = { accountId: AccountId, options?: { includeRevoked?: boolean }, ) => Promise; - findActiveTokenByHash: ( - tokenHash: string, - ) => Promise<(TokenMetadata & { account_id: AccountId }) | null>; + findActiveTokenById: ( + id: import("@barestash/shared").TokenId, + ) => Promise< + (TokenMetadata & { account_id: AccountId; token_hash: string }) | null + >; updateTokenLastUsed: ( id: import("@barestash/shared").TokenId, lastUsedAt: string, diff --git a/apps/api/src/infrastructure/d1/index.test.ts b/apps/api/src/infrastructure/d1/index.test.ts index 12fcc61..85db93c 100644 --- a/apps/api/src/infrastructure/d1/index.test.ts +++ b/apps/api/src/infrastructure/d1/index.test.ts @@ -517,13 +517,14 @@ describe("D1TokenRepository", () => { ]); }); - it("returns the account ID stored with the active token", async () => { + it("returns the account ID and token hash stored with the active token", async () => { + const tokenId = testTokenId("team"); const db = new (class { prepare() { return { bind: () => ({ first: async () => ({ - id: testTokenId("team"), + id: tokenId, account_id: "acct_team", name: "team token", token_hash: "hash", @@ -538,14 +539,15 @@ describe("D1TokenRepository", () => { })(); const repository = new D1TokenRepository(db as unknown as D1Database); - await expect(repository.findActiveTokenByHash("hash")).resolves.toEqual({ - id: testTokenId("team"), + await expect(repository.findActiveTokenById(tokenId)).resolves.toEqual({ + id: tokenId, account_id: "acct_team", name: "team token", status: "active", created_at: "2026-07-05T12:00:00.000Z", last_used_at: null, revoked_at: null, + token_hash: "hash", }); }); diff --git a/apps/api/src/infrastructure/d1/token-repository.ts b/apps/api/src/infrastructure/d1/token-repository.ts index 07acf48..bfc6b8c 100644 --- a/apps/api/src/infrastructure/d1/token-repository.ts +++ b/apps/api/src/infrastructure/d1/token-repository.ts @@ -97,19 +97,23 @@ export class D1TokenRepository implements TokenRepository { return result.results.map(tokenRowToMetadata); } - async findActiveTokenByHash( - tokenHash: string, - ): Promise<(TokenMetadata & { account_id: AccountId }) | null> { + async findActiveTokenById( + id: TokenId, + ): Promise< + (TokenMetadata & { account_id: AccountId; token_hash: string }) | null + > { const row = await this.#db - .prepare( - "SELECT * FROM tokens WHERE token_hash = ? AND status = 'active'", - ) - .bind(tokenHash) + .prepare("SELECT * FROM tokens WHERE id = ? AND status = 'active'") + .bind(id) .first(); return row === null ? null - : { ...tokenRowToMetadata(row), account_id: row.account_id }; + : { + ...tokenRowToMetadata(row), + account_id: row.account_id, + token_hash: row.token_hash, + }; } async updateTokenLastUsed(id: TokenId, lastUsedAt: string): Promise { diff --git a/apps/api/src/infrastructure/in-memory/index.ts b/apps/api/src/infrastructure/in-memory/index.ts index 122646a..fdbd9e2 100644 --- a/apps/api/src/infrastructure/in-memory/index.ts +++ b/apps/api/src/infrastructure/in-memory/index.ts @@ -441,17 +441,22 @@ export class InMemoryTokenRepository implements TokenRepository { .map(tokenToMetadata); } - async findActiveTokenByHash( - tokenHash: string, - ): Promise<(TokenMetadata & { account_id: AccountId }) | null> { - const token = Array.from(this.#tokens.values()).find( - (candidate) => - candidate.token_hash === tokenHash && candidate.status === "active", - ); + async findActiveTokenById( + id: TokenId, + ): Promise< + (TokenMetadata & { account_id: AccountId; token_hash: string }) | null + > { + const token = this.#tokens.get(id); - return token === undefined - ? null - : { ...tokenToMetadata(token), account_id: token.account_id }; + if (token === undefined || token.status !== "active") { + return null; + } + + return { + ...tokenToMetadata(token), + account_id: token.account_id, + token_hash: token.token_hash, + }; } async updateTokenLastUsed(id: TokenId, lastUsedAt: string): Promise { diff --git a/apps/api/src/presentation/rate-limit.ts b/apps/api/src/presentation/rate-limit.ts index 6d8ab6f..c540a07 100644 --- a/apps/api/src/presentation/rate-limit.ts +++ b/apps/api/src/presentation/rate-limit.ts @@ -7,7 +7,11 @@ import { verifiedRequestActorRateLimitKey, } from "../application/rate-limit.js"; import type { ApiEnv, AppDeps, RateLimitBindingName } from "../container.js"; -import { getRateLimiter, getTokenRepository } from "../container.js"; +import { + getCredentialPepper, + getRateLimiter, + getTokenRepository, +} from "../container.js"; import { respondRateLimitError } from "./response.js"; export async function checkContextRateLimit( @@ -69,6 +73,7 @@ export async function enforceWriteRateLimits( key: await verifiedRequestActorRateLimitKey( context.req.raw, getTokenRepository(context, deps), + getCredentialPepper(context), ), surface: "write", endpointId, diff --git a/apps/api/src/presentation/routes/endpoints.test.ts b/apps/api/src/presentation/routes/endpoints.test.ts index 45f1041..8c1fb6b 100644 --- a/apps/api/src/presentation/routes/endpoints.test.ts +++ b/apps/api/src/presentation/routes/endpoints.test.ts @@ -15,8 +15,10 @@ import { import { fixedNow, makeApp, + makeTestTokenSecret, RecordingEventRepository, RecordingRequestBodyStore, + testTokenId, unusedEndpointEventSlots, } from "../../testing/helpers.js"; @@ -428,8 +430,8 @@ describe("endpoint API routes", () => { requestBodyStore: new RecordingRequestBodyStore(), now: () => fixedNow, generateEndpointId: () => "ep_private", - generateTokenId: () => "tok_owner", - generateTokenSecret: (_tokenId) => "bst_owner_secret", + generateTokenId: () => testTokenId("owner"), + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "owner"), }); const tokenResponse = await app.request( @@ -438,12 +440,14 @@ describe("endpoint API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: "{}", }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -482,8 +486,8 @@ describe("endpoint API routes", () => { requestBodyStore: new RecordingRequestBodyStore(), now: () => new Date("2026-07-12T12:00:00.000Z"), generateEndpointId: () => "ep_private", - generateTokenId: () => "tok_owner", - generateTokenSecret: (_tokenId) => "bst_owner_secret", + generateTokenId: () => testTokenId("owner"), + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "owner"), }); const tokenResponse = await app.request( @@ -492,12 +496,14 @@ describe("endpoint API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: "{}", }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -545,8 +551,8 @@ describe("endpoint API routes", () => { requestBodyStore: new RecordingRequestBodyStore(), now: () => fixedNow, generateEndpointId: () => "ep_private", - generateTokenId: () => "tok_owner", - generateTokenSecret: (_tokenId) => "bst_owner_secret", + generateTokenId: () => testTokenId("owner"), + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "owner"), generateSecretId: (() => { const ids: SecretId[] = ["sec_old", "sec_new"]; return () => ids.shift() ?? "sec_extra"; @@ -563,12 +569,14 @@ describe("endpoint API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: "{}", }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -664,8 +672,8 @@ describe("endpoint API routes", () => { return () => ids.shift() ?? "ep_extra"; })(), generateEventId: () => "evt_delete", - generateTokenId: () => "tok_delete", - generateTokenSecret: (_tokenId) => "bst_delete_secret", + generateTokenId: () => testTokenId("delete"), + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "delete"), }); const tokenResponse = await app.request( @@ -674,12 +682,14 @@ describe("endpoint API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: "{}", }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -775,8 +785,9 @@ describe("endpoint API routes", () => { now: () => fixedNow, generateEndpointId: () => "ep_retry", generateEventId: () => "evt_retry_delete", - generateTokenId: () => "tok_retry_delete", - generateTokenSecret: (_tokenId) => "bst_retry_delete", + generateTokenId: () => testTokenId("retrydelete"), + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "retrydelete"), }); const tokenResponse = await app.request( @@ -785,12 +796,14 @@ describe("endpoint API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: "{}", }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -908,8 +921,9 @@ describe("endpoint API routes", () => { requestBodyStore: bodyStore, now: () => fixedNow, generateEndpointId: () => "ep_many", - generateTokenId: () => "tok_many_delete", - generateTokenSecret: (_tokenId) => "bst_many_delete", + generateTokenId: () => testTokenId("manydelete"), + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "manydelete"), generateEventId: (() => { let next = 0; @@ -923,12 +937,14 @@ describe("endpoint API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: "{}", }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, diff --git a/apps/api/src/presentation/routes/endpoints.ts b/apps/api/src/presentation/routes/endpoints.ts index 185fb5a..0187ad5 100644 --- a/apps/api/src/presentation/routes/endpoints.ts +++ b/apps/api/src/presentation/routes/endpoints.ts @@ -20,6 +20,7 @@ import { import { clientIpRateLimitKey } from "../../application/rate-limit.js"; import type { ApiEnv, AppDeps } from "../../container.js"; import { + getCredentialPepper, getEndpointRepository, getEndpointSecretRepository, getEventRepository, @@ -87,6 +88,7 @@ export function registerEndpointRoutes(app: Hono, deps: AppDeps): void { requestUrl: context.req.url, ingestHostname: context.env?.BARESTASH_INGEST_HOSTNAME, body, + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -104,6 +106,7 @@ export function registerEndpointRoutes(app: Hono, deps: AppDeps): void { authorizationHeader: getAuthorizationHeader(context.req.raw), requestUrl: context.req.url, ingestHostname: context.env?.BARESTASH_INGEST_HOSTNAME, + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -134,6 +137,7 @@ export function registerEndpointRoutes(app: Hono, deps: AppDeps): void { requestUrl: context.req.url, ingestHostname: context.env?.BARESTASH_INGEST_HOSTNAME, endpointId: assertEndpointId(endpointIdParam), + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -177,6 +181,7 @@ export function registerEndpointRoutes(app: Hono, deps: AppDeps): void { requestUrl: context.req.url, ingestHostname: context.env?.BARESTASH_INGEST_HOSTNAME, endpointId: assertEndpointId(endpointIdParam), + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -218,6 +223,7 @@ export function registerEndpointRoutes(app: Hono, deps: AppDeps): void { endpointId: assertEndpointId(endpointIdParam), makeSecretId: deps.makeSecretId, makeEndpointSecret: deps.makeEndpointSecret, + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -247,6 +253,7 @@ export function registerEndpointRoutes(app: Hono, deps: AppDeps): void { now: deps.getNow(), authorizationHeader: getAuthorizationHeader(context.req.raw), endpointId: assertEndpointId(endpointIdParam), + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -298,6 +305,7 @@ export function registerEndpointRoutes(app: Hono, deps: AppDeps): void { authorizationHeader: getAuthorizationHeader(context.req.raw), endpointId: assertEndpointId(endpointIdParam), secretId: assertSecretId(secretIdParam), + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { diff --git a/apps/api/src/presentation/routes/events.ts b/apps/api/src/presentation/routes/events.ts index 3c5e7a4..af2b31a 100644 --- a/apps/api/src/presentation/routes/events.ts +++ b/apps/api/src/presentation/routes/events.ts @@ -15,6 +15,7 @@ import { import { clientIpRateLimitKey } from "../../application/rate-limit.js"; import type { ApiEnv, AppDeps } from "../../container.js"; import { + getCredentialPepper, getEndpointRepository, getEventRepository, getRequestBodyStore, @@ -51,6 +52,7 @@ export function registerEventRoutes(app: Hono, deps: AppDeps): void { afterParam: searchParams.get("after"), beforeParam: searchParams.get("before"), limitParam: searchParams.get("limit"), + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -81,6 +83,7 @@ export function registerEventRoutes(app: Hono, deps: AppDeps): void { now: deps.getNow(), authorizationHeader: getAuthorizationHeader(context.req.raw), eventId: eventIdParam as EventId, + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -111,6 +114,7 @@ export function registerEventRoutes(app: Hono, deps: AppDeps): void { now: deps.getNow(), authorizationHeader: getAuthorizationHeader(context.req.raw), eventId: eventIdParam as EventId, + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -166,6 +170,7 @@ export function registerEventStreamRoutes( authorizationHeader: getAuthorizationHeader(context.req.raw), endpointId: assertEndpointId(endpointIdParam), lastEventIdHeader, + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { diff --git a/apps/api/src/presentation/routes/ingest.test.ts b/apps/api/src/presentation/routes/ingest.test.ts index 67e900d..ed13320 100644 --- a/apps/api/src/presentation/routes/ingest.test.ts +++ b/apps/api/src/presentation/routes/ingest.test.ts @@ -15,10 +15,13 @@ import { import { FailingRequestBodyStore, fixedNow, + hashCredentialForTest, makeTemporaryEndpointRepository, + makeTestTokenSecret, RecordingEventRepository, RecordingRequestBodyStore, sha256Hex, + testTokenId, unusedEndpointEventSlots, } from "../../testing/helpers.js"; @@ -45,7 +48,8 @@ describe("temporary endpoint ingest routes", () => { "user-agent": "Stripe/1.0", authorization: "Bearer raw-token", "stripe-signature": "t=raw,v1=raw", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", "x-barestash-secret": "endpoint-secret", }, body: bodyBytes, @@ -857,8 +861,9 @@ describe("temporary endpoint ingest routes", () => { now: () => fixedNow, generateEndpointId: () => "ep_private", generateEventId: () => nextEventId as EventId, - generateTokenId: () => "tok_secret_owner", - generateTokenSecret: (_tokenId) => "bst_secret_owner", + generateTokenId: () => testTokenId("secretowner"), + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "secretowner"), generateSecretId: () => "sec_ingest", generateEndpointSecret: () => "endpoint-secret", }); @@ -869,12 +874,14 @@ describe("temporary endpoint ingest routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: "{}", }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -1014,9 +1021,7 @@ describe("temporary endpoint ingest routes", () => { { id: "sec_revoked_before_insert", endpoint_id: "ep_private" as EndpointId, - secret_hash: await sha256Hex( - new TextEncoder().encode("endpoint-secret"), - ), + secret_hash: await hashCredentialForTest("endpoint-secret"), status: "active", created_at: "2026-07-05T12:00:00.000Z", last_used_at: null, @@ -1191,9 +1196,7 @@ describe("temporary endpoint ingest routes", () => { { id: "sec_last_used_failed", endpoint_id: "ep_private" as EndpointId, - secret_hash: await sha256Hex( - new TextEncoder().encode("endpoint-secret"), - ), + secret_hash: await hashCredentialForTest("endpoint-secret"), status: "active", created_at: "2026-07-05T12:00:00.000Z", last_used_at: null, diff --git a/apps/api/src/presentation/routes/ingest.ts b/apps/api/src/presentation/routes/ingest.ts index 443f848..ff1d2de 100644 --- a/apps/api/src/presentation/routes/ingest.ts +++ b/apps/api/src/presentation/routes/ingest.ts @@ -9,6 +9,7 @@ import { ingestRequest } from "../../application/ingest.js"; import { clientIpRateLimitKey } from "../../application/rate-limit.js"; import type { ApiEnv, AppDeps } from "../../container.js"; import { + getCredentialPepper, getEndpointRepository, getEndpointSecretRepository, getEventRepository, @@ -63,6 +64,7 @@ export function registerIngestRoutes(app: Hono, deps: AppDeps): void { makeEventId: deps.makeEventId, endpointId: assertEndpointId(endpointIdParam), request: context.req.raw, + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { diff --git a/apps/api/src/presentation/routes/mcp.test.ts b/apps/api/src/presentation/routes/mcp.test.ts index c6238fe..318c2db 100644 --- a/apps/api/src/presentation/routes/mcp.test.ts +++ b/apps/api/src/presentation/routes/mcp.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { createApiApp, InMemoryEndpointRepository } from "../../index.js"; import { fixedNow, + makeTestTokenSecret, RecordingEventRepository, RecordingRequestBodyStore, testTokenId, @@ -66,12 +67,13 @@ async function createApiToken( method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "mcp-agent" }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -118,7 +120,8 @@ describe("MCP API route", () => { allowInMemoryStorage: true, now: () => fixedNow, generateTokenId: () => TOK_MCP_REVOKED, - generateTokenSecret: (_tokenId) => "bst_mcp_revoked_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "mcprevoked"), }); const token = await createApiToken(app); const authorization = `Bearer ${token.secret}`; @@ -165,7 +168,7 @@ describe("MCP API route", () => { const app = createApiApp({ allowInMemoryStorage: true, generateTokenId: () => TOK_MCP_LIST, - generateTokenSecret: (_tokenId) => "bst_mcp_list_secret", + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "mcplist"), }); const token = await createApiToken(app); @@ -209,7 +212,7 @@ describe("MCP API route", () => { generateEndpointId: () => "ep_mcp_tmp", generateEventId: () => "evt_mcp_tmp", generateTokenId: () => TOK_MCP_TMP, - generateTokenSecret: (_tokenId) => "bst_mcp_tmp_secret", + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "mcptmp"), }); const token = await createApiToken(app); const authorization = `Bearer ${token.secret}`; @@ -336,7 +339,8 @@ describe("MCP API route", () => { return `evt_private_${eventSequence}` as EventId; }, generateTokenId: () => TOK_MCP_PRIVATE, - generateTokenSecret: (_tokenId) => "bst_mcp_private_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "mcpprivate"), generateSecretId: () => "sec_mcp_private", generateEndpointSecret: () => "endpoint-secret-raw", }); @@ -414,7 +418,7 @@ describe("MCP API route", () => { generateEndpointId: () => "ep_json", generateEventId: () => "evt_json", generateTokenId: () => TOK_MCP_JSON, - generateTokenSecret: (_tokenId) => "bst_mcp_json_secret", + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "mcpjson"), }); const token = await createApiToken(app); const authorization = `Bearer ${token.secret}`; @@ -458,7 +462,8 @@ describe("MCP API route", () => { const app = createApiApp({ allowInMemoryStorage: true, generateTokenId: () => TOK_MCP_STREAM, - generateTokenSecret: (_tokenId) => "bst_mcp_stream_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "mcpstream"), }); const token = await createApiToken(app); const response = await app.request("https://api.example.com/mcp", { diff --git a/apps/api/src/presentation/routes/mcp.ts b/apps/api/src/presentation/routes/mcp.ts index ce55e06..e500cfd 100644 --- a/apps/api/src/presentation/routes/mcp.ts +++ b/apps/api/src/presentation/routes/mcp.ts @@ -27,6 +27,7 @@ import { clientIpRateLimitKey } from "../../application/rate-limit.js"; import type { UseCaseError, UseCaseResult } from "../../application/result.js"; import type { ApiEnv, AppDeps } from "../../container.js"; import { + getCredentialPepper, getEndpointRepository, getEventRepository, getRequestBodyStore, @@ -139,6 +140,7 @@ function createMcpServer( version: "0.0.0", }); const authorizationHeader = getAuthorizationHeader(request); + const credentialPepper = getCredentialPepper(context); server.registerTool( "list_endpoints", @@ -157,6 +159,7 @@ function createMcpServer( authorizationHeader, requestUrl: request.url, ingestHostname: context.env?.BARESTASH_INGEST_HOSTNAME, + credentialPepper, }), ), ); @@ -196,6 +199,7 @@ function createMcpServer( requestUrl: request.url, ingestHostname: context.env?.BARESTASH_INGEST_HOSTNAME, body, + credentialPepper, }), ); }, @@ -231,6 +235,7 @@ function createMcpServer( afterParam: after ?? null, beforeParam: before ?? null, limitParam: limit === undefined ? null : String(limit), + credentialPepper, }), ); }, @@ -261,6 +266,7 @@ function createMcpServer( now: deps.getNow(), authorizationHeader, eventId, + credentialPepper, }), ); }, @@ -291,6 +297,7 @@ function createMcpServer( now: deps.getNow(), authorizationHeader, eventId, + credentialPepper, }); if (result.kind === "error") { @@ -326,6 +333,7 @@ export function registerMcpRoutes(app: Hono, deps: AppDeps): void { const authentication = await verifyRequestAuthentication( getAuthorizationHeader(context.req.raw), tokenRepository, + { pepper: getCredentialPepper(context) }, ); if (authentication.kind === "error") { diff --git a/apps/api/src/presentation/routes/rate-limit.test.ts b/apps/api/src/presentation/routes/rate-limit.test.ts index 55a644c..1a85cd1 100644 --- a/apps/api/src/presentation/routes/rate-limit.test.ts +++ b/apps/api/src/presentation/routes/rate-limit.test.ts @@ -7,6 +7,7 @@ import { InMemoryTokenRepository } from "../../infrastructure/in-memory/index.js import { fixedNow, makeTemporaryEndpointRepository, + makeTestTokenSecret, RecordingEventRepository, RecordingRequestBodyStore, testTokenId, @@ -83,12 +84,13 @@ async function createToken(app: ReturnType) { headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.10", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "rate-limit-test" }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -170,11 +172,13 @@ describe("API rate limit boundaries", () => { method: "POST", headers: { "cf-connecting-ip": "203.0.113.13", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -192,7 +196,8 @@ describe("API rate limit boundaries", () => { tokenRepository, now: () => fixedNow, generateTokenId: () => TOK_MCP_LIMITED, - generateTokenSecret: (_tokenId) => "bst_mcp_limited_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "mcplimited"), rateLimiters: { ABUSE_IP_RATE_LIMITER: allow(), MCP_RATE_LIMITER: limiter, @@ -244,7 +249,8 @@ describe("API rate limit boundaries", () => { allowInMemoryStorage: true, now: () => fixedNow, generateTokenId: () => TOK_MCP_CREATE_LIMITED, - generateTokenSecret: (_tokenId) => "bst_mcp_create_limited_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "mcpcreatelimited"), rateLimiters: { ABUSE_IP_RATE_LIMITER: allow(), MCP_RATE_LIMITER: allow(), @@ -295,7 +301,8 @@ describe("API rate limit boundaries", () => { endpointRepository: repository, now: () => fixedNow, generateTokenId: () => TOK_MCP_CREATE_UNAVAILABLE, - generateTokenSecret: (_tokenId) => "bst_mcp_create_unavailable_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "mcpcreateunavailable"), rateLimiters: { ABUSE_IP_RATE_LIMITER: allow(), MCP_RATE_LIMITER: allow(), @@ -337,7 +344,8 @@ describe("API rate limit boundaries", () => { now: () => fixedNow, generateEndpointId: () => "ep_write_limited", generateTokenId: () => TOK_WRITE_LIMITED, - generateTokenSecret: (_tokenId) => "bst_write_limited_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "writelimited"), rateLimiters: { WRITE_RATE_LIMITER: limiter }, }); const token = await createToken(app); diff --git a/apps/api/src/presentation/routes/tokens.test.ts b/apps/api/src/presentation/routes/tokens.test.ts index d24e74f..815c46c 100644 --- a/apps/api/src/presentation/routes/tokens.test.ts +++ b/apps/api/src/presentation/routes/tokens.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createApiApp, InMemoryEndpointRepository } from "../../index.js"; import { fixedNow, + makeTestTokenSecret, RacingBootstrapTokenRepository, RecordingEventRepository, RecordingRequestBodyStore, @@ -25,7 +26,8 @@ describe("token API routes", () => { requestBodyStore: new RecordingRequestBodyStore(), now: () => fixedNow, generateTokenId: () => TOK_DISABLED, - generateTokenSecret: (_tokenId) => "bst_disabled_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "disabled"), }); const response = await app.request( @@ -34,12 +36,14 @@ describe("token API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "must-not-exist" }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "production", }, @@ -66,7 +70,8 @@ describe("token API routes", () => { requestBodyStore: new RecordingRequestBodyStore(), now: () => fixedNow, generateTokenId: () => TOK_UNKNOWN_ENVIRONMENT, - generateTokenSecret: (_tokenId) => "bst_unknown_environment_secret", + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, "unknownenvironment"), }); const response = await app.request( @@ -75,12 +80,14 @@ describe("token API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "must-not-exist" }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: environment, }, @@ -99,7 +106,7 @@ describe("token API routes", () => { generateEndpointId: () => "ep_private", generateEventId: () => "evt_private", generateTokenId: () => TOK_PRIVATE, - generateTokenSecret: (_tokenId) => "bst_private_secret", + generateTokenSecret: (tokenId) => makeTestTokenSecret(tokenId, "private"), }); const tokenResponse = await app.request( @@ -108,14 +115,16 @@ describe("token API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "local-agent", }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -132,7 +141,7 @@ describe("token API routes", () => { last_used_at: null, revoked_at: null, }, - secret: "bst_private_secret", + secret: makeTestTokenSecret(TOK_PRIVATE, "private"), }); const tokenListResponse = await app.request( @@ -164,14 +173,16 @@ describe("token API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "should-not-work", }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -341,9 +352,10 @@ describe("token API routes", () => { return () => ids[index++] ?? TOK_EXTRA; })(), generateTokenSecret: (() => { - const secrets = ["bst_first_secret", "bst_second_secret"] as const; + const seeds = ["first", "second"] as const; let index = 0; - return () => secrets[index++] ?? "bst_extra_secret"; + return (tokenId: import("@barestash/shared").TokenId) => + makeTestTokenSecret(tokenId, seeds[index++] ?? "extra"); })(), }); @@ -353,12 +365,14 @@ describe("token API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "first" }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, @@ -464,7 +478,8 @@ describe("token API routes", () => { tokenSequence += 1; return testTokenId(`race${tokenSequence}`); }, - generateTokenSecret: (_tokenId) => `bst_race_${tokenSequence}`, + generateTokenSecret: (tokenId) => + makeTestTokenSecret(tokenId, `race${tokenSequence}`), }); const createRequest = () => app.request( @@ -473,14 +488,16 @@ describe("token API routes", () => { method: "POST", headers: { "content-type": "application/json", - "x-barestash-bootstrap-token": "bootstrap-secret", + "x-barestash-bootstrap-token": + "bootstrap-secret-for-local-staging-tests-ok", }, body: JSON.stringify({ name: "bootstrap", }), }, { - BARESTASH_BOOTSTRAP_TOKEN: "bootstrap-secret", + BARESTASH_BOOTSTRAP_TOKEN: + "bootstrap-secret-for-local-staging-tests-ok", BARESTASH_BOOTSTRAP_TOKEN_ENABLED: "true", BARESTASH_ENVIRONMENT: "staging", }, diff --git a/apps/api/src/presentation/routes/tokens.ts b/apps/api/src/presentation/routes/tokens.ts index 195871b..0889ba3 100644 --- a/apps/api/src/presentation/routes/tokens.ts +++ b/apps/api/src/presentation/routes/tokens.ts @@ -16,7 +16,7 @@ import { revokeToken, } from "../../application/tokens.js"; import type { ApiEnv, AppDeps } from "../../container.js"; -import { getTokenRepository } from "../../container.js"; +import { getCredentialPepper, getTokenRepository } from "../../container.js"; import { enforceHttpRateLimit, enforceWriteRateLimits } from "../rate-limit.js"; import { getAuthorizationHeader, @@ -68,6 +68,7 @@ export function registerTokenRoutes(app: Hono, deps: AppDeps): void { context.env?.BARESTASH_ENVIRONMENT === "staging"), bootstrapTokenHeader: getBootstrapTokenHeader(context.req.raw), configuredBootstrapToken: context.env?.BARESTASH_BOOTSTRAP_TOKEN, + credentialPepper: getCredentialPepper(context), body, }); @@ -85,6 +86,7 @@ export function registerTokenRoutes(app: Hono, deps: AppDeps): void { authorizationHeader: getAuthorizationHeader(context.req.raw), includeRevoked: new URL(context.req.url).searchParams.get("all") === "true", + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { @@ -118,6 +120,7 @@ export function registerTokenRoutes(app: Hono, deps: AppDeps): void { now: deps.getNow(), authorizationHeader: getAuthorizationHeader(context.req.raw), tokenId: assertStoredTokenId(tokenIdParam), + credentialPepper: getCredentialPepper(context), }); if (result.kind === "error") { diff --git a/apps/api/src/testing/helpers.ts b/apps/api/src/testing/helpers.ts index c2e9787..c07dcb9 100644 --- a/apps/api/src/testing/helpers.ts +++ b/apps/api/src/testing/helpers.ts @@ -3,7 +3,12 @@ import type { EventId, TokenCreateResponse, } from "@barestash/shared"; -import { testTokenId } from "@barestash/shared"; +import { + formatPatBearerTokenString, + type TokenId, + testTokenId, +} from "@barestash/shared"; +import { hashCredential } from "../application/credential-hash.js"; import { isEndpointExpired } from "../domain/endpoint.js"; import type { CreateTokenInput } from "../domain/token.js"; import { @@ -18,6 +23,19 @@ import { export { testTokenId }; +function testSecretFromSeed(seed: string): string { + const alphanumeric = seed.replace(/[^A-Za-z0-9]/g, ""); + + return (alphanumeric + "0".repeat(32)).slice(0, 32); +} + +export function makeTestTokenSecret(tokenId: TokenId, seed: string): string { + return formatPatBearerTokenString(tokenId, testSecretFromSeed(seed)); +} + +export const TEST_BOOTSTRAP_TOKEN = + "bootstrap-secret-for-local-staging-tests-ok"; + export const fixedNow = new Date("2026-07-05T12:00:00.000Z"); export const makeApp = () => @@ -122,7 +140,7 @@ export class RacingBootstrapTokenRepository implements TokenRepository { return []; } - async findActiveTokenByHash(): Promise { + async findActiveTokenById(): Promise { return null; } @@ -330,6 +348,9 @@ export const makeTemporaryEndpointRepository = ( } satisfies EndpointRepository; }; +export const hashCredentialForTest = async (secret: string): Promise => + hashCredential(secret, { pepper: "" }); + export const sha256Hex = async (bytes: Uint8Array): Promise => { const digest = await crypto.subtle.digest("SHA-256", bytes); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index ae847ff..d249ff2 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "migrations/**/*.test.ts"], }, }); diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml index 21d5bdd..db115ad 100644 --- a/apps/api/wrangler.toml +++ b/apps/api/wrangler.toml @@ -5,6 +5,7 @@ compatibility_date = "2026-07-06" [vars] BARESTASH_BOOTSTRAP_TOKEN_ENABLED = "false" BARESTASH_ENVIRONMENT = "default" +BARESTASH_CREDENTIAL_PEPPER = "local-dev-credential-pepper-not-for-production" [triggers] crons = ["0 * * * *"] diff --git a/biome.json b/biome.json index fe783e7..68d880e 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,14 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "includes": ["**", "!!**/dist", "!!**/coverage", "!!infra/cloudflare/build"] + "includes": [ + "**", + "!!**/dist", + "!!**/coverage", + "!!**/.direnv", + "!!**/.wrangler", + "!!infra/cloudflare/build" + ] }, "assist": { "enabled": true, diff --git a/docs/api-deployment.md b/docs/api-deployment.md index 3077eee..befb768 100644 --- a/docs/api-deployment.md +++ b/docs/api-deployment.md @@ -74,19 +74,46 @@ deployment credentials. ## Secrets -Supply Worker secrets only through the sensitive `worker_secrets` variable. For -example, staging may temporarily use the bootstrap token: +Supply Worker secrets only through the sensitive `worker_secrets` variable. +Generate a credential pepper once per environment, store it in the +organization's secret manager, and load the same value before every plan and +apply. A 32-byte random value encoded as hexadecimal is sufficient: + +```bash +openssl rand -hex 32 +``` + +Do not run that command again for an existing environment. Load the saved value +into `BARESTASH_CREDENTIAL_PEPPER`, then construct the Worker secret map. For +example, staging may temporarily include the bootstrap token: ```bash export TF_VAR_worker_secrets="$(jq -cn \ + --arg pepper "$BARESTASH_CREDENTIAL_PEPPER" \ --arg token "$BARESTASH_BOOTSTRAP_TOKEN" \ - '{BARESTASH_BOOTSTRAP_TOKEN: $token}')" + '{ + BARESTASH_CREDENTIAL_PEPPER: $pepper, + BARESTASH_BOOTSTRAP_TOKEN: $token + }')" +``` + +Production must contain the pepper without the bootstrap token: + +```bash +export TF_VAR_worker_secrets="$(jq -cn \ + --arg pepper "$BARESTASH_CREDENTIAL_PEPPER" \ + '{BARESTASH_CREDENTIAL_PEPPER: $pepper}')" ``` Production rejects `BARESTASH_BOOTSTRAP_TOKEN` at plan time. Do not pass an empty or placeholder secret. Secret removal from the map removes the binding on the next approved apply. +The Worker supports one active credential pepper. Changing it makes every +HMAC-hashed PAT and endpoint ingest secret unverifiable. Online pepper rotation +is not currently supported; treat any pepper change as a coordinated, +destructive credential reset rather than a routine secret rotation. + ## Plan And Apply Initialize and create a saved, reviewable plan: @@ -138,6 +165,26 @@ Migration files are append-only after they have reached any remote environment. Never edit or renumber an applied file. Breaking schema changes require a separately reviewed expand/migrate/contract release sequence. +Migration `0002_invalidate_non_hmac_credentials.sql` is an intentional +pre-release breaking transition. It deletes token rows and non-active +endpoint-secret rows that are not stored as canonical +`hmac-sha256$<64 lowercase hex>` values, including legacy bare SHA-256 hashes +and intermediate PBKDF2 hashes. It preserves endpoints, events, raw request +objects, and already-canonical HMAC credentials. + +Active endpoint secrets with an unsupported hash remain as invalidated guard +rows. They cannot authenticate ingest, but their presence keeps the private +endpoint fail-closed instead of allowing secretless requests. Create a new +endpoint secret, update the webhook sender, verify delivery with the new secret, +and only then revoke the invalidated secret row. + +If the migration removes every token in staging, use the staging-only bootstrap +flow to create a new initial PAT, then follow the endpoint-secret replacement +sequence above. Production bootstrap remains prohibited. This release assumes +there is no existing production deployment containing legacy credentials; do +not apply the migration to such an environment without a separately reviewed +recovery procedure. + On the first apply, OpenTofu creates a stable bootstrap Worker version without a Durable Object binding, deploys it through Wrangler to apply the `v1` SQLite namespace migration, and only then creates the application version with the @@ -177,5 +224,6 @@ just cloudflare-check ``` This runs a Wrangler dry-run build plus `tofu fmt -check`, provider -initialization without a backend, and `tofu validate`. It does not contact the -target Cloudflare account or inspect remote drift; only `tofu plan` does that. +initialization without a backend, `tofu validate`, and the offline OpenTofu +tests. It does not contact the target Cloudflare account or inspect remote +drift; only `tofu plan` does that. diff --git a/infra/cloudflare/main.tf b/infra/cloudflare/main.tf index 6ab3d7b..acbeec8 100644 --- a/infra/cloudflare/main.tf +++ b/infra/cloudflare/main.tf @@ -156,6 +156,15 @@ resource "cloudflare_worker" "api" { error_message = "BARESTASH_BOOTSTRAP_TOKEN must not be configured in production." } + precondition { + condition = length(trimspace(lookup( + nonsensitive(var.worker_secrets), + "BARESTASH_CREDENTIAL_PEPPER", + "", + ))) > 0 + error_message = "BARESTASH_CREDENTIAL_PEPPER must be configured as a non-empty Worker secret." + } + precondition { condition = length(setintersection( toset(keys(nonsensitive(var.worker_secrets))), diff --git a/infra/cloudflare/tests/worker_secrets.tftest.hcl b/infra/cloudflare/tests/worker_secrets.tftest.hcl new file mode 100644 index 0000000..d0641ca --- /dev/null +++ b/infra/cloudflare/tests/worker_secrets.tftest.hcl @@ -0,0 +1,49 @@ +mock_provider "cloudflare" {} + +mock_provider "local" {} + +variables { + environment = "staging" + account_id = "0123456789abcdef0123456789abcdef" + zone_id = "fedcba9876543210fedcba9876543210" + zone_name = "example.com" + api_hostname = "api.example.com" + ingest_hostname = "ingest.example.com" + worker_name = "barestash-staging" + d1_database_name = "barestash_staging" + r2_bucket_name = "barestash-staging" +} + +run "accepts_non_empty_credential_pepper" { + command = plan + + variables { + worker_secrets = { + BARESTASH_CREDENTIAL_PEPPER = "test-credential-pepper" + } + } +} + +run "rejects_empty_credential_pepper" { + command = plan + + variables { + worker_secrets = { + BARESTASH_CREDENTIAL_PEPPER = "" + } + } + + expect_failures = [cloudflare_worker.api] +} + +run "rejects_whitespace_credential_pepper" { + command = plan + + variables { + worker_secrets = { + BARESTASH_CREDENTIAL_PEPPER = " " + } + } + + expect_failures = [cloudflare_worker.api] +} diff --git a/just/cloudflare.just b/just/cloudflare.just index c6784df..eaec097 100644 --- a/just/cloudflare.just +++ b/just/cloudflare.just @@ -44,6 +44,7 @@ cloudflare-check: cloudflare-build tofu -chdir=infra/cloudflare fmt -check -recursive tofu -chdir=infra/cloudflare init -backend=false tofu -chdir=infra/cloudflare validate + tofu -chdir=infra/cloudflare test [arg('env', long='env', pattern='staging|production', help='Target environment')] [group('ops')] diff --git a/tests/smoke/helpers.ts b/tests/smoke/helpers.ts index c6b44b2..325c792 100644 --- a/tests/smoke/helpers.ts +++ b/tests/smoke/helpers.ts @@ -13,7 +13,7 @@ export const REPO_ROOT = join( "../..", ); export const API_DIR = join(REPO_ROOT, "apps/api"); -export const SMOKE_BOOTSTRAP_TOKEN = "smoke-e2e-bootstrap"; +export const SMOKE_BOOTSTRAP_TOKEN = "smoke-e2e-bootstrap-token-for-tests-ok"; export async function getFreePort(): Promise { return new Promise((resolve, reject) => {