@@ -2,7 +2,7 @@ import crypto from "node:crypto"
22
33import { getDb } from "~/lib/db"
44
5- // Row type from DB
5+ // Row type from DB (full — includes hash for internal auth use)
66export interface KeyRow {
77 id : string
88 hash : string
@@ -18,13 +18,17 @@ export interface KeyRow {
1818// Base32 alphabet (RFC 4648, no padding)
1919const BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
2020
21+ // Regex that mirrors the upstream model-name validation in config-store (SSRF prevention)
22+ const MODEL_RE = / ^ \w [ \w . : - ] * $ /
23+
2124/**
2225 * Generate a new API key: "sk-cap-" + 52 base32 chars = 59 chars total.
23- * Uses 32 random bytes = 256 bits entropy.
26+ * Uses 33 random bytes = 264 bits of entropy; 264 / 5 = 52 full 5-bit groups
27+ * (260 bits encoded) with 4 bits remaining — no zero-padding required.
2428 */
2529export function generateKey ( ) : string {
26- const bytes = crypto . randomBytes ( 32 )
27- // Encode to base32: each 5 bits → one char; 32 bytes = 256 bits = 51.2 → pad to 52 chars
30+ const bytes = crypto . randomBytes ( 33 )
31+ // Encode to base32: each 5 bits → one char; 33 bytes = 264 bits → exactly 52 chars
2832 let result = ""
2933 let buffer = 0
3034 let bitsLeft = 0
@@ -36,51 +40,81 @@ export function generateKey(): string {
3640 result += BASE32_CHARS [ ( buffer >> bitsLeft ) & 0x1f ]
3741 }
3842 }
39- // Pad to exactly 52 chars if needed
40- while ( result . length < 52 ) {
41- result += BASE32_CHARS [ 0 ]
42- }
43+ // 33 bytes yields exactly 52 chars; the slice+guard is belt-and-suspenders
4344 return `sk-cap-${ result . slice ( 0 , 52 ) } `
4445}
4546
4647/**
47- * Hash a plain key to sha256 hex for storage.
48+ * Hash a plain key to SHA-256 hex for storage.
49+ *
50+ * Unsalted SHA-256 is intentional: API keys have ≥260 bits of random entropy
51+ * so dictionary attacks and rainbow tables are meaningless.
52+ * Do NOT use this function for user-chosen secrets (passwords, PINs, etc.).
53+ *
4854 * The plain key value must NEVER be written to the DB.
4955 */
5056export function hashKey ( plain : string ) : string {
5157 return crypto . createHash ( "sha256" ) . update ( plain ) . digest ( "hex" )
5258}
5359
60+ /** Validate allowedModels: non-empty array of valid model identifiers or "*" */
61+ function validateAllowedModels ( models : Array < string > | undefined ) : void {
62+ if ( models === undefined ) return
63+ if ( models . length === 0 ) {
64+ throw new Error (
65+ 'allowedModels must not be empty; use ["*"] for unrestricted access' ,
66+ )
67+ }
68+ for ( const m of models ) {
69+ if ( m !== "*" && ! MODEL_RE . test ( m ) ) {
70+ throw new Error (
71+ `Invalid model name in allowedModels: "${ m } ". Must match /^\\w[\\w.:-]*$/ or be "*"` ,
72+ )
73+ }
74+ }
75+ }
76+
77+ /**
78+ * Compute the rate-limit integer to store: null means "inherit global".
79+ * Positive values are capped at 10× globalDefault.
80+ */
81+ function resolveRateLimit (
82+ override : number | undefined ,
83+ globalDefault : number ,
84+ ) : number | null {
85+ if ( override === undefined || override === 0 ) return null
86+ if ( ! Number . isInteger ( override ) || override < 0 ) {
87+ throw new Error ( "rateLimitOverride must be a non-negative integer" )
88+ }
89+ const cap = globalDefault * 10
90+ if ( override > cap ) {
91+ throw new Error (
92+ `rate_limit_override ${ override } exceeds cap ${ cap } (10× global default ${ globalDefault } )` ,
93+ )
94+ }
95+ return override
96+ }
97+
5498export function createKey ( options : {
5599 tier : "admin" | "client"
56100 label ?: string
57101 allowedModels ?: Array < string >
58102 rateLimitOverride ?: number
59103 debugEnabled ?: boolean
60- globalRateLimit ?: number // for cap enforcement
104+ globalRateLimit ?: number // for cap enforcement; defaults to 60 if not provided
61105} ) : { plain : string ; row : KeyRow } {
106+ validateAllowedModels ( options . allowedModels )
107+
62108 const db = getDb ( )
63109 const plain = generateKey ( )
64110 const hash = hashKey ( plain )
65111 const id = crypto . randomUUID ( )
66112 const now = Date . now ( )
67113
68- // Rate limit override safety: 0/negative = use default (null).
69- // Hard cap at 10× global default; reject above cap.
70- let rateLimit : number | null = null
71- if (
72- options . rateLimitOverride !== undefined
73- && options . rateLimitOverride > 0
74- ) {
75- const globalDefault = options . globalRateLimit ?? 60
76- const cap = globalDefault * 10
77- if ( options . rateLimitOverride > cap ) {
78- throw new Error (
79- `rate_limit_override ${ options . rateLimitOverride } exceeds cap ${ cap } (10× global default ${ globalDefault } )` ,
80- )
81- }
82- rateLimit = options . rateLimitOverride
83- }
114+ const rateLimit = resolveRateLimit (
115+ options . rateLimitOverride ,
116+ options . globalRateLimit ?? 60 ,
117+ )
84118
85119 const allowedModels = JSON . stringify ( options . allowedModels ?? [ "*" ] )
86120
@@ -114,13 +148,23 @@ export function createKey(options: {
114148 return { plain, row }
115149}
116150
117- export function revokeKey ( id : string ) : void {
118- getDb ( ) . run ( `UPDATE keys SET revoked_at = ? WHERE id = ?` , [ Date . now ( ) , id ] )
151+ /**
152+ * Revoke a key by ID.
153+ * Idempotent: only sets revoked_at if the key is currently active.
154+ * Returns true if the key was revoked, false if not found or already revoked.
155+ * Callers are responsible for verifying the tier before calling (no tier guard here).
156+ */
157+ export function revokeKey ( id : string ) : boolean {
158+ const result = getDb ( ) . run (
159+ `UPDATE keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL` ,
160+ [ Date . now ( ) , id ] ,
161+ )
162+ return result . changes === 1
119163}
120164
121165export function listKeys ( ) : Array < KeyRow > {
122166 return getDb ( )
123- . query < KeyRow , [ ] > ( "SELECT * FROM keys ORDER BY created_at" )
167+ . query < KeyRow , [ ] > ( "SELECT * FROM keys ORDER BY created_at, id " )
124168 . all ( )
125169}
126170
@@ -142,9 +186,14 @@ export function countActiveAdminKeys(): number {
142186 return row ?. n ?? 0
143187}
144188
145- export function setDebugEnabled ( id : string , enabled : boolean ) : void {
146- getDb ( ) . run ( `UPDATE keys SET debug_enabled = ? WHERE id = ?` , [
189+ /**
190+ * Toggle the debug flag on a key.
191+ * Returns true if the key was found and updated, false otherwise.
192+ */
193+ export function setDebugEnabled ( id : string , enabled : boolean ) : boolean {
194+ const result = getDb ( ) . run ( `UPDATE keys SET debug_enabled = ? WHERE id = ?` , [
147195 enabled ? 1 : 0 ,
148196 id ,
149197 ] )
198+ return result . changes === 1
150199}
0 commit comments