initluyhgulednj
This commit is contained in:
@@ -8,12 +8,33 @@ import { logger } from '../lib/logger';
|
||||
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
||||
*
|
||||
* Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии).
|
||||
*
|
||||
* itsdangerous 2.x по умолчанию: key_derivation=django-concat, digest=sha1.
|
||||
* Старый Node-код использовал legacy-hmac-signer — из-за этого prod 403 при верном secret_key.
|
||||
*/
|
||||
|
||||
const ITSDANGEROUS_EPOCH = 1293840000;
|
||||
|
||||
const DEFAULT_SALT = 'itsdangerous.Signer';
|
||||
const LEGACY_VERIFY_SALTS = ['csrf-salt', 'csrf', 'csrf-token', 'itsdangerous.Signer'] as const;
|
||||
const LEGACY_VERIFY_SALTS = [
|
||||
'csrf-salt',
|
||||
'csrf',
|
||||
'csrf-token',
|
||||
'wtf',
|
||||
'wtf-csrf',
|
||||
'itsdangerous.Signer',
|
||||
] as const;
|
||||
|
||||
/** Порядок: сначала то, что реально ставит itsdangerous 2.x / Flask-WTF. */
|
||||
const KEY_DERIVATIONS = [
|
||||
'django-concat',
|
||||
'legacy-hmac-signer',
|
||||
'hmac',
|
||||
'concat',
|
||||
'none',
|
||||
] as const;
|
||||
|
||||
export type CsrfKeyDerivation = (typeof KEY_DERIVATIONS)[number];
|
||||
|
||||
export interface CsrfConfig {
|
||||
secret: string;
|
||||
@@ -70,7 +91,7 @@ export function logCsrfConfigLoaded(): void {
|
||||
`CSRF config loaded: mount=${summary.mount} path=${summary.path} ` +
|
||||
`salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` +
|
||||
`salt_source=${summary.saltSource} digest_source=${summary.digestSource} ` +
|
||||
`secret_fp=${summary.secretFp}`,
|
||||
`secret_fp=${summary.secretFp} verify_key_derivations=${KEY_DERIVATIONS.join(',')}`,
|
||||
);
|
||||
if (summary.saltSource === 'default') {
|
||||
logger.warn(
|
||||
@@ -125,10 +146,54 @@ function b64urlDecode(s: string): Buffer {
|
||||
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function deriveKey(secret: string, salt: string, digest: string): Buffer {
|
||||
/** itsdangerous 2.x default: hash(salt + b"signer" + secret_key). */
|
||||
function deriveKeyDjangoConcat(secret: string, salt: string, digest: string): Buffer {
|
||||
const data = Buffer.concat([
|
||||
Buffer.from(salt, 'utf8'),
|
||||
Buffer.from('signer', 'utf8'),
|
||||
Buffer.from(secret, 'utf8'),
|
||||
]);
|
||||
return crypto.createHash(digest).update(data).digest();
|
||||
}
|
||||
|
||||
/** itsdangerous key_derivation="hmac": HMAC(secret, salt). */
|
||||
function deriveKeyHmac(secret: string, salt: string, digest: string): Buffer {
|
||||
return crypto.createHmac(digest, secret).update(salt, 'utf8').digest();
|
||||
}
|
||||
|
||||
/** itsdangerous key_derivation="concat": hash(salt + secret). */
|
||||
function deriveKeyConcat(secret: string, salt: string, digest: string): Buffer {
|
||||
const data = Buffer.concat([Buffer.from(salt, 'utf8'), Buffer.from(secret, 'utf8')]);
|
||||
return crypto.createHash(digest).update(data).digest();
|
||||
}
|
||||
|
||||
/** Старый wallet / часть 1.x: HMAC(secret, salt + "signer"). */
|
||||
function deriveKeyLegacyHmacSigner(secret: string, salt: string, digest: string): Buffer {
|
||||
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
|
||||
}
|
||||
|
||||
export function deriveSigningKey(
|
||||
secret: string,
|
||||
salt: string,
|
||||
digest: string,
|
||||
mode: CsrfKeyDerivation,
|
||||
): Buffer {
|
||||
switch (mode) {
|
||||
case 'django-concat':
|
||||
return deriveKeyDjangoConcat(secret, salt, digest);
|
||||
case 'hmac':
|
||||
return deriveKeyHmac(secret, salt, digest);
|
||||
case 'concat':
|
||||
return deriveKeyConcat(secret, salt, digest);
|
||||
case 'legacy-hmac-signer':
|
||||
return deriveKeyLegacyHmacSigner(secret, salt, digest);
|
||||
case 'none':
|
||||
return Buffer.from(secret, 'utf8');
|
||||
default:
|
||||
return deriveKeyDjangoConcat(secret, salt, digest);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeTimestamp(encoded: string): number {
|
||||
const raw = b64urlDecode(encoded);
|
||||
let ts = 0;
|
||||
@@ -159,6 +224,7 @@ function verifyCsrfTokenWithParams(
|
||||
cfg: CsrfConfig,
|
||||
salt: string,
|
||||
digest: CsrfDigest,
|
||||
keyDerivation: CsrfKeyDerivation,
|
||||
token: string,
|
||||
): CsrfVerifyResult {
|
||||
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
||||
@@ -174,7 +240,7 @@ function verifyCsrfTokenWithParams(
|
||||
|
||||
const tsStr = payloadTs.slice(prevDot + 1);
|
||||
|
||||
const derived = deriveKey(cfg.secret, salt, digest);
|
||||
const derived = deriveSigningKey(cfg.secret, salt, digest, keyDerivation);
|
||||
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest();
|
||||
|
||||
let actualSig: Buffer;
|
||||
@@ -232,53 +298,79 @@ function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfD
|
||||
return order;
|
||||
}
|
||||
|
||||
function derivationsToTry(primary: CsrfVerifyResult): CsrfKeyDerivation[] {
|
||||
const order: CsrfKeyDerivation[] = [...KEY_DERIVATIONS];
|
||||
if (primary.actualSigLen === 20) {
|
||||
// Prod auth: itsdangerous 2.x + sha1 → django-concat первым.
|
||||
return ['django-concat', ...order.filter((d) => d !== 'django-concat')];
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function isRetryableVerifyFailure(reason?: string): boolean {
|
||||
return reason === 'Signature length mismatch' || reason === 'Signature mismatch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify: Vault salt+digest first, then legacy matrix (same secret_key from Vault, read-only).
|
||||
* Verify: django-concat (itsdangerous 2.x) + legacy matrix (salt × digest × key_derivation).
|
||||
*/
|
||||
function inferSigLenFromToken(token: string): number | undefined {
|
||||
const lastDot = token.lastIndexOf('.');
|
||||
if (lastDot < 0) return undefined;
|
||||
try {
|
||||
return b64urlDecode(token.slice(lastDot + 1)).length;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
|
||||
const vaultSalt = current.salt;
|
||||
const vaultDigest = current.digest as CsrfDigest;
|
||||
const salts = saltsToTry(vaultSalt);
|
||||
const sigProbe: CsrfVerifyResult = { valid: false, actualSigLen: inferSigLenFromToken(token) };
|
||||
const derivations = derivationsToTry(sigProbe);
|
||||
|
||||
const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token);
|
||||
if (primary.valid) return primary;
|
||||
|
||||
if (!isRetryableVerifyFailure(primary.reason)) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
const digests = digestsToTry(primary, vaultDigest);
|
||||
let lastMismatch: CsrfVerifyResult = primary;
|
||||
let lastMismatch: CsrfVerifyResult = { valid: false, reason: 'Signature mismatch' };
|
||||
|
||||
for (const keyDerivation of derivations) {
|
||||
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}" ` +
|
||||
`(config digest=${vaultDigest} salt="${vaultSalt}"). ` +
|
||||
'Align auth signing with Vault read config.',
|
||||
for (const digest of digestsToTry(sigProbe.actualSigLen !== undefined ? sigProbe : lastMismatch, vaultDigest)) {
|
||||
const attempt = verifyCsrfTokenWithParams(
|
||||
current,
|
||||
salt,
|
||||
digest,
|
||||
keyDerivation,
|
||||
token,
|
||||
);
|
||||
if (attempt.valid) {
|
||||
const isPrimary =
|
||||
keyDerivation === 'django-concat' &&
|
||||
salt === vaultSalt &&
|
||||
digest === vaultDigest;
|
||||
if (!isPrimary) {
|
||||
logger.warn(
|
||||
`CSRF verified with fallback key_derivation=${keyDerivation} digest=${digest} salt="${salt}" ` +
|
||||
`(config digest=${vaultDigest} salt="${vaultSalt}"). Align auth with Vault metadata when possible.`,
|
||||
);
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') {
|
||||
if (isRetryableVerifyFailure(attempt.reason)) {
|
||||
lastMismatch = attempt;
|
||||
} else if (attempt.reason && attempt.reason !== 'Signature mismatch') {
|
||||
return attempt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Signature mismatch (all digest/salt fallbacks failed)',
|
||||
actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen,
|
||||
expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen,
|
||||
reason: 'Signature mismatch (all digest/salt/key_derivation fallbacks failed)',
|
||||
actualSigLen: lastMismatch.actualSigLen,
|
||||
expectedSigLen: lastMismatch.expectedSigLen,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user