File size: 5,732 Bytes
88c4c60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | /**
* Qoder COSY (hybrid RSA+AES+MD5) signing, ported from CLIProxyAPIPlus
* qoder-provider branch (internal/auth/qoder/cosy.go).
*
* Every signed request carries:
* - an AES-128-CBC payload of the user info, the AES key wrapped in RSA
* - an MD5 signature over `payload || cosyKey || timestamp || body || sigPath`
* - the body's MD5 hash + length so the server can validate integrity
* - 17 Cosy-* / X-* headers fingerprinting the client (machine id, IDE
* version, organization id, etc.)
*
* The on-the-wire header keys use the same casing as qodercli:
* Cosy-Machineid, not Cosy-MachineID.
*/
import crypto from "crypto";
import { v4 as uuidv4 } from "uuid";
import {
QODER_CLIENT_TYPE,
QODER_DATA_POLICY,
QODER_IDE_VERSION,
QODER_LOGIN_VERSION,
QODER_MACHINE_OS,
QODER_MACHINE_TYPE,
QODER_RSA_PUBLIC_KEY,
} from "./constants.js";
// AES-128 wants a 16-byte key. Match qodercli/Veria: take the first 16 chars
// of a fresh UUID's canonical string (hyphens included). The key is fresh
// per request so even though the IV reuses the key bytes, each request still
// has a unique IV.
function generateAesKey() {
return uuidv4().slice(0, 16);
}
function pkcs7Pad(data, blockSize) {
const padding = blockSize - (data.length % blockSize);
const padded = Buffer.alloc(data.length + padding, padding);
data.copy(padded, 0);
return padded;
}
function aesEncryptCbcBase64(plaintext, keyStr) {
const keyBytes = Buffer.from(keyStr, "utf8");
if (keyBytes.length !== 16) {
throw new Error(`aes key must be 16 bytes, got ${keyBytes.length}`);
}
const iv = keyBytes.subarray(0, 16);
const cipher = crypto.createCipheriv("aes-128-cbc", keyBytes, iv);
cipher.setAutoPadding(false);
const padded = pkcs7Pad(Buffer.from(plaintext, "utf8"), 16);
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
return encrypted.toString("base64");
}
function rsaEncryptBase64(data) {
const encrypted = crypto.publicEncrypt(
{ key: QODER_RSA_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(data, "utf8"),
);
return encrypted.toString("base64");
}
function encryptUserInfo(userInfo) {
const aesKey = generateAesKey();
const plaintext = JSON.stringify(userInfo);
const infoB64 = aesEncryptCbcBase64(plaintext, aesKey);
const cosyKeyB64 = rsaEncryptBase64(aesKey);
return { cosyKey: cosyKeyB64, info: infoB64 };
}
function md5Hex(input) {
return crypto.createHash("md5").update(input).digest("hex");
}
/**
* Strip the leading "/algo" prefix from the request path. Matches qodercli
* convention. Empty input returns "".
*/
function computeSigPath(requestUrl) {
let pathname;
try {
pathname = new URL(requestUrl).pathname || "";
} catch {
return "";
}
if (pathname.startsWith("/algo")) {
return pathname.slice("/algo".length);
}
return pathname;
}
/**
* Generate a fresh machine UUID. Persisted on the connection record so
* every request from the same auth carries the same machineId.
*/
export function generateMachineId() {
return uuidv4();
}
/**
* Build the full Cosy-* header set for a single Qoder request.
*
* @param {Buffer|Uint8Array|string} body The exact bytes that will be sent.
* For GET requests pass an empty Buffer / "".
* @param {string} requestUrl Full request URL (used for sigPath).
* @param {object} creds
* @param {string} creds.userId Stable Qoder user id.
* @param {string} creds.authToken Device access token (`dt-...`).
* @param {string} [creds.name] Display name (optional).
* @param {string} [creds.email] Email (optional, can be empty).
* @param {string} [creds.machineId] Persisted machine UUID.
* @returns {Record<string, string>} Header map ready to merge onto fetch().
*/
export function buildCosyHeaders(body, requestUrl, creds) {
if (!creds?.userId) throw new Error("cosy: user id is empty");
if (!creds?.authToken) throw new Error("cosy: auth token is empty");
const bodyBuf = Buffer.isBuffer(body)
? body
: typeof body === "string"
? Buffer.from(body, "latin1")
: Buffer.from(body || []);
const { cosyKey, info } = encryptUserInfo({
uid: creds.userId,
security_oauth_token: creds.authToken,
name: creds.name || "",
aid: "",
email: creds.email || "",
});
const timestamp = String(Math.floor(Date.now() / 1000));
const requestId = uuidv4();
const payloadJson = JSON.stringify({
version: "v1",
requestId,
info,
cosyVersion: QODER_IDE_VERSION,
ideVersion: "",
});
const payloadB64 = Buffer.from(payloadJson, "utf8").toString("base64");
const sigPath = computeSigPath(requestUrl);
const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${bodyBuf.toString("latin1")}\n${sigPath}`;
const sig = md5Hex(Buffer.from(sigInput, "latin1"));
const machineId = creds.machineId || generateMachineId();
const bodyHash = md5Hex(bodyBuf);
const bodyLength = String(bodyBuf.length);
return {
Authorization: `Bearer COSY.${payloadB64}.${sig}`,
"Cosy-Key": cosyKey,
"Cosy-User": creds.userId,
"Cosy-Date": timestamp,
"Cosy-Version": QODER_IDE_VERSION,
"Cosy-Machineid": machineId,
"Cosy-Machinetoken": machineId,
"Cosy-Machinetype": QODER_MACHINE_TYPE,
"Cosy-Machineos": QODER_MACHINE_OS,
"Cosy-Clienttype": QODER_CLIENT_TYPE,
"Cosy-Clientip": "127.0.0.1",
"Cosy-Bodyhash": bodyHash,
"Cosy-Bodylength": bodyLength,
"Cosy-Sigpath": sigPath,
"Cosy-Data-Policy": QODER_DATA_POLICY,
"Cosy-Organization-Id": "",
"Cosy-Organization-Tags": "",
"Login-Version": QODER_LOGIN_VERSION,
"X-Request-Id": uuidv4(),
};
}
|