mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-13 17:30:56 +00:00
385 lines
11 KiB
TypeScript
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],
|
|
},
|
|
};
|
|
}
|
|
}
|