diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 7898293e..eb7f5682 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,2 +1,15 @@ data_extensions: - .github/codeql/extensions/safeLog.model.yml + +query-filters: + # API tokens are 256-bit CSPRNG random; sha256 of the raw token is the + # correct construction. js/insufficient-password-hash exists to catch weak + # hashing of low-entropy human passwords, which is irrelevant for these + # high-entropy opaque keys. Scoped to the token-handling files only, so + # real user-password code (bcrypt-hashed elsewhere) is still analyzed. + - exclude: + id: js/insufficient-password-hash + paths: + - backend/src/utils/apiTokenFormat.ts + - backend/src/routes/apiTokens.ts + - backend/src/__tests__/** diff --git a/backend/src/__tests__/api-token-format.test.ts b/backend/src/__tests__/api-token-format.test.ts new file mode 100644 index 00000000..8564db85 --- /dev/null +++ b/backend/src/__tests__/api-token-format.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { + API_TOKEN_PREFIX, + API_TOKEN_TOTAL_LEN, + API_TOKEN_REGEX, + generateApiToken, + looksLikeApiToken, + verifyApiTokenChecksum, +} from '../utils/apiTokenFormat'; + +describe('apiTokenFormat.generateApiToken', () => { + it('emits tokens with the sen_sk_ prefix', () => { + const token = generateApiToken(); + expect(token.startsWith(API_TOKEN_PREFIX)).toBe(true); + }); + + it('emits tokens of exactly 56 characters', () => { + expect(generateApiToken().length).toBe(API_TOKEN_TOTAL_LEN); + expect(API_TOKEN_TOTAL_LEN).toBe(56); + }); + + it('emits tokens that match the canonical regex', () => { + expect(API_TOKEN_REGEX.test(generateApiToken())).toBe(true); + }); + + it('uses only base62 characters in the body (no dashes, no underscores)', () => { + const body = generateApiToken().slice(API_TOKEN_PREFIX.length); + expect(/^[A-Za-z0-9]+$/.test(body)).toBe(true); + expect(body.includes('-')).toBe(false); + expect(body.includes('_')).toBe(false); + }); +}); + +describe('apiTokenFormat.verifyApiTokenChecksum (accept path)', () => { + it('accepts a freshly generated token', () => { + expect(verifyApiTokenChecksum(generateApiToken())).toBe(true); + }); +}); + +describe('apiTokenFormat.looksLikeApiToken / verifyApiTokenChecksum (reject path)', () => { + it('rejects an empty string', () => { + expect(looksLikeApiToken('')).toBe(false); + expect(verifyApiTokenChecksum('')).toBe(false); + }); + + it('rejects a JWT-shaped token', () => { + const jwtLike = 'eyJhbGciOiJIUzI1NiJ9.eyJzY29wZSI6ImFwaV90b2tlbiJ9.signature'; + expect(looksLikeApiToken(jwtLike)).toBe(false); + expect(verifyApiTokenChecksum(jwtLike)).toBe(false); + }); + + it('rejects a token with the wrong prefix', () => { + const token = generateApiToken(); + const swapped = 'sen_pk_' + token.slice(API_TOKEN_PREFIX.length); + expect(looksLikeApiToken(swapped)).toBe(false); + expect(verifyApiTokenChecksum(swapped)).toBe(false); + }); + + it('rejects a token with one character truncated', () => { + const truncated = generateApiToken().slice(0, -1); + expect(verifyApiTokenChecksum(truncated)).toBe(false); + }); + + it('rejects a token with one extra character', () => { + const longer = generateApiToken() + 'A'; + expect(verifyApiTokenChecksum(longer)).toBe(false); + }); + + it('rejects a token containing a dash', () => { + const token = generateApiToken(); + const mutated = token.slice(0, 20) + '-' + token.slice(21); + expect(verifyApiTokenChecksum(mutated)).toBe(false); + }); + + it('rejects a token containing an underscore in the body', () => { + const token = generateApiToken(); + const mutated = token.slice(0, 30) + '_' + token.slice(31); + expect(verifyApiTokenChecksum(mutated)).toBe(false); + }); + + it('rejects a single-char mutation inside the random portion', () => { + const token = generateApiToken(); + const mutateAt = API_TOKEN_PREFIX.length + 5; + const original = token[mutateAt]; + const replacement = original === 'a' ? 'b' : 'a'; + const mutated = token.slice(0, mutateAt) + replacement + token.slice(mutateAt + 1); + expect(mutated).not.toBe(token); + expect(verifyApiTokenChecksum(mutated)).toBe(false); + }); + + it('rejects a single-char mutation inside the checksum portion', () => { + const token = generateApiToken(); + const mutateAt = API_TOKEN_TOTAL_LEN - 3; + const original = token[mutateAt]; + const replacement = original === 'a' ? 'b' : 'a'; + const mutated = token.slice(0, mutateAt) + replacement + token.slice(mutateAt + 1); + expect(mutated).not.toBe(token); + expect(verifyApiTokenChecksum(mutated)).toBe(false); + }); +}); + +describe('apiTokenFormat: bulk generator integrity', () => { + it('produces 10000 tokens with no collisions and all valid checksums', () => { + const seen = new Set(); + const iterations = 10_000; + for (let i = 0; i < iterations; i++) { + const token = generateApiToken(); + expect(verifyApiTokenChecksum(token)).toBe(true); + seen.add(token); + } + expect(seen.size).toBe(iterations); + }); +}); diff --git a/backend/src/__tests__/api-tokens.test.ts b/backend/src/__tests__/api-tokens.test.ts index 76c2184c..68c3d163 100644 --- a/backend/src/__tests__/api-tokens.test.ts +++ b/backend/src/__tests__/api-tokens.test.ts @@ -4,23 +4,23 @@ */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; -import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import bcrypt from 'bcrypt'; -import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { generateApiToken } from '../utils/apiTokenFormat'; let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let authCookie: string; -/** Create an API token directly in the DB and return its raw JWT string. */ +/** Create an API token directly in the DB and return its raw value. */ function createTestApiToken( scope: 'read-only' | 'deploy-only' | 'full-admin', expiresAt: number | null = null, userId?: number, ): string { - const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' }); + const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const db = DatabaseService.getInstance(); const resolvedUserId = userId ?? db.getUserByUsername('testadmin')!.id; diff --git a/backend/src/__tests__/auto-heal-routes.test.ts b/backend/src/__tests__/auto-heal-routes.test.ts index f164c2c5..c286d233 100644 --- a/backend/src/__tests__/auto-heal-routes.test.ts +++ b/backend/src/__tests__/auto-heal-routes.test.ts @@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb'; +import { generateApiToken } from '../utils/apiTokenFormat'; import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers'; let tmpDir: string; @@ -18,7 +19,7 @@ function userToken(username: string): string { } function createApiToken(scope: 'read-only' | 'deploy-only' | 'full-admin'): string { - const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '5m' }); + const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const admin = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME); if (!admin?.id) throw new Error('missing seeded admin'); diff --git a/backend/src/__tests__/helpers/apiTokenTestHelper.ts b/backend/src/__tests__/helpers/apiTokenTestHelper.ts new file mode 100644 index 00000000..5253bda2 --- /dev/null +++ b/backend/src/__tests__/helpers/apiTokenTestHelper.ts @@ -0,0 +1,41 @@ +import crypto from 'crypto'; +import { generateApiToken } from '../../utils/apiTokenFormat'; +import type { DatabaseService } from '../../services/DatabaseService'; + +type DbClass = typeof DatabaseService; + +export interface CreateTestApiTokenOptions { + db: DbClass; + scope: 'read-only' | 'deploy-only' | 'full-admin'; + userId: number; + name?: string; + expiresAt?: number | null; +} + +/** + * Generate a `sen_sk_` API token and insert a backing row into `api_tokens`, + * mirroring what `routes/apiTokens.ts` does in production. Returns the raw + * token (only secret value the test sees; the DB stores its sha256). + */ +export function createTestApiToken(opts: CreateTestApiTokenOptions): string { + const raw = generateApiToken(); + const tokenHash = crypto.createHash('sha256').update(raw).digest('hex'); + opts.db.getInstance().addApiToken({ + token_hash: tokenHash, + name: opts.name ?? `test-${opts.scope}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + scope: opts.scope, + user_id: opts.userId, + created_at: Date.now(), + expires_at: opts.expiresAt ?? null, + }); + return raw; +} + +/** + * Produce a well-formed `sen_sk_` token that has no matching DB row. The + * auth layer should reject it at the row-lookup step — useful for asserting + * unbacked tokens are not honoured. + */ +export function unbackedApiToken(): string { + return generateApiToken(); +} diff --git a/backend/src/__tests__/remote-console-session.test.ts b/backend/src/__tests__/remote-console-session.test.ts index 8402d059..86e9c8b8 100644 --- a/backend/src/__tests__/remote-console-session.test.ts +++ b/backend/src/__tests__/remote-console-session.test.ts @@ -16,8 +16,9 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; -import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb'; import { mintConsoleSession } from '../helpers/consoleSession'; +import { generateApiToken } from '../utils/apiTokenFormat'; describe('console_session token parity (HTTP route vs mint helper)', () => { let tmpDir: string; @@ -85,10 +86,11 @@ describe('console_session token parity (HTTP route vs mint helper)', () => { it('rejects API-token callers of POST /api/system/console-token (rejectApiTokenScope)', async () => { // Mint a JWT that claims the api_token scope but is not backed by a real - // row in the database. The authMiddleware rejects this at the api_token - // branch before the route handler runs, which is the behavior we want: - // an API token should never be allowed to mint a console session. - const fakeApiToken = jwt.sign({ scope: 'api_token', username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + // row in the database. authMiddleware accepts the sen_sk_ format and + // rejects at the row-lookup step before the route handler runs, which + // is the behavior we want: an API token should never be allowed to mint + // a console session. + const fakeApiToken = generateApiToken(); const res = await request(app) .post('/api/system/console-token') .set('Authorization', `Bearer ${fakeApiToken}`); diff --git a/backend/src/__tests__/sso.test.ts b/backend/src/__tests__/sso.test.ts index 74fe4570..6089e556 100644 --- a/backend/src/__tests__/sso.test.ts +++ b/backend/src/__tests__/sso.test.ts @@ -4,6 +4,7 @@ import supertest from 'supertest'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import type { Express } from 'express'; +import { generateApiToken } from '../utils/apiTokenFormat'; let tmpDir: string; let app: Express; @@ -583,7 +584,7 @@ describe('SSO Config - API Token Denied', () => { beforeAll(async () => { const { DatabaseService } = await import('../services/DatabaseService'); const db = DatabaseService.getInstance(); - apiRawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' }); + apiRawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(apiRawToken).digest('hex'); const admin = db.getUserByUsername('testadmin'); db.addApiToken({ token_hash: tokenHash, name: `sso-scope-${Date.now()}`, scope: 'full-admin', user_id: admin!.id, created_at: Date.now(), expires_at: null }); diff --git a/backend/src/__tests__/upgrade-order.test.ts b/backend/src/__tests__/upgrade-order.test.ts index c76abc9b..06848f5b 100644 --- a/backend/src/__tests__/upgrade-order.test.ts +++ b/backend/src/__tests__/upgrade-order.test.ts @@ -14,6 +14,7 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import type { AddressInfo } from 'net'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { generateApiToken } from '../utils/apiTokenFormat'; describe('WebSocket upgrade dispatch order', () => { let tmpDir: string; @@ -170,7 +171,7 @@ describe('WebSocket upgrade dispatch order', () => { it('accepts a full-admin api_token at the upgrade and reaches the handler', async () => { const { DatabaseService } = await import('../services/DatabaseService'); - const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' }); + const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id; DatabaseService.getInstance().addApiToken({ @@ -192,7 +193,7 @@ describe('WebSocket upgrade dispatch order', () => { it('rejects a read-only api_token at the upgrade with HTTP 403', async () => { const { DatabaseService } = await import('../services/DatabaseService'); - const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' }); + const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id; DatabaseService.getInstance().addApiToken({ diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts index d12ea767..ebaea777 100644 --- a/backend/src/__tests__/users-rbac.test.ts +++ b/backend/src/__tests__/users-rbac.test.ts @@ -8,6 +8,7 @@ import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; import crypto from 'crypto'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { generateApiToken } from '../utils/apiTokenFormat'; let tmpDir: string; let app: import('express').Express; @@ -112,7 +113,7 @@ describe('POST /api/users', () => { }); it('blocks API tokens (403 SCOPE_DENIED)', async () => { - const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' }); + const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const db = DatabaseService.getInstance(); const user = db.getUserByUsername(TEST_USERNAME); @@ -473,7 +474,7 @@ describe('PUT /api/auth/password', () => { }); it('blocks API tokens (403)', async () => { - const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' }); + const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const db = DatabaseService.getInstance(); const user = db.getUserByUsername(TEST_USERNAME); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 8de38a53..563cdc48 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -23,14 +23,15 @@ import { MFA_PENDING_TTL_MS, } from '../helpers/constants'; import { getCookieOptions } from '../helpers/cookies'; +import { looksLikeApiToken, verifyApiTokenChecksum } from '../utils/apiTokenFormat'; /** * Authenticate a request via cookie session or Bearer token. * - * Handles five scopes: user-session (cookie or bearer), api_token, - * mfa_pending, node_proxy, pilot_tunnel. Bearer token is preferred when both - * are present so node-to-node proxy calls aren't shadowed by a stale - * cross-instance cookie. + * Handles five auth modes: opaque sen_sk_ API tokens (routed before any JWT + * work) plus the JWT-backed user-session, mfa_pending, node_proxy, and + * pilot_tunnel scopes. Bearer token is preferred over cookie so node-to-node + * proxy calls aren't shadowed by a stale cross-instance cookie. */ export const authMiddleware: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { const cookieToken = req.cookies[COOKIE_NAME]; @@ -45,25 +46,29 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response } try { - const settings = DatabaseService.getInstance().getGlobalSettings(); - const jwtSecret = settings.auth_jwt_secret; - if (!jwtSecret) throw new Error('No JWT secret'); - const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean }; - - if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session'); - - // API token path: scope-based programmatic access - if (decoded.scope === 'api_token') { + // Opaque sen_sk_ API tokens: scope-based programmatic access. Routed + // before jwt.verify so the JWT path stays focused on session, mfa_pending, + // node_proxy, and pilot_tunnel. Steps 1-3 (prefix, length, checksum) + // reject malformed/typoed keys without touching SQLite. + if (looksLikeApiToken(token)) { + // Uniform 401 message across malformed-checksum, unknown-hash, and + // expired/revoked paths so the response body is not a token-existence + // oracle. Debug logs still capture the specific reason. + if (!verifyApiTokenChecksum(token)) { + if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: checksum'); + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash); if (!apiToken || apiToken.revoked_at) { if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: not found or revoked'); - res.status(401).json({ error: 'API token not found or revoked' }); + res.status(401).json({ error: 'Invalid or expired token' }); return; } if (apiToken.expires_at && apiToken.expires_at < Date.now()) { if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: expired'); - res.status(401).json({ error: 'API token has expired' }); + res.status(401).json({ error: 'Invalid or expired token' }); return; } DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id); @@ -79,6 +84,13 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response return; } + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) throw new Error('No JWT secret'); + const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean }; + + if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session'); + // Partial-auth session: a password/SSO credential has verified, but the // TOTP second factor is still required. Such a token can only be used to // complete the MFA challenge or to abort the flow by logging out. Every diff --git a/backend/src/middleware/rateLimiters.ts b/backend/src/middleware/rateLimiters.ts index c0c82ff4..26fb6fef 100644 --- a/backend/src/middleware/rateLimiters.ts +++ b/backend/src/middleware/rateLimiters.ts @@ -1,8 +1,10 @@ import type { Request } from 'express'; import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; import jwt from 'jsonwebtoken'; +import { createHash } from 'crypto'; import { COOKIE_NAME } from '../helpers/constants'; import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns'; +import { looksLikeApiToken } from '../utils/apiTokenFormat'; // ── Rate Limiting ───────────────────────────────────────────────────────────── // @@ -42,8 +44,15 @@ function isNodeProxyRequest(req: Request): boolean { cached._isNodeProxy = false; return false; } + const bearer = auth.slice(7); + // Opaque API tokens are never node_proxy credentials, and they are not + // JWTs — short-circuit so `jwt.decode` is never invoked on them. + if (looksLikeApiToken(bearer)) { + cached._isNodeProxy = false; + return false; + } try { - const decoded = jwt.decode(auth.slice(7)) as { scope?: string } | null; + const decoded = jwt.decode(bearer) as { scope?: string } | null; const result = decoded?.scope === 'node_proxy'; cached._isNodeProxy = result; return result; @@ -68,8 +77,16 @@ function rateLimitKeyGenerator(req: Request): string { } const auth = req.headers.authorization; if (auth?.startsWith('Bearer ')) { + const bearer = auth.slice(7); + // Opaque API tokens key by a non-reversible hash slice of the token + // itself: each token gets its own rate-limit budget without a DB hit on + // the hot path (this runs before authMiddleware). + if (looksLikeApiToken(bearer)) { + const slice = createHash('sha256').update(bearer).digest('hex').slice(0, 16); + return `user:sk:${slice}`; + } try { - const decoded = jwt.decode(auth.slice(7)) as { username?: string; sub?: string } | null; + const decoded = jwt.decode(bearer) as { username?: string; sub?: string } | null; if (decoded?.username) return `user:${decoded.username}`; if (decoded?.sub) return `user:${decoded.sub}`; } catch { /* fall through to IP */ } diff --git a/backend/src/routes/apiTokens.ts b/backend/src/routes/apiTokens.ts index b1360b93..92472d10 100644 --- a/backend/src/routes/apiTokens.ts +++ b/backend/src/routes/apiTokens.ts @@ -1,5 +1,4 @@ import { Router, type Request, type Response } from 'express'; -import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { DatabaseService, type ApiTokenScope } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; @@ -7,10 +6,8 @@ import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { isDebugEnabled } from '../utils/debug'; import { parseIntParam } from '../utils/parseIntParam'; +import { generateApiToken } from '../utils/apiTokenFormat'; -// JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB -// check (expires_at) is always the tighter bound. -const API_TOKEN_JWT_CEILING = '400d'; const MAX_ACTIVE_TOKENS_PER_USER = 25; const API_TOKEN_SCOPE_MESSAGE = 'API tokens cannot manage other API tokens.'; @@ -43,13 +40,6 @@ apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): P } const expiresAt = typeof expires_in === 'number' ? Date.now() + expires_in * 24 * 60 * 60 * 1000 : null; - const settings = DatabaseService.getInstance().getGlobalSettings(); - const jwtSecret = settings.auth_jwt_secret; - if (!jwtSecret) { - res.status(500).json({ error: 'No JWT secret configured.' }); - return; - } - const db = DatabaseService.getInstance(); const user = db.getUserByUsername(req.user!.username); if (!user) { @@ -68,7 +58,7 @@ apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): P return; } - const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret, { expiresIn: API_TOKEN_JWT_CEILING }); + const rawToken = generateApiToken(); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const id = db.addApiToken({ diff --git a/backend/src/utils/apiTokenFormat.ts b/backend/src/utils/apiTokenFormat.ts new file mode 100644 index 00000000..04fa8ade --- /dev/null +++ b/backend/src/utils/apiTokenFormat.ts @@ -0,0 +1,51 @@ +import { createHash, randomInt, timingSafeEqual } from 'crypto'; + +// Sencho secret key prefix. Tokens issued from Settings → API are opaque +// (not JWTs): a base62 random body plus a base62 checksum so malformed or +// typoed keys are rejected before any SQLite lookup, and so secret scanners +// (GitHub, TruffleHog, GitGuardian) have a recognisable signature. +export const API_TOKEN_PREFIX = 'sen_sk_'; + +const RANDOM_LEN = 43; +const CHECKSUM_LEN = 6; + +export const API_TOKEN_BODY_LEN = RANDOM_LEN + CHECKSUM_LEN; +export const API_TOKEN_TOTAL_LEN = API_TOKEN_PREFIX.length + API_TOKEN_BODY_LEN; +export const API_TOKEN_REGEX = /^sen_sk_[A-Za-z0-9]{49}$/; + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +function base62Encode32(value: number): string { + let n = value >>> 0; + let out = ''; + for (let i = 0; i < CHECKSUM_LEN; i++) { + out = ALPHABET[n % 62] + out; + n = Math.floor(n / 62); + } + return out; +} + +function computeChecksum(random: string): string { + const hash = createHash('sha256').update(random).digest(); + return base62Encode32(hash.readUInt32BE(0)); +} + +export function generateApiToken(): string { + let random = ''; + for (let i = 0; i < RANDOM_LEN; i++) { + random += ALPHABET[randomInt(0, 62)]; + } + return API_TOKEN_PREFIX + random + computeChecksum(random); +} + +export function looksLikeApiToken(token: string): boolean { + return token.length === API_TOKEN_TOTAL_LEN && API_TOKEN_REGEX.test(token); +} + +export function verifyApiTokenChecksum(token: string): boolean { + if (!looksLikeApiToken(token)) return false; + const random = token.slice(API_TOKEN_PREFIX.length, API_TOKEN_PREFIX.length + RANDOM_LEN); + const checksum = token.slice(API_TOKEN_PREFIX.length + RANDOM_LEN); + const expected = computeChecksum(random); + return timingSafeEqual(Buffer.from(checksum, 'utf8'), Buffer.from(expected, 'utf8')); +} diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index 0374950e..d00cf97d 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -14,6 +14,7 @@ import { handleLogsWs } from './logs'; import { handleHostConsoleWs } from './hostConsole'; import { handleGenericWs, attachGenericConnectionHandlers } from './generic'; import { rejectUpgrade as reject } from './reject'; +import { looksLikeApiToken, verifyApiTokenChecksum } from '../utils/apiTokenFormat'; function parseCookies(req: IncomingMessage): Record { const header = req.headers.cookie || ''; @@ -72,25 +73,30 @@ export function attachUpgrade( if (!token) return reject(socket, 401, 'Unauthorized'); try { - const settings = DatabaseService.getInstance().getGlobalSettings(); - const jwtSecret = settings.auth_jwt_secret; - if (!jwtSecret) throw new Error('No JWT secret'); - const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number }; - - // Node proxy tokens are machine-to-machine credentials and must never be - // granted interactive terminal access (host console or container exec). - const isProxyToken = decoded.scope === 'node_proxy'; - + // Opaque sen_sk_ API tokens: handled before jwt.verify. Prefix + + // length + checksum reject malformed keys without touching SQLite. + let decoded: { username?: string; scope?: string; role?: string; tv?: number }; let wsApiTokenScope: string | null = null; - if (decoded.scope === 'api_token') { + if (looksLikeApiToken(token)) { + if (!verifyApiTokenChecksum(token)) return reject(socket, 401, 'Unauthorized'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash); if (!apiToken || apiToken.revoked_at) return reject(socket, 401, 'Unauthorized'); if (apiToken.expires_at && apiToken.expires_at < Date.now()) return reject(socket, 401, 'Unauthorized'); DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id); wsApiTokenScope = apiToken.scope; + decoded = { scope: 'api_token' }; + } else { + const settings = DatabaseService.getInstance().getGlobalSettings(); + const jwtSecret = settings.auth_jwt_secret; + if (!jwtSecret) throw new Error('No JWT secret'); + decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number }; } + // Node proxy tokens are machine-to-machine credentials and must never be + // granted interactive terminal access (host console or container exec). + const isProxyToken = decoded.scope === 'node_proxy'; + // For user session tokens (no scope), resolve against DB for up-to-date // role and token_version checks. Scoped tokens (api_token, node_proxy, // console_session) skip this: they are validated by their own logic diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 250fa4e5..6e5a3363 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -22,6 +22,8 @@ curl -H "Authorization: Bearer YOUR_API_TOKEN" \ https://your-sencho-instance:1852/api/stacks ``` +API tokens are 56-character opaque strings that begin with `sen_sk_`, for example `sen_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`. The prefix makes them easy to identify in logs and is recognized by secret-scanning tools (GitHub, TruffleHog, GitGuardian). + API Tokens require a Sencho **Admiral** license. Community and Skipper editions do not include this feature. diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index 6f830c7d..fe9dba50 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -105,8 +105,8 @@ Click the trash icon next to any token in the API Tokens settings tab. A confirm ## Security model +- **Recognizable format**: Tokens are 56 characters and begin with the `sen_sk_` prefix, followed by 49 base62 (alphanumeric) characters. The last six characters are a checksum, so malformed or typoed values are rejected before any database lookup. The prefix makes Sencho tokens easy to spot in logs, code, and secret-scanning tools (GitHub, TruffleHog, GitGuardian). - **Hashed storage**: Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted. -- **JWT-level expiry ceiling**: Every token includes a built-in expiry at the JWT level as defense-in-depth, independent of the user-configured expiration. The database-level expiry is always the tighter constraint. - **Audit trail**: All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username. - **Optional expiry**: Tokens can be created with an expiration period (30 days, 60 days, 90 days, or 1 year). Tokens without an expiry must be revoked manually when no longer needed. - **Per-user limits**: Each user can create up to 25 active tokens. Token names must be unique among active tokens for the same user.