initluyhgulednj
This commit is contained in:
@@ -8,12 +8,33 @@ import { logger } from '../lib/logger';
|
|||||||
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
||||||
*
|
*
|
||||||
* Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии).
|
* 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 ITSDANGEROUS_EPOCH = 1293840000;
|
||||||
|
|
||||||
const DEFAULT_SALT = 'itsdangerous.Signer';
|
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 {
|
export interface CsrfConfig {
|
||||||
secret: string;
|
secret: string;
|
||||||
@@ -70,7 +91,7 @@ export function logCsrfConfigLoaded(): void {
|
|||||||
`CSRF config loaded: mount=${summary.mount} path=${summary.path} ` +
|
`CSRF config loaded: mount=${summary.mount} path=${summary.path} ` +
|
||||||
`salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` +
|
`salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` +
|
||||||
`salt_source=${summary.saltSource} digest_source=${summary.digestSource} ` +
|
`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') {
|
if (summary.saltSource === 'default') {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -125,10 +146,54 @@ function b64urlDecode(s: string): Buffer {
|
|||||||
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
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();
|
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 {
|
function decodeTimestamp(encoded: string): number {
|
||||||
const raw = b64urlDecode(encoded);
|
const raw = b64urlDecode(encoded);
|
||||||
let ts = 0;
|
let ts = 0;
|
||||||
@@ -159,6 +224,7 @@ function verifyCsrfTokenWithParams(
|
|||||||
cfg: CsrfConfig,
|
cfg: CsrfConfig,
|
||||||
salt: string,
|
salt: string,
|
||||||
digest: CsrfDigest,
|
digest: CsrfDigest,
|
||||||
|
keyDerivation: CsrfKeyDerivation,
|
||||||
token: string,
|
token: string,
|
||||||
): CsrfVerifyResult {
|
): CsrfVerifyResult {
|
||||||
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
||||||
@@ -174,7 +240,7 @@ function verifyCsrfTokenWithParams(
|
|||||||
|
|
||||||
const tsStr = payloadTs.slice(prevDot + 1);
|
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();
|
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest();
|
||||||
|
|
||||||
let actualSig: Buffer;
|
let actualSig: Buffer;
|
||||||
@@ -232,53 +298,79 @@ function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfD
|
|||||||
return order;
|
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 {
|
function isRetryableVerifyFailure(reason?: string): boolean {
|
||||||
return reason === 'Signature length mismatch' || reason === 'Signature mismatch';
|
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 {
|
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||||
|
|
||||||
const vaultSalt = current.salt;
|
const vaultSalt = current.salt;
|
||||||
const vaultDigest = current.digest as CsrfDigest;
|
const vaultDigest = current.digest as CsrfDigest;
|
||||||
const salts = saltsToTry(vaultSalt);
|
const salts = saltsToTry(vaultSalt);
|
||||||
|
const sigProbe: CsrfVerifyResult = { valid: false, actualSigLen: inferSigLenFromToken(token) };
|
||||||
|
const derivations = derivationsToTry(sigProbe);
|
||||||
|
|
||||||
const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token);
|
let lastMismatch: CsrfVerifyResult = { valid: false, reason: 'Signature mismatch' };
|
||||||
if (primary.valid) return primary;
|
|
||||||
|
|
||||||
if (!isRetryableVerifyFailure(primary.reason)) {
|
|
||||||
return primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
const digests = digestsToTry(primary, vaultDigest);
|
|
||||||
let lastMismatch: CsrfVerifyResult = primary;
|
|
||||||
|
|
||||||
|
for (const keyDerivation of derivations) {
|
||||||
for (const salt of salts) {
|
for (const salt of salts) {
|
||||||
for (const digest of digests) {
|
for (const digest of digestsToTry(sigProbe.actualSigLen !== undefined ? sigProbe : lastMismatch, vaultDigest)) {
|
||||||
if (salt === vaultSalt && digest === vaultDigest) continue;
|
const attempt = verifyCsrfTokenWithParams(
|
||||||
|
current,
|
||||||
const attempt = verifyCsrfTokenWithParams(current, salt, digest, token);
|
salt,
|
||||||
if (attempt.valid) {
|
digest,
|
||||||
logger.warn(
|
keyDerivation,
|
||||||
`CSRF verified with fallback digest=${digest} salt="${salt}" ` +
|
token,
|
||||||
`(config digest=${vaultDigest} salt="${vaultSalt}"). ` +
|
|
||||||
'Align auth signing with Vault read config.',
|
|
||||||
);
|
);
|
||||||
|
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 };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') {
|
if (isRetryableVerifyFailure(attempt.reason)) {
|
||||||
lastMismatch = attempt;
|
lastMismatch = attempt;
|
||||||
|
} else if (attempt.reason && attempt.reason !== 'Signature mismatch') {
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
reason: 'Signature mismatch (all digest/salt fallbacks failed)',
|
reason: 'Signature mismatch (all digest/salt/key_derivation fallbacks failed)',
|
||||||
actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen,
|
actualSigLen: lastMismatch.actualSigLen,
|
||||||
expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen,
|
expectedSigLen: lastMismatch.expectedSigLen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user