mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
d882f223f4
* 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.
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
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<string>();
|
|
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);
|
|
});
|
|
});
|