mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat(api-tokens): switch to sen_sk_ prefixed opaque keys (#1062)
* feat(api-tokens): switch to sen_sk_ prefixed opaque keys
Replace JWT-shaped API tokens with 56-char opaque keys of the form
`sen_sk_<43-char base62 random><6-char base62 checksum>` (256-bit
entropy, sha256-truncated checksum). Node-proxy tokens stay JWTs.
Why:
* The api_token path was already a sha256 DB lookup; the JWT signature
was wasted work and the 400d JWT ceiling vs DB expires_at was a
confusing dual bound.
* Opaque tokens carry a verifiable checksum so malformed/typoed values
are rejected before any SQLite lookup.
* `sen_sk_` prefix is recognizable to GitHub, TruffleHog, GitGuardian
and makes the on-wire shape visually distinct from node_proxy JWTs.
Changes:
* New `utils/apiTokenFormat.ts` (generate + checksum-verify, CSPRNG via
randomInt, timingSafeEqual on the checksum compare).
* `middleware/auth.ts` and `websocket/upgradeHandler.ts` route opaque
tokens before any jwt.verify; 401 messages unified to avoid a
token-existence oracle.
* `middleware/rateLimiters.ts` short-circuits opaque tokens in the
node_proxy detection and keys per-token via a non-reversible sha256
slice so each token keeps its own bucket without a DB hit.
* All six existing tests migrated from jwt.sign({scope:'api_token'})
to generateApiToken(); new format-only test suite covering prefix,
length, alphabet, checksum reject paths, and a 10k-iteration
collision/integrity loop.
* Docs (features/api-tokens.mdx, api-reference/overview.mdx) describe
the shape and drop the obsolete JWT-ceiling note.
* fix(api-tokens): clear CI lint and CodeQL false positives
* Drop unused TEST_USERNAME import in remote-console-session.test.ts;
the migration to generateApiToken() left it orphaned.
* Add a CodeQL barrier model so `generateApiToken`'s ReturnValue does
not flow into the `insufficient-password-hash` query. The function
emits 256-bit CSPRNG opaque keys; sha256 of the raw token is the
correct construction for high-entropy API tokens (bcrypt-class
hashes target low-entropy human passwords). CodeQL's name heuristic
was treating "Token" as a password source and flagging the standard
sha256 wrapping at all 9 call sites.
* ci(codeql): exclude js/insufficient-password-hash for token paths
The previous barrierModel data extension was a no-op for this rule: the
js/insufficient-password-hash query identifies its "password" sources via
SensitiveExpr's name heuristic ("token", "secret", "key" substrings),
which is upstream of the taint-tracking layer where barrierModel applies.
Verified by post-push re-analysis: 9 alerts still open, all undismissed.
Replace the dead extension with a path-scoped query-filter in
codeql-config.yml so the rule no longer fires on apiTokenFormat,
apiTokens, and the test directory. Real user-password hashing code
elsewhere in the repo (auth, users, setup routes) remains analyzed.
The 9 existing alerts on PR #1062 are dismissed via API as false
positives with a justification pointing at this config. Future runs
will not re-flag them because of the path filter.
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
Reference in New Issue
Block a user