162 lines
5.1 KiB
JavaScript
162 lines
5.1 KiB
JavaScript
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import {fileURLToPath} from 'url';
|
|
import {ecKeyPair} from './config.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ECDH_FILE = path.join(__dirname, '..', 'ecdh-keypair.json');
|
|
|
|
// ─── ECDH ───
|
|
|
|
const vtokenSuite = 'OCRA-1:HOTP-SHA256-6:QH64-T1M';
|
|
|
|
// ─── AES-256-GCM encryption for vtokenSecret ───
|
|
|
|
if (!process.env.TOKEN_SECRET_KEY) {
|
|
console.error('FATAL: TOKEN_SECRET_KEY environment variable is not set.');
|
|
console.error('Generate one with: echo "TOKEN_SECRET_KEY=$(openssl rand -hex 32)" > .env');
|
|
process.exit(1);
|
|
}
|
|
const ENCRYPTION_KEY = Buffer.from(process.env.TOKEN_SECRET_KEY, 'hex');
|
|
|
|
export const encryptSecret = (secretBuffer) => {
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
|
const encrypted = Buffer.concat([cipher.update(secretBuffer), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
|
};
|
|
|
|
export const decryptSecret = (tokenB64) => {
|
|
const buf = Buffer.from(tokenB64, 'base64');
|
|
const iv = buf.subarray(0, 12);
|
|
const tag = buf.subarray(12, 28);
|
|
const encrypted = buf.subarray(28);
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
};
|
|
|
|
let lastEcdhKeyPair = null;
|
|
|
|
export const generateECDH = () => {
|
|
lastEcdhKeyPair = crypto.generateKeyPairSync('ec', {namedCurve: 'prime256v1'});
|
|
// Persist ECDH private key so refresh (SignInLite) can reuse it
|
|
const saved = {
|
|
privateKey: lastEcdhKeyPair.privateKey.export({type: 'pkcs8', format: 'der'}).toString('base64'),
|
|
publicKey: lastEcdhKeyPair.publicKey.export({type: 'spki', format: 'der'}).toString('base64'),
|
|
};
|
|
fs.writeFileSync(ECDH_FILE, JSON.stringify(saved, null, 2));
|
|
const spki = lastEcdhKeyPair.publicKey.export({type: 'spki', format: 'der'});
|
|
return spki.toString('base64');
|
|
};
|
|
|
|
export const completeECDH = (serverX509B64) => {
|
|
if (!lastEcdhKeyPair) throw new Error('No ECDH keypair generated');
|
|
const serverPubKey = crypto.createPublicKey({
|
|
key: Buffer.from(serverX509B64, 'base64'),
|
|
format: 'der',
|
|
type: 'spki',
|
|
});
|
|
const secret = crypto.diffieHellman({
|
|
privateKey: lastEcdhKeyPair.privateKey,
|
|
publicKey: serverPubKey,
|
|
});
|
|
console.log('ECDH shared secret derived, length:', secret.length);
|
|
lastEcdhKeyPair = null;
|
|
return secret;
|
|
};
|
|
|
|
export const completeECDHWithSaved = (serverX509B64) => {
|
|
if (!fs.existsSync(ECDH_FILE)) throw new Error('No saved ECDH keypair (ecdh-keypair.json missing)');
|
|
const saved = JSON.parse(fs.readFileSync(ECDH_FILE, 'utf8'));
|
|
const privateKey = crypto.createPrivateKey({
|
|
key: Buffer.from(saved.privateKey, 'base64'),
|
|
format: 'der',
|
|
type: 'pkcs8',
|
|
});
|
|
const serverPubKey = crypto.createPublicKey({
|
|
key: Buffer.from(serverX509B64, 'base64'),
|
|
format: 'der',
|
|
type: 'spki',
|
|
});
|
|
const secret = crypto.diffieHellman({privateKey, publicKey: serverPubKey});
|
|
console.log('ECDH (saved key) shared secret derived, length:', secret.length);
|
|
return secret;
|
|
};
|
|
|
|
// ─── Helpers ───
|
|
|
|
const hexToBytes = (hex) => {
|
|
const bytes = [];
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes.push(parseInt(hex.substring(i, i + 2), 16));
|
|
}
|
|
return Buffer.from(bytes);
|
|
};
|
|
|
|
// ─── OCRA-1 TOTP (matches Kaspi vtoken) ───
|
|
|
|
export const computeTokenSnMac = (tokenSN, secret) => {
|
|
if (!secret) return '000000';
|
|
|
|
const timeStep = BigInt(Date.now()) / BigInt(30000);
|
|
const timeHex = timeStep.toString(16);
|
|
|
|
const qHex = Buffer.from(tokenSN || '00000000')
|
|
.toString('hex')
|
|
.substring(0, 64);
|
|
|
|
const suiteBytes = Buffer.from(vtokenSuite);
|
|
const separator = Buffer.from([0x00]);
|
|
|
|
const qPadded = qHex.padEnd(256, '0');
|
|
const qBytes = hexToBytes(qPadded);
|
|
|
|
const tPadded = timeHex.padStart(16, '0');
|
|
const tBytes = hexToBytes(tPadded);
|
|
|
|
const dataBuffer = Buffer.concat([suiteBytes, separator, qBytes, tBytes]);
|
|
|
|
const hash = crypto.createHmac('sha256', secret).update(dataBuffer).digest();
|
|
|
|
// Dynamic truncation (RFC 4226)
|
|
const offset = hash[hash.length - 1] & 0x0f;
|
|
const binCode =
|
|
((hash[offset] & 0x7f) << 24) |
|
|
((hash[offset + 1] & 0xff) << 16) |
|
|
((hash[offset + 2] & 0xff) << 8) |
|
|
(hash[offset + 3] & 0xff);
|
|
|
|
return (binCode % 1000000).toString().padStart(6, '0');
|
|
};
|
|
|
|
// ─── ECDSA signing ───
|
|
|
|
export const ecSign = (data) => {
|
|
const sign = crypto.createSign('SHA256');
|
|
sign.update(data);
|
|
sign.end();
|
|
return sign.sign(ecKeyPair.privateKey).toString('base64');
|
|
};
|
|
|
|
export const signDataPayload = (dataB64) => ecSign(dataB64);
|
|
|
|
export const computeXSU = (url) => crypto.createHash('md5').update(url.toLowerCase()).digest('hex');
|
|
|
|
export const computeXSign = (url, headers, xshList) => {
|
|
const parts = xshList.split(',').map((name) => {
|
|
if (name === 'url') {
|
|
try {
|
|
const u = new URL(url);
|
|
return u.pathname + u.search;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
return headers[name] || '';
|
|
});
|
|
return ecSign(parts.join(''));
|
|
};
|