initluyhgul
This commit is contained in:
@@ -9,18 +9,13 @@ import { logger } from '../lib/logger';
|
||||
* Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
||||
*
|
||||
* Digests: sha1 (20-byte sig, legacy Flask-WTF), sha256 (32), sha512 (64, itsdangerous 2.x default).
|
||||
*
|
||||
* Default algorithm (itsdangerous ≥ 2.0):
|
||||
* - digest: SHA-512 (HMAC)
|
||||
* - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token")
|
||||
* - derived_key = HMAC(secret, salt + "signer").digest()
|
||||
* - signature = HMAC(derived_key, payload + "." + timestamp).digest()
|
||||
*
|
||||
* Timestamp: seconds since 2011-01-01 (itsdangerous epoch), base64url-encoded big-endian.
|
||||
*/
|
||||
|
||||
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
|
||||
|
||||
/** Типичные salt у auth / Flask-WTF — только для verify fallback, не для generate. */
|
||||
const LEGACY_VERIFY_SALTS = ['csrf-salt', 'csrf', 'csrf-token', 'itsdangerous.Signer'] as const;
|
||||
|
||||
export interface CsrfConfig {
|
||||
secret: string;
|
||||
salt: string;
|
||||
@@ -28,7 +23,6 @@ export interface CsrfConfig {
|
||||
maxAgeSec: number;
|
||||
}
|
||||
|
||||
// Live config — атомарно подменяется через swapCsrfConfig()
|
||||
let current: CsrfConfig | null = null;
|
||||
|
||||
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
||||
@@ -43,9 +37,6 @@ function b64urlEncode(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
|
||||
*/
|
||||
export async function fetchCsrfConfig(
|
||||
addr: string,
|
||||
token: string,
|
||||
@@ -68,15 +59,12 @@ export async function fetchCsrfConfig(
|
||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
||||
}
|
||||
|
||||
// sha1 deprecated — accept только sha256/sha512.
|
||||
// Default sha256: совпадает с deploy vault-init и типичным Flask config при явном digest_method.
|
||||
// itsdangerous 2.x без digest → sha512; wallet API при несовпадении пробует fallback в verifyCsrfToken.
|
||||
let digest: 'sha256' | 'sha512' = 'sha256';
|
||||
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
digest = secrets.digest;
|
||||
}
|
||||
|
||||
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
let maxAgeSec = 60 * 60 * 24 * 7;
|
||||
if (secrets.max_age_sec) {
|
||||
const n = parseInt(secrets.max_age_sec);
|
||||
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
||||
@@ -97,8 +85,6 @@ function deriveKey(secret: string, salt: string, digest: string): Buffer {
|
||||
|
||||
function decodeTimestamp(encoded: string): number {
|
||||
const raw = b64urlDecode(encoded);
|
||||
// Использовать арифметику вместо bitwise — bitwise overflowит на 32-bit signed
|
||||
// после 2038 если timestamp encoding станет 5-байтным.
|
||||
let ts = 0;
|
||||
for (const b of raw) ts = ts * 256 + b;
|
||||
return ts + ITSDANGEROUS_EPOCH;
|
||||
@@ -119,6 +105,8 @@ export interface CsrfVerifyResult {
|
||||
reason?: string;
|
||||
actualSigLen?: number;
|
||||
expectedSigLen?: number;
|
||||
fallbackDigest?: CsrfDigest;
|
||||
fallbackSalt?: string;
|
||||
}
|
||||
|
||||
export interface CsrfConfigSummary {
|
||||
@@ -127,6 +115,16 @@ export interface CsrfConfigSummary {
|
||||
maxAgeSec: number;
|
||||
}
|
||||
|
||||
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
|
||||
|
||||
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
|
||||
20: 'sha1',
|
||||
32: 'sha256',
|
||||
64: 'sha512',
|
||||
};
|
||||
|
||||
const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512'];
|
||||
|
||||
export function getCsrfConfigSummary(): CsrfConfigSummary | null {
|
||||
if (!current) return null;
|
||||
return { salt: current.salt, digest: current.digest, maxAgeSec: current.maxAgeSec };
|
||||
@@ -157,17 +155,9 @@ export function generateCsrfToken(): { token: string; maxAgeSec: number } {
|
||||
};
|
||||
}
|
||||
|
||||
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
|
||||
|
||||
/** HMAC output length → digest (auth-service legacy часто sha1 → sigLen=20). */
|
||||
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
|
||||
20: 'sha1',
|
||||
32: 'sha256',
|
||||
64: 'sha512',
|
||||
};
|
||||
|
||||
function verifyCsrfTokenWithDigest(
|
||||
function verifyCsrfTokenWithParams(
|
||||
cfg: CsrfConfig,
|
||||
salt: string,
|
||||
digest: CsrfDigest,
|
||||
token: string,
|
||||
): CsrfVerifyResult {
|
||||
@@ -184,7 +174,7 @@ function verifyCsrfTokenWithDigest(
|
||||
|
||||
const tsStr = payloadTs.slice(prevDot + 1);
|
||||
|
||||
const derived = deriveKey(cfg.secret, cfg.salt, digest);
|
||||
const derived = deriveKey(cfg.secret, salt, digest);
|
||||
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest();
|
||||
|
||||
let actualSig: Buffer;
|
||||
@@ -218,48 +208,80 @@ function verifyCsrfTokenWithDigest(
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function digestsToTry(primary: CsrfVerifyResult): CsrfDigest[] {
|
||||
/** Минимальная длина salt при verify-fallback (Flask-WTF default `csrf` = 4). */
|
||||
const MIN_VERIFY_SALT_LEN = 1;
|
||||
|
||||
function saltsToTry(vaultSalt: string): string[] {
|
||||
const out: string[] = [];
|
||||
const add = (s: string, minLen = MIN_VERIFY_SALT_LEN) => {
|
||||
if (s && s.length >= minLen && !out.includes(s)) out.push(s);
|
||||
};
|
||||
add(vaultSalt, 8);
|
||||
for (const s of LEGACY_VERIFY_SALTS) add(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfDigest[] {
|
||||
const order: CsrfDigest[] = [];
|
||||
const add = (d: CsrfDigest) => {
|
||||
if (!order.includes(d)) order.push(d);
|
||||
};
|
||||
|
||||
add(current!.digest);
|
||||
add(vaultDigest);
|
||||
if (primary.actualSigLen !== undefined) {
|
||||
const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen];
|
||||
if (inferred) add(inferred);
|
||||
}
|
||||
add('sha1');
|
||||
add('sha256');
|
||||
add('sha512');
|
||||
for (const d of ALL_DIGESTS) add(d);
|
||||
return order;
|
||||
}
|
||||
|
||||
function isRetryableVerifyFailure(reason?: string): boolean {
|
||||
return reason === 'Signature length mismatch' || reason === 'Signature mismatch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSRF token. Fallback по длине подписи: sha1 (20) / sha256 (32) / sha512 (64).
|
||||
* Verify CSRF: Vault salt+digest first, then legacy digest/salt matrix (auth Flask-WTF).
|
||||
*/
|
||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
|
||||
const primaryDigest = current.digest;
|
||||
const primary = verifyCsrfTokenWithDigest(current, primaryDigest, token);
|
||||
const vaultSalt = current.salt;
|
||||
const vaultDigest = current.digest as CsrfDigest;
|
||||
const salts = saltsToTry(vaultSalt);
|
||||
|
||||
const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token);
|
||||
if (primary.valid) return primary;
|
||||
|
||||
if (primary.reason !== 'Signature length mismatch' && primary.reason !== 'Signature mismatch') {
|
||||
if (!isRetryableVerifyFailure(primary.reason)) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
for (const digest of digestsToTry(primary)) {
|
||||
if (digest === primaryDigest) continue;
|
||||
const attempt = verifyCsrfTokenWithDigest(current, digest, token);
|
||||
if (attempt.valid) {
|
||||
logger.warn(
|
||||
`CSRF verified with fallback digest ${digest} (Vault digest=${primaryDigest}, ` +
|
||||
`sigLen=${primary.actualSigLen ?? '?'}). Align auth digest_method with Vault.`,
|
||||
);
|
||||
return { valid: true };
|
||||
const digests = digestsToTry(primary, vaultDigest);
|
||||
let lastMismatch: CsrfVerifyResult = primary;
|
||||
|
||||
for (const salt of salts) {
|
||||
for (const digest of digests) {
|
||||
if (salt === vaultSalt && digest === vaultDigest) continue;
|
||||
|
||||
const attempt = verifyCsrfTokenWithParams(current, salt, digest, token);
|
||||
if (attempt.valid) {
|
||||
logger.warn(
|
||||
`CSRF verified with fallback digest=${digest} salt="${salt}" ` +
|
||||
`(Vault digest=${vaultDigest} salt="${vaultSalt}"). ` +
|
||||
'Align auth-service with Vault `digest` and `salt` fields.',
|
||||
);
|
||||
return { valid: true, fallbackDigest: digest, fallbackSalt: salt };
|
||||
}
|
||||
if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') {
|
||||
lastMismatch = attempt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return primary;
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Signature mismatch (all digest/salt fallbacks failed)',
|
||||
actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen,
|
||||
expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user