diff --git a/backend/.env.example b/backend/.env.example index e19613cd1..3432cc374 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,13 @@ # Backend Server Configuration # Copy this to .env and customize for your environment +# ── Tenant API Key Authentication ───────────────────────────────────────────── +# Per-tenant API keys are stored hashed in the `apiKey` table with scopes, +# expiry, and last-used tracking. Rotate keys per tenant without downtime. +# API_KEY below is a deprecated global fallback; it only applies when no +# tenant-scoped key matches and logs a deprecation warning on each use. +# API_KEY= + # Server Configuration PORT=3000 NODE_ENV=development @@ -10,6 +17,9 @@ NODE_ENV=development RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX_REQUESTS=100 +# Deprecated: single global API key fallback (see Tenant API Key Authentication) +# API_KEY= + # API endpoint rate limiter: 30 requests per minute (60000 ms) API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_MAX_REQUESTS=30 @@ -29,6 +39,16 @@ API_RATE_LIMIT_MAX_REQUESTS=30 #PAYLOAD_LIMIT_ADMIN=16kb #PAYLOAD_LIMIT_WRITES=32kb +# ── Tenant API Key Scopes ───────────────────────────────────────────────────── +# Comma-separated list of scopes required per route tier. Requests must present +# an x-api-key whose tenant record includes the required scope, otherwise the +# middleware returns 401 SCOPE_INSUFFICIENT with a WWW-Authenticate header. +# API_KEY_SCOPES_READ=read +# API_KEY_SCOPES_WRITE=write +# API_KEY_SCOPES_ADMIN=admin +# API_KEY_EXPIRED_ERROR=API_KEY_EXPIRED +# API_KEY_SCOPE_ERROR=SCOPE_INSUFFICIENT + # ── Wallet signed-action nonces ─────────────────────────────────────────────── # WALLET_NONCE_TTL_SECONDS=300 # WALLET_NONCE_MAX_ACTIVE_PER_WALLET=10 diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 20e8c2e41..17c6d7cc4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -138,6 +138,23 @@ model AdminAuditLog { @@index([actor]) } +model ApiKey { + id String @id @default(uuid()) + tenantId String + hashedKey String @unique + scopes String // JSON-serialised string[] + expiresAt DateTime? + lastUsedAt DateTime? + revoked Boolean @default(false) + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId]) + @@index([hashedKey]) + @@index([expiresAt]) +} + model ApiKeyAuditEvent { id String @id @default(uuid()) actor String diff --git a/backend/scripts/canary-migration-check.ts b/backend/scripts/canary-migration-check.ts index a2cbdaae9..f0b429f08 100644 --- a/backend/scripts/canary-migration-check.ts +++ b/backend/scripts/canary-migration-check.ts @@ -10,6 +10,8 @@ * # or from check:migrations:canary npm script * * Annotation opt-outs (add as a SQL comment in the migration file): + * -- migration-safety: allow-api-key-auth + * -- migration-safety: allow-tenant-scoped-auth * -- migration-safety: allow-not-null-add * -- migration-safety: allow-nonconcurrent-indexes * -- migration-safety: allow-drop @@ -208,6 +210,64 @@ const rules: Array<{ }, ]; +// ── API key / tenant scoping rules (Issue: cross-tenant replay) ────────────── + +const apiKeyRules: Array<{ + id: string; + severity: 'error' | 'warning'; + annotation?: string; + description: string; + check: (content: string, lower: string) => Array<{ message: string; index: number }>; +}> = [ + { + id: 'api-key-tenant-scope', + severity: 'error', + annotation: 'allow-api-key-auth', + description: + 'apiKey table must be scoped per tenantId with hashedKey, scopes, expiresAt, lastUsedAt ' + + 'to prevent cross-tenant replay and enable per-tenant rotation.', + check: (_, lower) => { + const matches: Array<{ message: string; index: number }> = []; + const re = /\bcreate\s+table\b[^;]{0,200}\bapi[_]?key\b/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(lower)) !== null) { + const statementEnd = lower.indexOf(';', m.index); + const windowEnd = + statementEnd === -1 ? m.index + 800 : Math.min(statementEnd, m.index + 800); + const slice = lower.slice(m.index, windowEnd); + const required = ['tenant_id', 'hashed_key', 'scopes', 'expires_at', 'last_used_at']; + const missing = required.filter((col) => !slice.includes(col)); + if (missing.length > 0) { + matches.push({ + message: `apiKey table missing tenant-scoped columns: ${missing.join(', ')} — cross-tenant replay risk`, + index: m.index, + }); + } + } + return matches; + }, + }, + { + id: 'no-global-api-key-env', + severity: 'warning', + annotation: 'allow-tenant-scoped-auth', + description: + 'Single global API_KEY env var allows cross-tenant replay; migrate to per-tenant keys with fallback deprecation log.', + check: (_, lower) => { + const matches: Array<{ message: string; index: number }> = []; + const re = /\bapi_key\b\s*=\s*['"`]/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(lower)) !== null) { + matches.push({ + message: `Hard-coded global API_KEY detected — use per-tenant hashedKey with fallback env var + deprecation log`, + index: m.index, + }); + } + return matches; + }, + }, +]; + // ── Main checker ───────────────────────────────────────────────────────────── /** @@ -236,6 +296,22 @@ export function checkMigrationFile(filePath: string): MigrationCheckResult { const lower = content.toLowerCase(); const issues: MigrationIssue[] = []; + for (const rule of apiKeyRules) { + if (rule.annotation && hasAnnotation(content, rule.annotation)) { + continue; + } + + const findings = rule.check(content, lower); + for (const finding of findings) { + issues.push({ + severity: rule.severity, + rule: rule.id, + message: finding.message, + line: findLineNumber(content, finding.index), + }); + } + } + for (const rule of rules) { // Check annotation opt-out if (rule.annotation && hasAnnotation(content, rule.annotation)) { diff --git a/backend/scripts/check-migrations.js b/backend/scripts/check-migrations.js index a087d9b75..05280a551 100644 --- a/backend/scripts/check-migrations.js +++ b/backend/scripts/check-migrations.js @@ -13,6 +13,54 @@ const searchRoots = [ path.join(repoRoot, 'contracts', 'mock-strategy', 'migrations'), ]; +// ─── API key per-tenant migration safety rules ─────────────────────────────── +// +// The apiKey table stores per-tenant credentials with hashedKey, scopes, +// expiresAt and lastUsedAt. Migrations that touch this table must not +// introduce a NOT NULL column without a DEFAULT (breaks the legacy single-key +// fallback during canary rollout) and must not drop the legacy API_KEY env +// fallback column in the same migration that adds the new one. +const API_KEY_TABLE_PATTERN = /\bapi_?key\b/i; +const API_KEY_REQUIRED_COLUMNS = ['hashedkey', 'scopes', 'expiresat', 'lastusedat']; + +function checkApiKeyMigration(content, file) { + const results = []; + const lowered = content.toLowerCase(); + + if (!API_KEY_TABLE_PATTERN.test(lowered)) { + return results; + } + + // The migration must declare the per-tenant credential columns. + const missing = API_KEY_REQUIRED_COLUMNS.filter((col) => !lowered.includes(col)); + if (missing.length > 0) { + results.push({ + file, + severity: 'error', + message: + `apiKey migration is missing required column(s): ${missing.join(', ')}. ` + + 'Per-tenant keys need hashedKey, scopes[], expiresAt and lastUsedAt for rotation and audit.', + }); + } + + // Dropping the legacy fallback in the same migration removes the env-var + // fallback path and breaks existing single-key deployments. + const dropsLegacyFallback = + /\bdrop\s+column\b[^;]{0,120}\b(api_?key|legacy_?key|env_?key)\b/i.test(content); + if (dropsLegacyFallback) { + results.push({ + file, + severity: 'error', + message: + 'Dropping the legacy API_KEY fallback column in the same migration that adds per-tenant keys ' + + 'breaks existing single-key deployments. Keep the fallback until all tenants have migrated.', + }); + } + + return results; +} + + const files = searchRoots.flatMap((root) => findMigrationFiles(root)); if (files.length === 0) { @@ -148,6 +196,9 @@ function checkFile(file) { } } + // ── 5b. API key per-tenant migration safety ─────────────────────────────── + results.push(...checkApiKeyMigration(content, file)); + // ── 6. Schema change references indexed columns but declares no index ────── const addsIndexedColumns = /(_id|status|created_at|updated_at|tenant_id)/i.test(content); const hasIndex = /\bindex\b|\bcreate\s+index\b/i.test(content); diff --git a/backend/src/middleware/apiKeyAuth.ts b/backend/src/middleware/apiKeyAuth.ts index 5d4d00ad2..cdb3d5cd9 100644 --- a/backend/src/middleware/apiKeyAuth.ts +++ b/backend/src/middleware/apiKeyAuth.ts @@ -1,235 +1 @@ -import type { Request, Response, NextFunction } from 'express'; -import crypto from 'crypto'; -import { prisma } from '../prisma'; -import { sendApiError } from './apiError'; - -export type ApiKeyRole = 'viewer' | 'operator' | 'admin' | 'super-admin'; - -interface ApiKeyMetadata { - role: ApiKeyRole; - createdAt: Date; - rotatedAt?: Date; - revokedAt?: Date; - tenantId: string; - scopes: string[]; -} - -declare global { - namespace Express { - interface Request { - authApiKeyHash?: string; - authApiKeyRole?: ApiKeyRole; - authApiKeyTenantId?: string; - authApiKeyScopes?: string[]; - } - } -} - -const ROLE_PRECEDENCE: Record = { - viewer: 0, - operator: 1, - admin: 2, - 'super-admin': 3, -}; - -export function normalizeApiKeyRole(raw: unknown): ApiKeyRole | null { - if (typeof raw !== 'string') { - return null; - } - const value = raw.trim().toLowerCase(); - if (value === 'viewer' || value === 'operator' || value === 'admin' || value === 'super-admin') { - return value as ApiKeyRole; - } - return null; -} - -function parseScopes(raw: unknown): string[] { - if (Array.isArray(raw)) return raw as string[]; - if (typeof raw === 'string') { - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } - } - return []; -} - -export interface PersistedApiKeyStatus { - isActive: boolean; - deletedAt?: Date | null; - tenant?: { deletedAt?: Date | null }; -} - -export function isPersistedApiKeyUsable( - apiKey: PersistedApiKeyStatus | null -): apiKey is PersistedApiKeyStatus { - return Boolean(apiKey && apiKey.isActive && !apiKey.deletedAt && !apiKey.tenant?.deletedAt); -} - -export async function validateApiKey( - req: Request, - res: Response, - next: NextFunction -): Promise { - const authHeader = req.get?.('Authorization') || ''; - const match = authHeader.match(/^ApiKey\s+(.+)$/i); - if (!match) { - sendApiError(req, res, { - status: 401, - code: 'AUTH_API_KEY_MISSING', - message: 'Missing or invalid API key', - retryable: false, - }); - return; - } - const providedKey = match[1]; - const hashed = crypto.createHash('sha256').update(providedKey).digest('hex'); - - // Try Prisma first; fall back to in-memory store when the table doesn't exist - // (e.g. in tests with an un-migrated SQLite database). - let apiKey: { - hashedKey: string; - role: string; - tenantId: string; - scopes: unknown; - isActive: boolean; - deletedAt?: Date | null; - tenant?: { deletedAt?: Date | null }; - } | null = null; - try { - apiKey = await prisma.apiKey.findUnique({ - where: { hashedKey: hashed }, - include: { tenant: { select: { deletedAt: true } } }, - }); - } catch (err) { - // Only fall back when the ApiKey table hasn't been created (tests / fresh DB). - const isMissingTable = - err instanceof Error && - (err.message.includes('does not exist in the current database') || - (err as { code?: string }).code === 'P2021'); - if (!isMissingTable) throw err; - } - - if (apiKey) { - if (isPersistedApiKeyUsable(apiKey)) { - req.authApiKeyHash = apiKey.hashedKey; - req.authApiKeyRole = apiKey.role as ApiKeyRole; - req.authApiKeyTenantId = apiKey.tenantId; - req.authApiKeyScopes = parseScopes(apiKey.scopes); - next(); - return; - } - - // A persisted-but-disabled credential must fail closed. Never let it fall - // through to the legacy in-memory store where an old duplicate could live. - sendApiError(req, res, { - status: 401, - code: 'AUTH_API_KEY_INVALID', - message: 'Invalid API key', - retryable: false, - }); - return; - } - - // Fallback: check the in-memory store (used by tests and legacy callers) - const meta = IN_MEMORY_KEYS.get(hashed); - if (!meta || meta.revokedAt) { - sendApiError(req, res, { - status: 401, - code: 'AUTH_API_KEY_INVALID', - message: 'Invalid API key', - retryable: false, - }); - return; - } - req.authApiKeyHash = hashed; - req.authApiKeyRole = meta.role; - req.authApiKeyTenantId = meta.tenantId; - req.authApiKeyScopes = meta.scopes; - next(); -} - -export function hashApiKey(key: string): string { - return crypto.createHash('sha256').update(key).digest('hex'); -} - -// Legacy helpers retained for compatibility – they operate on in‑memory map only. -const IN_MEMORY_KEYS = new Map(); - -export function registerApiKey( - key: string, - options?: { role?: ApiKeyRole; tenantId?: string; scopes?: string[] } -): string { - const hash = hashApiKey(key); - IN_MEMORY_KEYS.set(hash, { - role: options?.role || 'admin', - createdAt: new Date(), - tenantId: options?.tenantId || 'unknown', - scopes: options?.scopes || [], - }); - return hash; -} - -export function revokeApiKey(hash: string): boolean { - const meta = IN_MEMORY_KEYS.get(hash); - if (meta) { - meta.revokedAt = new Date(); - IN_MEMORY_KEYS.set(hash, meta); - } - return IN_MEMORY_KEYS.delete(hash); -} - -export function rotateApiKey( - oldHash: string, - newKey: string, - options: { role?: ApiKeyRole; tenantId?: string; scopes?: string[] } = {} -): string | null { - const meta = IN_MEMORY_KEYS.get(oldHash); - if (!meta) return null; - IN_MEMORY_KEYS.delete(oldHash); - const newHash = hashApiKey(newKey); - IN_MEMORY_KEYS.set(newHash, { - role: options.role || meta.role, - createdAt: meta.createdAt, - rotatedAt: new Date(), - tenantId: options.tenantId || meta.tenantId, - scopes: options.scopes || meta.scopes, - }); - return newHash; -} - -export function restoreApiKey(hash: string): boolean { - const meta = IN_MEMORY_KEYS.get(hash); - if (!meta) return false; - meta.revokedAt = undefined; - IN_MEMORY_KEYS.set(hash, meta); - return true; -} - -export function getApiKeyMetadata(hash: string) { - const meta = IN_MEMORY_KEYS.get(hash); - if (!meta) return null; - return { - hash, - role: meta.role, - createdAt: meta.createdAt.toISOString(), - ...(meta.rotatedAt ? { rotatedAt: meta.rotatedAt.toISOString() } : {}), - ...(meta.revokedAt ? { revokedAt: meta.revokedAt.toISOString() } : {}), - tenantId: meta.tenantId, - scopes: meta.scopes, - }; -} - -export function authenticateApiKeyValue(value: string) { - const hash = hashApiKey(value); - const meta = IN_MEMORY_KEYS.get(hash); - if (!meta || meta.revokedAt) return null; - return { hash, role: meta.role }; -} - -export function hasRequiredApiKeyRole(req: Request, requiredRole: ApiKeyRole): boolean { - const currentRole = req.authApiKeyRole || 'admin'; - return ROLE_PRECEDENCE[currentRole] >= ROLE_PRECEDENCE[requiredRole]; -} +aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBjcnlwdG8gZnJvbSAnY3J5cHRvJzsKaW1wb3J0IHsgcHJpc21hIH0gZnJvbSAnLi4vcHJpc21hJzsKaW1wb3J0IHsgc2VuZEFwaUVycm9yIH0gZnJvbSAnLi9hcGlFcnJvcic7CgpleHBvcnQgdHlwZSBBcGlLZXlSb2xlID0gJ3ZpZXdlcicgfCAnb3BlcmF0b3InIHwgJ2FkbWluJyB8ICdzdXBlci1hZG1pbic7CgppbnRlcmZhY2UgQXBpS2V5TWV0YWRhdGEgewogIHJvbGU6IEFwaUtleVJvbGU7CiAgY3JlYXRlZEF0OiBEYXRlOwogIHJvdGF0ZWRBdD86IERhdGU7CiAgcmV2b2tlZEF0PzogRGF0ZTsKICB0ZW5hbnRJZDogc3RyaW5nOwogIHNjb3Blczogc3RyaW5nW107CiAgZXhwaXJlc0F0PzogRGF0ZSB8IG51bGw7CiAgbGFzdFVzZWRBdD86IERhdGUgfCBudWxsOwp9CgpkZWNsYXJlIGdsb2JhbCB7CiAgbmFtZXNwYWNlIEV4cHJlc3MgewogICAgaW50ZXJmYWNlIFJlcXVlc3QgewogICAgICBhdXRoQXBpS2V5SGFzaD86IHN0cmluZzsKICAgICAgYXV0aEFwaUtleVJvbGU/OiBBcGlLZXlSb2xlOwogICAgICBhdXRoQXBpS2V5VGVuYW50SWQ/OiBzdHJpbmc7CiAgICAgIGF1dGhBcGlLZXlTY29wZXM/OiBzdHJpbmdbXTsKICAgICAgYXV0aEFwaUtleUxhc3RVc2VkQXQ/OiBEYXRlIHwgbnVsbDsKICAgICAgYXV0aEFwaUtleUxlZ2FjeT86IGJvb2xlYW47CiAgICB9CiAgfQp9Cgpjb25zdCBST0xFX1BSRUNFREVOQ0U6IFJlY29yZDxBcGlLZXlSb2xlLCBudW1iZXI+ID0gewogIHZpZXdlcjogMCwKICBvcGVyYXRvcjogMSwKICBhZG1pbjogMiwKICAnc3VwZXItYWRtaW4nOiAzLAp9OwoKZXhwb3J0IGZ1bmN0aW9uIG5vcm1hbGl6ZUFwaUtleVJvbGUocmF3OiB1bmtub3duKTogQXBpS2V5Um9sZSB8IG51bGwgewogIGlmICh0eXBlb2YgcmF3ICE9PSAnc3RyaW5nJykgewogICAgcmV0dXJuIG51bGw7CiAgfQogIGNvbnN0IHZhbHVlID0gcmF3LnRyaW0oKS50b0xvd2VyQ2FzZSgpOwogIGlmICh2YWx1ZSA9PT0gJ3ZpZXdlcicgfHwgdmFsdWUgPT09ICdvcGVyYXRvcicgfHwgdmFsdWUgPT09ICdhZG1pbicgfHwgdmFsdWUgPT09ICdzdXBlci1hZG1pbicpIHsKICAgIHJldHVybiB2YWx1ZSBhcyBBcGlLZXlSb2xlOwogIH0KICByZXR1cm4gbnVsbDsKfQoKZnVuY3Rpb24gcGFyc2VTY29wZXMocmF3OiB1bmtub3duKTogc3RyaW5nW10gewogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHJldHVybiByYXcgYXMgc3RyaW5nW107CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnKSB7CiAgICB0cnkgewogICAgICBjb25zdCBwYXJzZWQgPSBKU09OLnBhcnNlKHJhdyk7CiAgICAgIHJldHVybiBBcnJheS5pc0FycmF5KHBhcnNlZCkgPyBwYXJzZWQgOiBbXTsKICAgIH0gY2F0Y2ggewogICAgICByZXR1cm4gW107CiAgICB9CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGludGVyZmFjZSBQZXJzaXN0ZWRBcGlLZXlTdGF0dXMgewogIGlzQWN0aXZlOiBib29sZWFuOwogIGRlbGV0ZWRBdD86IERhdGUgfCBudWxsOwogIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogIHRlbmFudD86IHsgZGVsZXRlZEF0PzogRGF0ZSB8IG51bGwgfTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGlzUGVyc2lzdGVkQXBpS2V5VXNhYmxlKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBhcGlLZXkgaXMgUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmlzQWN0aXZlICYmICFhcGlLZXkuZGVsZXRlZEF0ICYmICFhcGlLZXkudGVuYW50Py5kZWxldGVkQXQpOwp9CgpleHBvcnQgZnVuY3Rpb24gaXNQZXJzaXN0ZWRBcGlLZXlFeHBpcmVkKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBib29sZWFuIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmV4cGlyZXNBdCAmJiBhcGlLZXkuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcTogUmVxdWVzdCk6IHN0cmluZyB8IG51bGwgewogIGNvbnN0IGZyb21QYXJhbXMgPSByZXEucGFyYW1zPy50ZW5hbnRJZDsKICBpZiAodHlwZW9mIGZyb21QYXJhbXMgPT09ICdzdHJpbmcnICYmIGZyb21QYXJhbXMudHJpbSgpKSB7CiAgICByZXR1cm4gZnJvbVBhcmFtcy50cmltKCk7CiAgfQogIGNvbnN0IGZyb21RdWVyeSA9IHJlcS5xdWVyeT8udGVuYW50SWQ7CiAgaWYgKHR5cGVvZiBmcm9tUXVlcnkgPT09ICdzdHJpbmcnICYmIGZyb21RdWVyeS50cmltKCkpIHsKICAgIHJldHVybiBmcm9tUXVlcnkudHJpbSgpOwogIH0KICBjb25zdCBmcm9tSGVhZGVyID0gcmVxLmdldD8uKCd4LXRlbmFudC1pZCcpOwogIGlmICh0eXBlb2YgZnJvbUhlYWRlciA9PT0gJ3N0cmluZycgJiYgZnJvbUhlYWRlci50cmltKCkpIHsKICAgIHJldHVybiBmcm9tSGVhZGVyLnRyaW0oKTsKICB9CiAgcmV0dXJuIG51bGw7Cn0KCmV4cG9ydCBmdW5jdGlvbiBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxOiBSZXF1ZXN0KTogc3RyaW5nW10gewogIGNvbnN0IHJhdyA9IChyZXEgYXMgUmVxdWVzdCAmIHsgcmVxdWlyZWRTY29wZXM/OiB1bmtub3duIH0pLnJlcXVpcmVkU2NvcGVzOwogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHsKICAgIHJldHVybiByYXcuZmlsdGVyKChzKTogcyBpcyBzdHJpbmcgPT4gdHlwZW9mIHMgPT09ICdzdHJpbmcnKTsKICB9CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnICYmIHJhdy50cmltKCkpIHsKICAgIHJldHVybiByYXcuc3BsaXQoJywnKS5tYXAoKHMpID0+IHMudHJpbSgpKS5maWx0ZXIoQm9vbGVhbik7CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGhhc1JlcXVpcmVkU2NvcGVzKGF2YWlsYWJsZTogc3RyaW5nW10sIHJlcXVpcmVkOiBzdHJpbmdbXSk6IGJvb2xlYW4gewogIGlmIChyZXF1aXJlZC5sZW5ndGggPT09IDApIHJldHVybiB0cnVlOwogIGlmIChhdmFpbGFibGUuaW5jbHVkZXMoJyonKSkgcmV0dXJuIHRydWU7CiAgcmV0dXJuIHJlcXVpcmVkLmV2ZXJ5KChzY29wZSkgPT4gYXZhaWxhYmxlLmluY2x1ZGVzKHNjb3BlKSk7Cn0KCmZ1bmN0aW9uIHNlbmRVbmF1dGhvcml6ZWQoCiAgcmVxOiBSZXF1ZXN0LAogIHJlczogUmVzcG9uc2UsCiAgY29kZTogc3RyaW5nLAogIG1lc3NhZ2U6IHN0cmluZywKICBzY2hlbWU6IHN0cmluZyA9ICdCZWFyZXInCik6IHZvaWQgewogIHJlcy5zZXRIZWFkZXIoJ1dXVy1BdXRoZW50aWNhdGUnLCBzY2hlbWUpOwogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlLAogICAgbWVzc2FnZSwKICAgIHJldHJhYmxlOiBmYWxzZSwKICB9KTsKfQoKY29uc3QgTEVHQUNZX0VOVl9LRVkgPSBwcm9jZXNzLmVudi5BUElfS0VZOwpjb25zdCBMRUdBQ1lfRU5WX1RFTkFOVCA9IHByb2Nlc3MuZW52LkFQSV9LRVlfVEVOQU5UX0lEIHx8ICdkZWZhdWx0JzsKY29uc3QgTEVHQUNZX0VOVl9TQ09QRVMgPSAocHJvY2Vzcy5lbnYuQVBJX0tFWV9TQ09QRVMgfHwgJyonKQogIC5zcGxpdCgnLCcpCiAgLm1hcCgocykgPT4gcy50cmltKCkpCiAgLmZpbHRlcihCb29sZWFuKTsKCmZ1bmN0aW9uIGNoZWNrTGVnYWN5RW52S2V5KHByb3ZpZGVkS2V5OiBzdHJpbmcpOiBib29sZWFuIHsKICBpZiAoIUxFR0FDWV9FTlZfS0VZKSByZXR1cm4gZmFsc2U7CiAgY29uc3QgYSA9IEJ1ZmZlci5mcm9tKHByb3ZpZGVkS2V5KTsKICBjb25zdCBiID0gQnVmZmVyLmZyb20oTEVHQUNZX0VOVl9LRVkpOwogIGlmIChhLmxlbmd0aCAhPT0gYi5sZW5ndGgpIHJldHVybiBmYWxzZTsKICByZXR1cm4gY3J5cHRvLnRpbWluZ1NhZmVFcXVhbChhLCBiKTsKfQoKZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHZhbGlkYXRlQXBpS2V5KAogIHJlcTogUmVxdWVzdCwKICByZXM6IFJlc3BvbnNlLAogIG5leHQ6IE5leHRGdW5jdGlvbgopOiBQcm9taXNlPHZvaWQ+IHsKICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmdldD8uKCdBdXRob3JpemF0aW9uJykgfHwgJyc7CiAgY29uc3QgbWF0Y2ggPSBhdXRoSGVhZGVyLm1hdGNoKC9eQXBpS2V5XHMrKC4rKSQvaSk7CiAgaWYgKCFtYXRjaCkgewogICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgIHN0YXR1czogNDAxLAogICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX01JU1NJTkcnLAogICAgICBtZXNzYWdlOiAnTWlzc2luZyBvciBpbnZhbGlkIEFQSSBrZXknLAogICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgfSk7CiAgICByZXR1cm47CiAgfQogIGNvbnN0IHByb3ZpZGVkS2V5ID0gbWF0Y2hbMV07CiAgY29uc3QgaGFzaGVkID0gY3J5cHRvLmNyZWF0ZUhhc2goJ3NoYTI1NicpLnVwZGF0ZShwcm92aWRlZEtleSkuZGlnZXN0KCdoZXgnKTsKICBjb25zdCByZXF1ZXN0ZWRUZW5hbnRJZCA9IGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcSk7CiAgY29uc3QgcmVxdWlyZWRTY29wZXMgPSBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxKTsKCiAgLy8gVHJ5IFByaXNtYSBmaXJzdDsgZmFsbCBiYWNrIHRvIGluLW1lbW9yeSBzdG9yZSB3aGVuIHRoZSB0YWJsZSBkb2Vzbid0IGV4aXN0CiAgLy8gKGUuZy4gaW4gdGVzdHMgd2l0aCBhbiB1bi1taWdyYXRlZCBTUUxpdGUgZGF0YWJhc2UpLgogIGxldCBhcGlLZXk6IHsKICAgIGlkPzogc3RyaW5nOwogICAgaGFzaGVkS2V5OiBzdHJpbmc7CiAgICByb2xlOiBzdHJpbmc7CiAgICB0ZW5hbnRJZDogc3RyaW5nOwogICAgc2NvcGVzOiB1bmtub3duOwogICAgaXNBY3RpdmU6IGJvb2xlYW47CiAgICBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbDsKICAgIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogICAgdGVuYW50PzogeyBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbCB9OwogIH0gfCBudWxsID0gbnVsbDsKICB0cnkgewogICAgYXBpS2V5ID0gYXdhaXQgcHJpc21hLmFwaUtleS5maW5kVW5pcXVlKHsKICAgICAgd2hlcmU6IHsgaGFzaGVkS2V5OiBoYXNoZWQgfSwKICAgICAgaW5jbHVkZTogeyB0ZW5hbnQ6IHsgc2VsZWN0OiB7IGRlbGV0ZWRBdDogdHJ1ZSB9IH0gfSwKICAgIH0pOwogIH0gY2F0Y2ggKGVycikgewogICAgLy8gT25seSBmYWxsIGJhY2sgd2hlbiB0aGUgQXBpS2V5IHRhYmxlIGhhc24ndCBiZWVuIGNyZWF0ZWQgKHRlc3RzIC8gZnJlc2ggREIpLgogICAgY29uc3QgaXNN aXNzaW5nVGFibGUgPQogICAgICBlcnIgaW5zdGFuY2VvZiBFcnJvciAmJgogICAgICAoZXJyLm1lc3NhZ2UuaW5jbHVkZXMoJ2RvZXMgbm90IGV4aXN0IGluIHRoZSBjdXJyZW50IGRhdGFiYXNlJykgfHwKICAgICAgICAoZXJyIGFzIHsgY29kZT86IHN0cmluZyB9KS5jb2RlID09PSAnUDIwMjEnKTsKICAgIGlmICghaXNN aXNzaW5nVGFibGUpIHRocm93IGVycjsKICB9CgogIGlmIChhcGlLZXkpIHsKICAgIGlmICghaXNQZXJzaXN0ZWRBcGlLZXlVc2FibGUoYXBpS2V5KSkgewogICAgICAvLyBBIHBlcnNpc3RlZC1idXQtZGlzYWJsZWQgY3JlZGVudGlhbCBtdXN0IGZhaWwgY2xvc2VkLiBOZXZlciBsZXQgaXQgZmFsbAogICAgICAvLyB0aHJvdWdoIHRvIHRoZSBsZWdhY3kgaW4tbWVtb3J5IHN0b3JlIHdoZXJlIGFuIG9sZCBkdXBsaWNhdGUgY291bGQgbGl2ZS4KICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCBBUEkga2V5JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGlmIChpc1BlcnNpc3RlZEFwaUtleUV4cGlyZWQoYXBpS2V5KSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKHJlcSwgcmVzLCAnQVBJX0tFWV9FWFBJUkVEJywgJ0FQSSBrZXkgaGFzIGV4cGlyZWQnKTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIC8vIFRlbmFudCBzY29waW5nOiBpZiB0aGUgcmVxdWVzdCB0YXJnZXRzIGEgdGVuYW50LCB0aGUga2V5IG11c3QgYmVsb25nIHRvIGl0LgogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBhcGlLZXkudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGNvbnN0IGF2YWlsYWJsZVNjb3BlcyA9IHBhcnNlU2NvcGVzKGFwaUtleS5zY29wZXMpOwogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhhdmFpbGFibGVTY29wZXMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CgogICAgcmVxLmF1dGhBcGlLZXlIYXNoID0gYXBpS2V5Lmhhc2hlZEtleTsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IGFwaUtleS5yb2xlIGFzIEFwaUtleVJvbGU7CiAgICByZXEuYXV0aEFwaUtleVRlbmFudElkID0gYXBpS2V5LnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBhdmFpbGFibGVTY29wZXM7CiAgICByZXEuYXV0aEFwaUtleUxlZ2FjeSA9IGZhbHNlOwoKICAgIC8vIEJlc3QtZWZmb3J0IGxhc3QtdXNlZCB0cmFja2luZyBmb3IgYXVkaXQuIE5ldmVyIGZhaWwgYXV0aCBiZWNhdXNlIG9mIGl0LgogICAgdHJ5IHsKICAgICAgY29uc3Qgbm93ID0gbmV3IERhdGUoKTsKICAgICAgYXdhaXQgcHJpc21hLmFwaUtleS51cGRhdGUoewogICAgICAgIHdoZXJlOiB7IGhhc2hlZEtleTogYXBpS2V5Lmhhc2hlZEtleSB9LAogICAgICAgIGRhdGE6IHsgbGFzdFVzZWRBdDogbm93IH0sCiAgICAgIH0pOwogICAgICByZXEuYXV0aEFwaUtleUxhc3RVc2VkQXQgPSBub3c7CiAgICB9IGNhdGNoIHsKICAgICAgLy8gaWdub3JlIGxhc3QtdXNlZCB1cGRhdGUgZmFpbHVyZXMKICAgIH0KCiAgICBuZXh0KCk7CiAgICByZXR1cm47CiAgfQoKICAvLyBGYWxsYmFjazogY2hlY2sgdGhlIGluLW1lbW9yeSBzdG9yZSAodXNlZCBieSB0ZXN0cyBhbmQgbGVnYWN5IGNhbGxlcnMpCiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoZWQpOwogIGlmIChtZXRhICYmICFtZXRhLnJldm9rZWRBdCkgewogICAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQocmVxLCByZXMsICdBUElfS0VZX0VYUElSRUQnLCAnQVBJIGtleSBoYXMgZXhwaXJlZCcpOwogICAgICByZXR1cm47CiAgICB9CiAgICBpZiAocmVxdWVzdGVkVGVuYW50SWQgJiYgcmVxdWVzdGVkVGVuYW50SWQgIT09IG1ldGEudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhtZXRhLnNjb3BlcywgcmVxdWlyZWRTY29wZXMpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQoCiAgICAgICAgcmVxLAogICAgICAgIHJlcywKICAgICAgICAnU0NPUEVfSU5TVUZGSUNJRU5UJywKICAgICAgICAnQVBJIGtleSBtaXNzZXMgcmVxdWlyZWQgc2NvcGUocyknLAogICAgICAgICdCZWFyZXIgZXJyb3I9Imluc3VmZmljaWVudF9zY29wZSInCiAgICAgICk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIHJlcS5hdXRoQXBpS2V5SGFzaCA9IGhhc2hlZDsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IG1ldGEucm9sZTsKICAgIHJlcS5hdXRoQXBpS2V5VGVuYW50SWQgPSBtZXRhLnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBtZXRhLnNjb3BlczsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gZmFsc2U7CiAgICBtZXRhLmxhc3RVc2VkQXQgPSBuZXcgRGF0ZSgpOwogICAgcmVxLmF1dGhBcGlLZXlMYXN0VXNlZEF0ID0gbWV0YS5sYXN0VXNlZEF0OwogICAgbmV4dCgpOwogICAgcmV0dXJuOwogIH0KCiAgLy8gTGVnYWN5IGZhbGxiYWNrOiBzaW5nbGUgZ2xvYmFsIGVudiB2YXIgQVBJX0tFWS4gRGVwcmVjYXRlZC4KICBpZiAoY2hlY2tMZWdhY3lFbnZLZXkocHJvdmlkZWRLZXkpKSB7CiAgICBjb25zb2xlLndhcm4oCiAgICAgICdbZGVwcmVjYXRpb25dIEFQSV9LRVkgZW52IHZhciBmYWxsYmFjayBpcyBkZXByZWNhdGVkLiBNaWdyYXRlIHRvIHBlci10ZW5hbnQgQVBJIGtleXMgaW4gdGhlIGFwaUtleSB0YWJsZS4nCiAgICApOwogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBMRUdBQ1lfRU5WX1RFTkFOVCkgewogICAgICBzZW5kQXBpRXJyb3IocmVxLCByZXMsIHsKICAgICAgICBzdGF0dXM6IDQwMSwKICAgICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgICAgIG1lc3NhZ2U6ICdBUElrZXkgaXMgbm90IHZhbGlkIGZvciB0aGlzIHRlbmFudCcsCiAgICAgICAgcmV0cnlhYmxlOiBmYWxzZSwKICAgICAgfSk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIGlmICghaGFzUmVxdWlyZWRTY29wZXMoTEVHQUNZX0VOVl9TQ09QRVMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CiAgICByZXEuYXV0aEFwaUtleUhhc2ggPSBoYXNoZWQ7CiAgICByZXEuYXV0aEFwaUtleVJvbGUgPSAnYWRtaW4nOwogICAgcmVxLmF1dGhBcGlLZXlUZW5hbnRJZCA9IExFR0FDWV9FTlZfVEVOQU5UOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBMRUdBQ1lfRU5WX1NDT1BFUzsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gdHJ1ZTsKICAgIG5leHQoKTsKICAgIHJldHVybjsKICB9CgogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgbWVzc2FnZTogJ0ludmFsaWQgQVBJIGtleScsCiAgICByZXRyeWFibGU6IGZhbHNlLAogIH0pOwp9CgpleHBvcnQgZnVuY3Rpb24gaGFzaEFwaUtleShrZXk6IHN0cmluZyk6IHN0cmluZyB7CiAgcmV0dXJuIGNyeXB0by5jcmVhdGVIYXNoKCdzaGEyNTYnKS51cGRhdGUoa2V5KS5kaWdlc3QoJ2hleCcpOwp9CgovLyBMZWdhY3kgaGVscGVycyByZXRhaW5lZCBmb3IgY29tcGF0aWJpbGl0eSDigJMgb3BlcmF0ZSBvbiBpbi1tZW1vcnkgbWFwIG9ubHkuCmNvbnN0IElOX01FTU9SWV9LRVlTID0gbmV3IE1hcDxzdHJpbmcsIEFwaUtleU1ldGFkYXRhPigpOwoKZXhwb3J0IGZ1bmN0aW9uIHJlZ2lzdGVyQXBpS2V5KAogIGtleTogc3RyaW5nLAogIG9wdGlvbnM/OiB7IHJvbGU/OiBBcGlLZXlSb2xlOyB0ZW5hbnRJZD86IHN0cmluZzsgc2NvcGVzPzogc3RyaW5nW107IGV4cGlyZXNBdD86IERhdGUgfCBudWxsIH0KKTogc3RyaW5nIHsKICBjb25zdCBoYXNoID0gaGFzaEFwaUtleShrZXkpOwogIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCB7CiAgICByb2xlOiBvcHRpb25zPy5yb2xlIHx8ICdhZG1pbicsCiAgICBjcmVhdGVkQXQ6IG5ldyBEYXRlKCksCiAgICB0ZW5hbnRJZDogb3B0aW9ucz8udGVuYW50SWQgfHwgJ3Vua25vd24nLAogICAgc2NvcGVzOiBvcHRpb25zPy5zY29wZXMgfHwgW10sCiAgICBleHBpcmVzQXQ6IG9wdGlvbnM/LmV4cGlyZXNBdCA/PyBudWxsLAogIH0pOwogIHJldHVybiBoYXNoOwp9CgpleHBvcnQgZnVuY3Rpb24gcmV2b2tlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKG1ldGEpIHsKICAgIG1ldGEucmV2b2tlZEF0ID0gbmV3IERhdGUoKTsKICAgIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCBtZXRhKTsKICB9CiAgcmV0dXJuIElOX01FTU9SWV9LRVlTLmRlbGV0ZShoYXNoKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIHJvdGF0ZUFwaUtleSgKICBvbGRIYXNoOiBzdHJpbmcsCiAgbmV3S2V5OiBzdHJpbmcsCiAgb3B0aW9uczogeyByb2xlPzogQXBpS2V5Um9sZTsgdGVuYW50SWQ/OiBzdHJpbmc7IHNjb3Blcz86IHN0cmluZ1tdIH0gPSB7fQopOiBzdHJpbmcgfCBudWxsIHsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KG9sZEhhc2gpOwogIGlmICghbWV0YSkgcmV0dXJuIG51bGw7CiAgSU5fTUVNT1JZX0tFWVMuZGVsZXRlKG9sZEhhc2gpOwogIGNvbnN0IG5ld0hhc2ggPSBoYXNoQXBpS2V5KG5ld0tleSk7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KG5ld0hhc2gsIHsKICAgIHJvbGU6IG9wdGlvbnMucm9sZSB8fCBtZXRhLnJvbGUsCiAgICBjcmVhdGVkQXQ6IG1ldGEuY3JlYXRlZEF0LAogICAgcm90YXRlZEF0OiBuZXcgRGF0ZSgpLAogICAgdGVuYW50SWQ6IG9wdGlvbnMudGVuYW50SWQgfHwgbWV0YS50ZW5hbnRJZCwKICAgIHNjb3Blczogb3B0aW9ucy5zY29wZXMgfHwgbWV0YS5zY29wZXMsCiAgICBleHBpcmVzQXQ6IG1ldGEuZXhwaXJlc0F0ID8/IG51bGwsCiAgfSk7CiAgcmV0dXJuIG5ld0hhc2g7Cn0KCmV4cG9ydCBmdW5jdGlvbiByZXN0b3JlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKCFtZXRhKSByZXR1cm4gZmFsc2U7CiAgbWV0YS5yZXZva2VkQXQgPSB1bmRlZmluZWQ7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KGhhc2gsIG1ldGEpOwogIHJldHVybiB0cnVlOwp9CgpleHBvcnQgZnVuY3Rpb24gZ2V0QXBpS2V5TWV0YWRhdGEoaGFzaDogc3RyaW5nKSB7CiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoKTsKICBpZiAoIW1ldGEpIHJldHVybiBudWxsOwogIHJldHVybiB7CiAgICBoYXNoLAogICAgcm9sZTogbWV0YS5yb2xlLAogICAgY3JlYXRlZEF0OiBtZXRhLmNyZWF0ZWRBdC50b0lTT1N0cmluZygpLAogICAgLi4uKG1ldGEucm90YXRlZEF0ID8geyByb3RhdGVkQXQ6IG1ldGEucm90YXRlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICAgIC4uLihtZXRhLnJldm9rZWRBdCA/IHsgcmV2b2tlZEF0OiBtZXRhLnJldm9rZWRBdC50b0lTT1N0cmluZygpIH0gOiB7fSksCiAgICB0ZW5hbnRJZDogbWV0YS50ZW5hbnRJZCwKICAgIHNjb3BlczogbWV0YS5zY29wZXMsCiAgICAuLi4obWV0YS5leHBpcmVzQXQgPyB7IGV4cGlyZXNBdDogbWV0YS5leHBpcmVzQXQudG9JU09TdHJpbmcoKSB9IDoge30pLAogICAgLi4uKG1ldGEubGFzdFVzZWRBdCA/IHsgbGFzdFVzZWRBdDogbWV0YS5sYXN0VXNlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICB9Owp9CgpleHBvcnQgZnVuY3Rpb24gYXV0aGVudGljYXRlQXBpS2V5VmFsdWUodmFsdWU6IHN0cmluZykgewogIGNvbnN0IGhhc2ggPSBoYXNoQXBpS2V5KHZhbHVlKTsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KGhhc2gpOwogIGlmICghbWV0YSB8fCBtZXRhLnJldm9rZWRBdCkgcmV0dXJuIG51bGw7CiAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSByZXR1cm4gbnVsbDsKICByZXR1cm4geyBoYXNoLCByb2xlOiBtZXRhLnJvbGUsIHRlbmFudElkOiBtZXRhLnRlbmFudElkLCBzY29wZXM6IG1ldGEuc2NvcGVzIH07Cn0KCmV4cG9ydCBmdW5jdGlvbiBoYXNSZXF1aXJlZEFwaUtleVJvbGUocmVxOiBSZXF1ZXN0LCByZXF1aXJlZFJvbGU6IEFwaUtleVJvbGUpOiBib29sZWFuIHsKICBjb25zdCBjdXJyZW50Um9sZSA9IHJlcS5hdXRoQXBpS2V5Um9sZSB8fCAnYWRtaW4nOwogIHJldHVybiBST0xFX1BSRUNFREVOQ0VbY3VycmVudFJvbGVdID49IFJPTEVfUFJFQ0VERU5DRVtyZXF1aXJlZFJvbGVdOwp9Cg== \ No newline at end of file diff --git a/backend/src/middleware/tenantBoundary.ts b/backend/src/middleware/tenantBoundary.ts index ffe20a51d..4ba5fab85 100644 --- a/backend/src/middleware/tenantBoundary.ts +++ b/backend/src/middleware/tenantBoundary.ts @@ -10,6 +10,8 @@ * ✓ Validate ownership or tenant scope on every sensitive action * ✓ Return authorization errors with clear messaging * ✓ Document expected access patterns for operators + * ✓ Enforce per-tenant API key scopes and expiry (API_KEY_EXPIRED / SCOPE_INSUFFICIENT) + * ✓ Support legacy global API_KEY fallback with deprecation logging */ import type { Request, Response, NextFunction } from 'express'; @@ -24,6 +26,10 @@ declare global { tenantId?: string; walletAddress?: string; tenantScopes?: Set; + authApiKeyTenantId?: string; + authApiKeyScopes?: string[]; + authApiKeyRole?: string; + authApiKeyHash?: string; } } } @@ -50,6 +56,26 @@ export class MissingTenantContext extends Error { } } +export class ApiKeyExpiredError extends Error { + constructor(public readonly tenantId: string) { + super(`API key for tenant ${tenantId} has expired`); + this.name = 'ApiKeyExpiredError'; + } +} + +export class ScopeInsufficientError extends Error { + constructor( + public readonly tenantId: string, + public readonly requiredScope: string, + public readonly grantedScopes: string[] + ) { + super( + `Tenant ${tenantId} lacks required scope ${requiredScope}; granted: ${grantedScopes.join(',')}` + ); + this.name = 'ScopeInsufficientError'; + } +} + // ─── Tenant Scope Definition ───────────────────────────────────────────────── export interface TenantScope { @@ -67,6 +93,8 @@ export const TENANT_SCOPES = { READ_AUDIT: 'read:audit', } as const; +export type TenantScopeName = (typeof TENANT_SCOPES)[keyof typeof TENANT_SCOPES]; + // ─── Core Middleware ──────────────────────────────────────────────────────── /** @@ -120,6 +148,13 @@ export function validateTenantOwnership( resourceName: string ): (req: Request, res: Response, next: NextFunction) => void { return (req: Request, res: Response, next: NextFunction): void => { + // Enforce per-tenant API key scope/expiry before any boundary check. + // Throws ApiKeyExpiredError / ScopeInsufficientError which are mapped to + // 401 responses with WWW-Authenticate by protectTenantRoute. + if (req.authApiKeyTenantId) { + assertApiKeyUsable(req, resourceName); + } + // Admin/super-admin bypass with logging for audit trail if (req.authApiKeyRole === 'admin' || req.authApiKeyRole === 'super-admin') { logger.log('info', 'Admin access with tenant bypass', { @@ -188,6 +223,78 @@ export async function validateWalletInTenant( return true; } +/** + * Validates that the authenticated API key is not expired and that its + * scopes cover the required scope for the given resource. + * + * The required scope is derived from the resource name via a small mapping + * so callers do not need to pass it explicitly. Unknown resources default + * to READ_TENANT_DATA which is the least-privileged read scope. + */ +export function requiredScopeForResource(resourceName: string): TenantScopeName { + const normalized = resourceName.toLowerCase(); + if (normalized.includes('audit')) return TENANT_SCOPES.READ_AUDIT; + if (normalized.startsWith('delete') || normalized.includes('delete')) { + return TENANT_SCOPES.DELETE_TENANT_DATA; + } + if (normalized.includes('deposit') || normalized.includes('withdraw')) { + return TENANT_SCOPES.WRITE_TENANT_DATA; + } + if (normalized.includes('write') || normalized.includes('create') || normalized.includes('update')) { + return TENANT_SCOPES.WRITE_TENANT_DATA; + } + return TENANT_SCOPES.READ_TENANT_DATA; +} + +/** + * Asserts that the API key attached to the request is usable for the + * requested resource. Throws typed errors consumed by protectTenantRoute. + */ +export function assertApiKeyUsable(req: Request, resourceName: string): void { + const tenantId = req.authApiKeyTenantId; + if (!tenantId) return; + + // Expiry check: apiKeyAuth middleware sets authApiKeyExpiresAt when the + // key record has an expiresAt column populated. + const expiresAt = (req as Request & { authApiKeyExpiresAt?: Date | string | null }) + .authApiKeyExpiresAt; + if (expiresAt) { + const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt); + if (!Number.isNaN(expiry.getTime()) && expiry.getTime() <= Date.now()) { + logger.log('warn', 'API key expired', { + action: 'api_key_expired', + actor: req.authApiKeyHash, + tenantId, + resource: resourceName, + expiresAt: expiry.toISOString(), + }); + throw new ApiKeyExpiredError(tenantId); + } + } + + const requiredScope = requiredScopeForResource(resourceName); + const granted = req.authApiKeyScopes || []; + + // Legacy fallback: keys minted from the deprecated global API_KEY env var + // have no scopes recorded. Treat them as having all tenant scopes so the + // fallback path keeps working while operators migrate. + if (granted.length === 0 && req.authApiKeyRole === 'legacy') { + return; + } + + if (!granted.includes(requiredScope)) { + logger.log('warn', 'API key scope insufficient', { + action: 'api_key_scope_insufficient', + actor: req.authApiKeyHash, + tenantId, + resource: resourceName, + requiredScope, + grantedScopes: granted, + }); + throw new ScopeInsufficientError(tenantId, requiredScope, granted); + } +} + /** * Validates that a transaction/resource belongs to the authenticated tenant. */ @@ -292,6 +399,30 @@ export function protectTenantRoute(resourceName: string, paramName = 'tenantId') return; } + if (error instanceof ApiKeyExpiredError) { + res.setHeader('WWW-Authenticate', 'ApiKey realm="tenant", error="invalid_token", error_description="API key expired"'); + res.status(401).json({ + error: 'Unauthorized', + message: 'API key has expired', + code: 'API_KEY_EXPIRED', + }); + return; + } + + if (error instanceof ScopeInsufficientError) { + res.setHeader( + 'WWW-Authenticate', + `ApiKey realm="tenant", error="insufficient_scope", scope="${error.requiredScope}"` + ); + res.status(401).json({ + error: 'Unauthorized', + message: `API key missing required scope: ${error.requiredScope}`, + code: 'SCOPE_INSUFFICIENT', + requiredScope: error.requiredScope, + }); + return; + } + next(error); } }; diff --git a/backend/src/services/apiKeyService.ts b/backend/src/services/apiKeyService.ts index 38e2d06c2..dfa77badf 100644 --- a/backend/src/services/apiKeyService.ts +++ b/backend/src/services/apiKeyService.ts @@ -15,6 +15,7 @@ export interface ApiKeyRecord { scopes: string[]; createdAt: Date; expiresAt?: Date | null; + lastUsedAt?: Date | null; isActive: boolean; deletedAt?: Date | null; deletedBy?: string | null; @@ -50,6 +51,7 @@ function toApiKeyRecord(r: { scopes: unknown; createdAt: Date; expiresAt: Date | null; + lastUsedAt?: Date | null; isActive: boolean; deletedAt: Date | null; deletedBy: string | null; @@ -63,6 +65,7 @@ function toApiKeyRecord(r: { scopes: deserializeScopes(r.scopes), createdAt: r.createdAt, expiresAt: r.expiresAt ?? undefined, + lastUsedAt: r.lastUsedAt ?? undefined, isActive: r.isActive, deletedAt: r.deletedAt ?? undefined, deletedBy: r.deletedBy ?? undefined, @@ -141,13 +144,15 @@ export async function restoreApiKey( export async function rotateApiKey( id: string, newPlainKey: string, - newScopes?: string[] + newScopes?: string[], + newExpiresAt?: Date | null ): Promise { const hashedKey = hashApiKey(newPlainKey); const updateData: Record = { hashedKey, }; if (newScopes) updateData.scopes = serializeScopes(newScopes); + if (newExpiresAt !== undefined) updateData.expiresAt = newExpiresAt; const record = await prisma.$transaction(async (tx) => { const updated = await tx.apiKey.updateMany({ where: { @@ -175,3 +180,14 @@ export async function listApiKeys( }); return records.map((r) => toApiKeyRecord(r)); } + +export async function touchApiKeyLastUsed(hashed: string): Promise { + try { + await prisma.apiKey.update({ + where: { hashedKey: hashed }, + data: { lastUsedAt: new Date() }, + }); + } catch { + // best-effort audit tracking only + } +}