t2-mapper/relay/auth.ts
2026-03-09 12:38:40 -07:00

385 lines
11 KiB
TypeScript

import crypto from "node:crypto";
import { authLog } from "./logger.js";
/**
* T2csri authentication — reimplements the TribesNext challenge-response
* flow in TypeScript. The relay acts as the client side.
*
* Flow (after Torque-level ConnectAccept):
* 1. Server sends: t2csri_pokeClient(version)
* 2. Client sends: certificate in 200-byte chunks via t2csri_sendCertChunk
* 3. Client sends: t2csri_sendChallenge(clientChallenge)
* 4. Server sends: encrypted challenge chunks via t2csri_getChallengeChunk
* 5. Server sends: t2csri_decryptChallenge
* 6. Client decrypts, verifies, sends: t2csri_challengeResponse(serverChallenge)
*/
export interface AccountCredentials {
/** Tab-separated: username\tguid\te\tn\tsig */
certificate: string;
/** Hex-encoded RSA private exponent (after RC4 decryption). */
privateKey: string;
}
/**
* Decrypt the RC4-encrypted private key using the account password.
*
* The stored format is `nonce:encryptedHex` where:
* - nonce = SHA1 of the plaintext private key (used for verification)
* - encryptedHex = hex-encoded RC4-encrypted private key bytes
* - RC4 key = SHA1(password + nonce), with 2048 bytes discarded from stream
*
* Based on t2csri_decryptAccountKey in clientSide.cs.
*/
export function decryptAccountKey(
encryptedKeyBase64: string,
_username: string,
password: string,
): string {
// Decode the base64 to get the "nonce:encryptedHex" string
const stored = Buffer.from(encryptedKeyBase64, "base64").toString("ascii");
const colonIdx = stored.indexOf(":");
if (colonIdx === -1) {
throw new Error("Invalid encrypted key format: missing colon separator");
}
const nonce = stored.slice(0, colonIdx);
const encryptedHex = stored.slice(colonIdx + 1);
// RC4 key = SHA1(password + nonce) as ASCII hex string (not hex-decoded)
// T2csri uses strCmp(char, "") to get ASCII values, so the key is the
// raw 40-char hex string, not the 20-byte decoded value.
const rc4Key = sha1(password + nonce);
// Hex-decode the encrypted data
const encryptedBytes = Buffer.from(encryptedHex, "hex");
// RC4 decrypt with 2048-byte stream discard
const decrypted = rc4WithDiscard(
Buffer.from(rc4Key, "ascii"),
encryptedBytes,
2048,
);
// Result is the hex-encoded private key
const privateKeyHex = decrypted.toString("hex");
// Verify against nonce (nonce = SHA1 of plaintext hex)
const hash = sha1(privateKeyHex);
if (hash === nonce) {
return privateKeyHex;
}
// T2csri tries fixing the last nibble/byte if hash doesn't match
const truncated = privateKeyHex.slice(0, -2);
for (let i = 0; i < 16; i++) {
const candidate = truncated + i.toString(16);
if (sha1(candidate) === nonce) return candidate;
}
for (let i = 0; i < 256; i++) {
const candidate = truncated + i.toString(16).padStart(2, "0");
if (sha1(candidate) === nonce) return candidate;
}
authLog.warn("Private key hash verification failed (password may be wrong)");
return privateKeyHex;
}
/** Load credentials from environment variables. */
export function loadCredentials(): AccountCredentials | null {
const certificate = process.env.T2_ACCOUNT_CERTIFICATE;
const encryptedKey = process.env.T2_ACCOUNT_ENCRYPTED_KEY;
const username = process.env.T2_ACCOUNT_NAME;
const password = process.env.T2_ACCOUNT_PASSWORD;
if (!certificate) {
authLog.warn("T2_ACCOUNT_CERTIFICATE not set");
return null;
}
let privateKey: string;
if (encryptedKey && username && password) {
privateKey = decryptAccountKey(encryptedKey, username, password);
} else {
authLog.warn(
"T2_ACCOUNT_ENCRYPTED_KEY / T2_ACCOUNT_NAME / T2_ACCOUNT_PASSWORD not fully set",
);
return null;
}
const cert = Buffer.from(certificate, "base64").toString("ascii");
return { certificate: cert, privateKey };
}
/**
* Generate a random hex challenge string.
* Mirrors T2csri's `rand(18446744073709551615).to_s(16)` — a random
* 64-bit integer converted to hex WITHOUT leading zeros. This is critical:
* the challenge round-trips through BigInt→hex conversions (`.to_i(16)` /
* `.to_s(16)`) during RSA encryption/decryption, which strip leading zeros.
* If we generated "06ab..." it would decrypt as "6ab..." and fail to match.
*/
export function generateChallenge(): string {
const bytes = crypto.randomBytes(8);
const num = BigInt("0x" + bytes.toString("hex"));
return num.toString(16); // no leading zeros, matches Ruby .to_s(16)
}
/** Get the hex representation of an IPv4 address (e.g. "192.168.1.1" -> "c0a80101"). */
export function ipToHex(ip: string): string {
return ip
.split(".")
.map((octet) => parseInt(octet, 10).toString(16).padStart(2, "0"))
.join("");
}
/**
* Build the client challenge string: random_challenge + server_ip_hex.
*/
export function buildClientChallenge(serverIp: string): {
fullChallenge: string;
randomPart: string;
} {
const randomPart = generateChallenge();
const ipHex = ipToHex(serverIp);
return { fullChallenge: randomPart + ipHex, randomPart };
}
/**
* RSA modular exponentiation: base^exp mod modulus.
* All values are hex strings.
*/
export function rsaModExp(
baseHex: string,
expHex: string,
modHex: string,
): string {
const base = BigInt("0x" + baseHex);
const exp = BigInt("0x" + expHex);
const mod = BigInt("0x" + modHex);
const result = modPow(base, exp, mod);
// No padding — matches T2csri's Ruby `.to_s(16)` which strips leading zeros.
// Both server and client use unpadded hex throughout the challenge flow.
return result.toString(16);
}
/** Efficient modular exponentiation using square-and-multiply. */
function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
if (mod === 1n) return 0n;
let result = 1n;
base = ((base % mod) + mod) % mod;
while (exp > 0n) {
if (exp & 1n) {
result = (result * base) % mod;
}
exp >>= 1n;
base = (base * base) % mod;
}
return result;
}
/**
* Decrypt the server's encrypted challenge using our private key.
* encrypted = challenge^e mod n (server encrypted with our public key)
* decrypted = encrypted^d mod n (we decrypt with private key)
*/
export function decryptChallenge(
encryptedHex: string,
privateKeyHex: string,
modulusHex: string,
): string {
return rsaModExp(encryptedHex, privateKeyHex, modulusHex);
}
/**
* Process the decrypted challenge from the server.
* Returns the server challenge portion if valid.
*/
export function processDecryptedChallenge(
decryptedHex: string,
originalClientChallenge: string,
): { valid: boolean; serverChallenge: string } {
// The decrypted value should be: clientChallenge + serverChallenge
const clientPart = decryptedHex.slice(0, originalClientChallenge.length);
if (clientPart.toLowerCase() !== originalClientChallenge.toLowerCase()) {
return { valid: false, serverChallenge: "" };
}
const serverChallenge = decryptedHex.slice(originalClientChallenge.length);
return { valid: true, serverChallenge };
}
/** SHA1 hash of a string, returned as hex. */
function sha1(data: string): string {
return crypto.createHash("sha1").update(data).digest("hex");
}
/** RC4 encrypt/decrypt with optional stream discard (drop-N). */
function rc4WithDiscard(
key: Buffer,
data: Buffer,
discardBytes: number = 0,
): Buffer {
// Initialize S-box
const S = new Uint8Array(256);
for (let i = 0; i < 256; i++) S[i] = i;
let j = 0;
for (let i = 0; i < 256; i++) {
j = (j + S[i] + key[i % key.length]) & 0xff;
[S[i], S[j]] = [S[j], S[i]];
}
let si = 0;
j = 0;
// Discard initial bytes from the keystream
for (let k = 0; k < discardBytes; k++) {
si = (si + 1) & 0xff;
j = (j + S[si]) & 0xff;
[S[si], S[j]] = [S[j], S[si]];
}
// Generate keystream and XOR
const result = Buffer.alloc(data.length);
for (let k = 0; k < data.length; k++) {
si = (si + 1) & 0xff;
j = (j + S[si]) & 0xff;
[S[si], S[j]] = [S[j], S[si]];
result[k] = data[k] ^ S[(S[si] + S[j]) & 0xff];
}
return result;
}
/**
* T2csri authentication state machine.
* Manages the challenge-response flow over an established connection.
*/
export class T2csriAuth {
private credentials: AccountCredentials;
private clientChallenge = "";
private encryptedChallenge = "";
private _authenticated = false;
constructor(credentials: AccountCredentials) {
this.credentials = credentials;
}
get authenticated(): boolean {
return this._authenticated;
}
/**
* Handle t2csri_pokeClient from server.
* Returns commands to send back (cert chunks + challenge).
*/
onPokeClient(
_version: string,
serverIp: string,
): { commands: Array<{ name: string; args: string[] }> } {
const commands: Array<{ name: string; args: string[] }> = [];
// Send certificate in 200-byte chunks
const cert = this.credentials.certificate;
for (let i = 0; i < cert.length; i += 200) {
commands.push({
name: "t2csri_sendCertChunk",
args: [cert.substring(i, i + 200)],
});
}
// Generate and send client challenge
const { fullChallenge } = buildClientChallenge(serverIp);
this.clientChallenge = fullChallenge;
commands.push({
name: "t2csri_sendChallenge",
args: [fullChallenge],
});
return { commands };
}
/** Handle t2csri_getChallengeChunk from server. */
onChallengeChunk(chunk: string): void {
this.encryptedChallenge += chunk;
}
/**
* Handle t2csri_decryptChallenge from server.
* Returns the challenge response command to send, or null on failure.
*/
onDecryptChallenge(): {
command: { name: string; args: string[] };
} | null {
// Sanitize: must be hex only
const challenge = this.encryptedChallenge.toLowerCase();
authLog.info(
{ challengeLen: challenge.length, clientChallengeLen: this.clientChallenge.length },
"Auth: starting challenge decryption",
);
for (let i = 0; i < challenge.length; i++) {
const c = challenge.charCodeAt(i);
const isHex =
(c >= 48 && c <= 57) || // 0-9
(c >= 97 && c <= 102); // a-f
if (!isHex) {
authLog.error(
{ charCode: c, pos: i, char: challenge[i] },
"Invalid characters in server challenge",
);
return null;
}
}
// Parse certificate to get modulus (n)
const fields = this.credentials.certificate.split("\t");
const modulusHex = fields[3];
authLog.debug(
{ encryptedLen: challenge.length, modulusLen: modulusHex?.length, privateKeyLen: this.credentials.privateKey.length },
"Auth: RSA parameters",
);
// Decrypt using private key
const decrypted = decryptChallenge(
challenge,
this.credentials.privateKey,
modulusHex,
);
authLog.debug(
{
decryptedLen: decrypted.length,
decryptedPrefix: decrypted.slice(0, 40),
clientChallenge: this.clientChallenge,
},
"Auth: decryption result",
);
// Verify client challenge is intact
const result = processDecryptedChallenge(decrypted, this.clientChallenge);
if (!result.valid) {
authLog.error(
{
decryptedPrefix: decrypted.slice(0, 40),
expectedPrefix: this.clientChallenge,
},
"Server sent back wrong client challenge",
);
return null;
}
void result.serverChallenge; // verified
this._authenticated = true;
return {
command: {
name: "t2csri_challengeResponse",
args: [result.serverChallenge],
},
};
}
}