/** * SecureChat · sobre criptográfico v1 ("sc1") * * Código compartido entre la app (react-native-libsodium) y el SDK de Node * (libsodium-wrappers). No importa ninguna librería: cada función recibe la * instancia de `sodium` como primer argumento. Así el mismo fichero se ejecuta * en Hermes y en Node, y los vectores de prueba comprueban exactamente el mismo * código en los dos lados. * * Construcción (ver docs/ADR.md, ADR-022): * * Identidad X25519 (crypto_box_keypair) * Clave de conversación 32 bytes aleatorios, generada por la APP al canjear * Envoltura crypto_box_seal(K ‖ contexto) para cada extremo * Mensaje XChaCha20-Poly1305 IETF, nonce aleatorio de 24 bytes, * AD = conversationId, senderType, clientMessageId, keyVersion * Relleno ISO/IEC 7816-4 hasta múltiplos de 256 bytes * Firma de plataforma Ed25519 sobre la llave pública de la empresa * Lista de agentes Ed25519 de la empresa sobre las llaves de su bandeja web (ADR-038) * Huella BLAKE2b-256 con separación de dominio * * Reglas que este fichero hace cumplir: * - Todo fallo lanza CryptoError con `code`. Nunca se devuelve el ciphertext * ni una cadena vacía como si fuera texto (lección de asesor-financiero). * - El AD es una cadena ASCII: react-native-libsodium 1.7.0 solo acepta * `additional_data` como string y lo codifica en UTF-8. Restringir los * campos a [A-Za-z0-9_-] hace que los bytes sean idénticos en ambos lados * y que el separador '|' no pueda aparecer dentro de un campo. */ export const ENVELOPE_VERSION = 1; export const SCHEME = 'sc1'; export const PAD_BLOCK = 256; export const MAX_PLAINTEXT_BYTES = 64 * 1024; export const KEY_BYTES = 32; export const NONCE_BYTES = 24; export const PUBLIC_KEY_BYTES = 32; export const SENDER_TYPES = Object.freeze(['user', 'company']); const DOMAIN = 'SecureChat|v1'; const WRAP_MAGIC = [0x53, 0x43, 0x4b, 0x31]; // "SCK1" const ID_RE = /^[A-Za-z0-9_-]{1,128}$/; // ─── Errores ──────────────────────────────────────────────────────────────── export class CryptoError extends Error { constructor(code, message) { super(`${code}: ${message}`); this.name = 'CryptoError'; this.code = code; } } export const ErrorCodes = Object.freeze({ INVALID_INPUT: 'INVALID_INPUT', UNSUPPORTED_VERSION: 'UNSUPPORTED_VERSION', DECRYPT_FAILED: 'DECRYPT_FAILED', KEY_MISMATCH: 'KEY_MISMATCH', BAD_PADDING: 'BAD_PADDING', SIGNATURE_INVALID: 'SIGNATURE_INVALID', BINDING_INVALID: 'BINDING_INVALID', CRYPTO_UNAVAILABLE: 'CRYPTO_UNAVAILABLE', }); function fail(code, message) { throw new CryptoError(code, message); } // ─── Codecs propios (sin depender de TextEncoder/Buffer/atob) ─────────────── export function utf8Encode(str) { if (typeof str !== 'string') fail(ErrorCodes.INVALID_INPUT, 'utf8Encode espera string'); const out = []; for (let i = 0; i < str.length; i++) { let cp = str.charCodeAt(i); if (cp >= 0xd800 && cp <= 0xdbff) { const next = str.charCodeAt(i + 1); if (!(next >= 0xdc00 && next <= 0xdfff)) fail(ErrorCodes.INVALID_INPUT, 'surrogate sin pareja'); cp = 0x10000 + ((cp - 0xd800) << 10) + (next - 0xdc00); i++; } else if (cp >= 0xdc00 && cp <= 0xdfff) { fail(ErrorCodes.INVALID_INPUT, 'surrogate sin pareja'); } if (cp < 0x80) out.push(cp); else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); else if (cp < 0x10000) out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); else out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); } return Uint8Array.from(out); } /** Decodificador UTF-8 estricto: rechaza secuencias inválidas, overlongs y surrogates. */ export function utf8Decode(bytes) { assertBytes(bytes, 'utf8Decode'); let out = ''; let i = 0; while (i < bytes.length) { const b0 = bytes[i]; let cp; let need; let min; if (b0 < 0x80) { cp = b0; need = 0; min = 0; } else if (b0 >= 0xc2 && b0 <= 0xdf) { cp = b0 & 0x1f; need = 1; min = 0x80; } else if (b0 >= 0xe0 && b0 <= 0xef) { cp = b0 & 0x0f; need = 2; min = 0x800; } else if (b0 >= 0xf0 && b0 <= 0xf4) { cp = b0 & 0x07; need = 3; min = 0x10000; } else fail(ErrorCodes.INVALID_INPUT, 'UTF-8 inválido'); // Un byte de continuación ausente (secuencia truncada) llega como undefined. for (let k = 1; k <= need; k++) { const b = bytes[i + k]; if (b === undefined || (b & 0xc0) !== 0x80) fail(ErrorCodes.INVALID_INPUT, 'UTF-8 inválido'); cp = (cp << 6) | (b & 0x3f); } if (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) fail(ErrorCodes.INVALID_INPUT, 'UTF-8 inválido'); if (cp >= 0x10000) { const v = cp - 0x10000; out += String.fromCharCode(0xd800 + (v >> 10), 0xdc00 + (v & 0x3ff)); } else { out += String.fromCharCode(cp); } i += need + 1; } return out; } const B64U = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; const B64U_REV = (() => { const t = new Int16Array(128).fill(-1); for (let i = 0; i < B64U.length; i++) t[B64U.charCodeAt(i)] = i; return t; })(); /** base64url sin relleno (RFC 4648 §5), la variante por defecto de libsodium. */ export function b64uEncode(bytes) { assertBytes(bytes, 'b64uEncode'); let out = ''; let i = 0; for (; i + 2 < bytes.length; i += 3) { const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; out += B64U[(n >> 18) & 63] + B64U[(n >> 12) & 63] + B64U[(n >> 6) & 63] + B64U[n & 63]; } const rest = bytes.length - i; if (rest === 1) { const n = bytes[i] << 16; out += B64U[(n >> 18) & 63] + B64U[(n >> 12) & 63]; } else if (rest === 2) { const n = (bytes[i] << 16) | (bytes[i + 1] << 8); out += B64U[(n >> 18) & 63] + B64U[(n >> 12) & 63] + B64U[(n >> 6) & 63]; } return out; } /** Decodificador estricto: rechaza caracteres fuera del alfabeto, relleno y bits sobrantes. */ export function b64uDecode(str) { if (typeof str !== 'string') fail(ErrorCodes.INVALID_INPUT, 'b64uDecode espera string'); if (str.length % 4 === 1) fail(ErrorCodes.INVALID_INPUT, 'longitud base64url inválida'); const vals = new Array(str.length); for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); const v = c < 128 ? B64U_REV[c] : -1; if (v < 0) fail(ErrorCodes.INVALID_INPUT, 'carácter base64url inválido'); vals[i] = v; } const outLen = Math.floor((str.length * 3) / 4); const out = new Uint8Array(outLen); let o = 0; let i = 0; for (; i + 3 < str.length; i += 4) { const n = (vals[i] << 18) | (vals[i + 1] << 12) | (vals[i + 2] << 6) | vals[i + 3]; out[o++] = (n >> 16) & 255; out[o++] = (n >> 8) & 255; out[o++] = n & 255; } const rest = str.length - i; if (rest === 2) { if (vals[i + 1] & 0x0f) fail(ErrorCodes.INVALID_INPUT, 'bits sobrantes en base64url'); out[o++] = ((vals[i] << 2) | (vals[i + 1] >> 4)) & 255; } else if (rest === 3) { if (vals[i + 2] & 0x03) fail(ErrorCodes.INVALID_INPUT, 'bits sobrantes en base64url'); const n = (vals[i] << 18) | (vals[i + 1] << 12) | (vals[i + 2] << 6); out[o++] = (n >> 16) & 255; out[o++] = (n >> 8) & 255; } return out; } export function hexEncode(bytes) { assertBytes(bytes, 'hexEncode'); let out = ''; for (let i = 0; i < bytes.length; i++) out += (bytes[i] < 16 ? '0' : '') + bytes[i].toString(16); return out; } export function hexDecode(str) { if (typeof str !== 'string' || str.length % 2 !== 0 || !/^[0-9a-f]*$/.test(str)) { fail(ErrorCodes.INVALID_INPUT, 'hex inválido'); } const out = new Uint8Array(str.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(str.substr(i * 2, 2), 16); return out; } export function concatBytes(...parts) { let len = 0; for (const p of parts) len += p.length; const out = new Uint8Array(len); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; } /** Comparación sin salida temprana. En JS es "mejor esfuerzo", no una garantía. */ export function constantTimeEqual(a, b) { assertBytes(a, 'constantTimeEqual'); assertBytes(b, 'constantTimeEqual'); if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; } // ─── Validación ──────────────────────────────────────────────────────────── function assertBytes(value, what, length) { if (!(value instanceof Uint8Array)) fail(ErrorCodes.INVALID_INPUT, `${what}: se esperaba Uint8Array`); if (length !== undefined && value.length !== length) { fail(ErrorCodes.INVALID_INPUT, `${what}: se esperaban ${length} bytes, hay ${value.length}`); } } function assertId(value, what) { if (typeof value !== 'string' || !ID_RE.test(value)) { fail(ErrorCodes.INVALID_INPUT, `${what} debe cumplir ${ID_RE}`); } } function assertSenderType(value) { if (!SENDER_TYPES.includes(value)) fail(ErrorCodes.INVALID_INPUT, `senderType inválido: ${value}`); } function assertKeyVersion(value) { if (!Number.isInteger(value) || value < 1 || value > 0x7fffffff) { fail(ErrorCodes.INVALID_INPUT, 'keyVersion debe ser un entero entre 1 y 2^31-1'); } } // ─── Relleno ISO/IEC 7816-4 ──────────────────────────────────────────────── export function paddedLength(plainLength, block = PAD_BLOCK) { return Math.ceil((plainLength + 1) / block) * block; } export function pad(bytes, block = PAD_BLOCK) { assertBytes(bytes, 'pad'); const out = new Uint8Array(paddedLength(bytes.length, block)); out.set(bytes, 0); out[bytes.length] = 0x80; return out; } export function unpad(bytes, block = PAD_BLOCK) { assertBytes(bytes, 'unpad'); if (bytes.length === 0 || bytes.length % block !== 0) fail(ErrorCodes.BAD_PADDING, 'longitud no múltiplo del bloque'); let i = bytes.length - 1; while (i >= 0 && bytes[i] === 0x00) i--; if (i < 0 || bytes[i] !== 0x80) fail(ErrorCodes.BAD_PADDING, 'marcador 0x80 ausente'); if (bytes.length - i > block) fail(ErrorCodes.BAD_PADDING, 'relleno más largo que un bloque'); return bytes.slice(0, i); } // ─── Datos asociados (AD) ────────────────────────────────────────────────── /** * Cadena canónica que liga el ciphertext a su contexto. Si el servidor cambia * la conversación, invierte el remitente, reutiliza el mensaje con otro * clientMessageId o lo etiqueta con otra versión de clave, el descifrado falla. */ export function messageAD({ conversationId, senderType, clientMessageId, keyVersion }) { assertId(conversationId, 'conversationId'); assertSenderType(senderType); assertId(clientMessageId, 'clientMessageId'); assertKeyVersion(keyVersion); return `${DOMAIN}|msg|${conversationId}|${senderType}|${clientMessageId}|${keyVersion}`; } // ─── Llaves ──────────────────────────────────────────────────────────────── export function generateIdentityKeyPair(sodium) { const kp = sodium.crypto_box_keypair(); return { publicKey: kp.publicKey, privateKey: kp.privateKey }; } export function identityKeyPairFromSeed(sodium, seed) { assertBytes(seed, 'seed', 32); const kp = sodium.crypto_box_seed_keypair(seed); return { publicKey: kp.publicKey, privateKey: kp.privateKey }; } export function generateConversationKey(sodium) { return sodium.randombytes_buf(KEY_BYTES); } // ─── Envoltura de la clave de conversación ───────────────────────────────── function wrapPayload(key, conversationId, keyVersion) { const kv = new Uint8Array([ (keyVersion >>> 24) & 255, (keyVersion >>> 16) & 255, (keyVersion >>> 8) & 255, keyVersion & 255, ]); return concatBytes(Uint8Array.from(WRAP_MAGIC), kv, key, utf8Encode(conversationId)); } /** Sella K para un destinatario. El contexto va dentro para que no se pueda mover a otra conversación. */ export function wrapConversationKey(sodium, { key, conversationId, keyVersion, recipientPublicKey }) { assertBytes(key, 'key', KEY_BYTES); assertId(conversationId, 'conversationId'); assertKeyVersion(keyVersion); assertBytes(recipientPublicKey, 'recipientPublicKey', PUBLIC_KEY_BYTES); const sealed = sodium.crypto_box_seal(wrapPayload(key, conversationId, keyVersion), recipientPublicKey); return { keyVersion, sealed: b64uEncode(sealed) }; } export function openConversationKey(sodium, { wrapped, conversationId, publicKey, privateKey }) { assertId(conversationId, 'conversationId'); assertBytes(publicKey, 'publicKey', PUBLIC_KEY_BYTES); assertBytes(privateKey, 'privateKey', 32); if (!wrapped || typeof wrapped.sealed !== 'string') fail(ErrorCodes.INVALID_INPUT, 'wrapped.sealed ausente'); assertKeyVersion(wrapped.keyVersion); const sealed = b64uDecode(wrapped.sealed); let payload; try { payload = sodium.crypto_box_seal_open(sealed, publicKey, privateKey); } catch (e) { fail(ErrorCodes.DECRYPT_FAILED, 'no se pudo abrir la clave de conversación'); } if (!(payload instanceof Uint8Array) || payload.length < 4 + 4 + KEY_BYTES + 1) { fail(ErrorCodes.KEY_MISMATCH, 'envoltura con formato inválido'); } for (let i = 0; i < 4; i++) { if (payload[i] !== WRAP_MAGIC[i]) fail(ErrorCodes.KEY_MISMATCH, 'envoltura con formato inválido'); } const kv = ((payload[4] << 24) | (payload[5] << 16) | (payload[6] << 8) | payload[7]) >>> 0; if (kv !== wrapped.keyVersion) fail(ErrorCodes.KEY_MISMATCH, 'keyVersion de la envoltura no coincide'); const cid = utf8Decode(payload.slice(8 + KEY_BYTES)); if (cid !== conversationId) fail(ErrorCodes.KEY_MISMATCH, 'la clave pertenece a otra conversación'); return payload.slice(8, 8 + KEY_BYTES); } /** * Canje de invitación en la app: genera K y la sella para la empresa y para el * propio usuario. Nosotros solo transportamos las dos envolturas. */ export function createConversationKeys(sodium, { conversationId, companyPublicKey, userPublicKey, keyVersion = 1 }) { const key = generateConversationKey(sodium); return { key, wrapForCompany: wrapConversationKey(sodium, { key, conversationId, keyVersion, recipientPublicKey: companyPublicKey }), wrapForUser: wrapConversationKey(sodium, { key, conversationId, keyVersion, recipientPublicKey: userPublicKey }), }; } // ─── Mensajes ────────────────────────────────────────────────────────────── /** * Cifra texto. `nonce` solo se inyecta desde los generadores de vectores; en * uso normal se omite y sale de randombytes_buf. */ export function encryptMessage(sodium, { key, conversationId, senderType, clientMessageId, keyVersion, text, nonce }) { assertBytes(key, 'key', KEY_BYTES); const plain = utf8Encode(text); if (plain.length > MAX_PLAINTEXT_BYTES) fail(ErrorCodes.INVALID_INPUT, 'mensaje demasiado largo'); const ad = messageAD({ conversationId, senderType, clientMessageId, keyVersion }); const n = nonce === undefined ? sodium.randombytes_buf(NONCE_BYTES) : nonce; assertBytes(n, 'nonce', NONCE_BYTES); const ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(pad(plain), ad, null, n, key); return { v: ENVELOPE_VERSION, scheme: SCHEME, keyVersion, clientMessageId, nonce: b64uEncode(n), ciphertext: b64uEncode(ct), }; } /** * Descifra. `conversationId` y `senderType` los pone el RECEPTOR según lo que * espera, no los toma del sobre: por eso el servidor no puede reetiquetarlos. */ export function decryptMessage(sodium, { key, conversationId, senderType, envelope }) { assertBytes(key, 'key', KEY_BYTES); if (!envelope || typeof envelope !== 'object') fail(ErrorCodes.INVALID_INPUT, 'sobre ausente'); if (envelope.v !== ENVELOPE_VERSION || envelope.scheme !== SCHEME) { fail(ErrorCodes.UNSUPPORTED_VERSION, `sobre v=${envelope.v} scheme=${envelope.scheme}`); } const ad = messageAD({ conversationId, senderType, clientMessageId: envelope.clientMessageId, keyVersion: envelope.keyVersion, }); const nonce = b64uDecode(envelope.nonce); assertBytes(nonce, 'nonce', NONCE_BYTES); const ct = b64uDecode(envelope.ciphertext); if (ct.length < 16 + PAD_BLOCK || (ct.length - 16) % PAD_BLOCK !== 0) { fail(ErrorCodes.DECRYPT_FAILED, 'longitud de ciphertext inválida'); } let padded; try { padded = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ct, ad, nonce, key); } catch (e) { fail(ErrorCodes.DECRYPT_FAILED, 'autenticación fallida'); } if (!(padded instanceof Uint8Array)) fail(ErrorCodes.DECRYPT_FAILED, 'resultado inesperado'); return utf8Decode(unpad(padded)); } // ─── Identidad de empresa: firma de plataforma ───────────────────────────── export function companyKeyStatement({ companyId, keyVersion, publicKey }) { assertId(companyId, 'companyId'); assertKeyVersion(keyVersion); assertBytes(publicKey, 'publicKey', PUBLIC_KEY_BYTES); return utf8Encode(`${DOMAIN}|company-key|${companyId}|${keyVersion}|${b64uEncode(publicKey)}`); } /** Solo del lado de la plataforma (Node). */ export function signCompanyKey(sodium, { companyId, keyVersion, publicKey, platformSigningKey }) { const sig = sodium.crypto_sign_detached(companyKeyStatement({ companyId, keyVersion, publicKey }), platformSigningKey); return b64uEncode(sig); } /** En la app, con la llave de plataforma fijada en el binario. Lanza si no verifica. */ export function verifyCompanyKey(sodium, { companyId, keyVersion, publicKey, signature, platformPublicKey }) { assertBytes(platformPublicKey, 'platformPublicKey', 32); const sig = b64uDecode(signature); assertBytes(sig, 'signature', 64); const ok = sodium.crypto_sign_verify_detached(sig, companyKeyStatement({ companyId, keyVersion, publicKey }), platformPublicKey); if (ok !== true) fail(ErrorCodes.SIGNATURE_INVALID, 'la llave de la empresa no está firmada por la plataforma'); return true; } // ─── Rotación firmada por la propia empresa (ADR-028) ───────────────────── // // La empresa tiene además un par Ed25519 propio (`signingPublicKey`), generado con // `keygen` en su infraestructura. Al rotar su llave X25519 firma el paso // anterior → nuevo. La app fija `signingPublicKey` en el primer contacto: una rotación // firmada por esa llave es de la empresa, no nuestra, y se presenta como aviso suave. // Sin esa firma (o con otra llave de firma) el aviso sigue siendo prominente. export function companyRotationStatement({ companyId, fromKeyVersion, fromPublicKey, toKeyVersion, toPublicKey }) { assertId(companyId, 'companyId'); assertKeyVersion(fromKeyVersion); assertKeyVersion(toKeyVersion); if (toKeyVersion <= fromKeyVersion) fail(ErrorCodes.INVALID_INPUT, 'toKeyVersion debe ser mayor que fromKeyVersion'); assertBytes(fromPublicKey, 'fromPublicKey', PUBLIC_KEY_BYTES); assertBytes(toPublicKey, 'toPublicKey', PUBLIC_KEY_BYTES); return utf8Encode( `${DOMAIN}|company-key-rotation|${companyId}|${fromKeyVersion}|${b64uEncode(fromPublicKey)}|${toKeyVersion}|${b64uEncode(toPublicKey)}`, ); } /** Lado empresa (SDK). `signingPrivateKey` es la secreta Ed25519 de 64 bytes. */ export function signCompanyRotation(sodium, { signingPrivateKey, ...statement }) { assertBytes(signingPrivateKey, 'signingPrivateKey', 64); return b64uEncode(sodium.crypto_sign_detached(companyRotationStatement(statement), signingPrivateKey)); } /** Lado app y backend. Lanza SIGNATURE_INVALID si no la firmó esa llave de la empresa. */ export function verifyCompanyRotation(sodium, { signature, signingPublicKey, ...statement }) { assertBytes(signingPublicKey, 'signingPublicKey', 32); const sig = b64uDecode(signature); assertBytes(sig, 'signature', 64); const ok = sodium.crypto_sign_verify_detached(sig, companyRotationStatement(statement), signingPublicKey); if (ok !== true) fail(ErrorCodes.SIGNATURE_INVALID, 'la rotación no está firmada por la llave de firma de la empresa'); return true; } // ─── Lista de agentes firmada por la empresa (ADR-038) ──────────────────── // // Cada persona que responde desde la bandeja web tiene su propio par X25519 (generado en // su navegador). La app solo sella K para las llaves de una lista firmada con la llave de // firma Ed25519 de la empresa (la misma de ADR-028), así el servidor no puede colar una // llave suya como "agente". Enunciado canónico (UTF-8): // // SecureChat|v1|agent-roster||||:,: // // Agentes ordenados por agentKeyId (orden de bytes; los ids son ASCII), lista vacía // permitida (el enunciado termina en '|'). `issuedAt` = YYYY-MM-DDTHH:MM:SSZ exacto. // Las envolturas para agentes son wrapConversationKey normales: ya ligan conversación y // keyVersion. export const AGENT_ROSTER_MAX_AGENTS = 1000; const AGENT_KEY_ID_RE = /^agk_[A-Za-z0-9_-]{8,64}$/; const ISSUED_AT_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/; function assertIssuedAt(value) { const m = typeof value === 'string' ? ISSUED_AT_RE.exec(value) : null; if (!m) fail(ErrorCodes.INVALID_INPUT, 'issuedAt debe ser YYYY-MM-DDTHH:MM:SSZ'); const [y, mo, d, h, mi, s] = m.slice(1).map((x) => parseInt(x, 10)); const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (mo < 1 || mo > 12 || d < 1 || d > days[mo - 1] || h > 23 || mi > 59 || s > 59) { fail(ErrorCodes.INVALID_INPUT, 'issuedAt no es una fecha válida'); } } function assertRosterVersion(value) { if (!Number.isInteger(value) || value < 1 || value > 0x7fffffff) { fail(ErrorCodes.INVALID_INPUT, 'rosterVersion debe ser un entero entre 1 y 2^31-1'); } } /** Valida y devuelve las entradas `agk:pk` ordenadas por agentKeyId. */ function canonicalAgents(agents) { if (!Array.isArray(agents)) fail(ErrorCodes.INVALID_INPUT, 'agents debe ser una lista'); if (agents.length > AGENT_ROSTER_MAX_AGENTS) fail(ErrorCodes.INVALID_INPUT, `como mucho ${AGENT_ROSTER_MAX_AGENTS} agentes`); const entries = agents.map((a) => { if (!a || typeof a !== 'object') fail(ErrorCodes.INVALID_INPUT, 'agente inválido'); if (typeof a.agentKeyId !== 'string' || !AGENT_KEY_ID_RE.test(a.agentKeyId)) { fail(ErrorCodes.INVALID_INPUT, `agentKeyId debe cumplir ${AGENT_KEY_ID_RE}`); } assertBytes(a.publicKey, 'agent.publicKey', PUBLIC_KEY_BYTES); return { id: a.agentKeyId, pk: b64uEncode(a.publicKey) }; }); // Comparación por unidades de código: con ids ASCII es el orden de bytes de cualquier lenguaje. entries.sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0)); const keys = new Set(); for (let i = 0; i < entries.length; i++) { if (i > 0 && entries[i].id === entries[i - 1].id) fail(ErrorCodes.INVALID_INPUT, 'agentKeyId repetido'); if (keys.has(entries[i].pk)) fail(ErrorCodes.INVALID_INPUT, 'llave de agente repetida'); keys.add(entries[i].pk); } return entries.map((e) => `${e.id}:${e.pk}`).join(','); } /** Enunciado canónico en UTF-8. `agents`: [{ agentKeyId, publicKey: Uint8Array(32) }] en cualquier orden. */ export function agentRosterStatement({ companyId, rosterVersion, issuedAt, agents }) { assertId(companyId, 'companyId'); assertRosterVersion(rosterVersion); assertIssuedAt(issuedAt); return utf8Encode(`${DOMAIN}|agent-roster|${companyId}|${rosterVersion}|${issuedAt}|${canonicalAgents(agents)}`); } /** Lado empresa (portal del owner o SDK). `signingPrivateKey`: secreta Ed25519 de 64 bytes (signing.key). */ export function signAgentRoster(sodium, { signingPrivateKey, ...statement }) { assertBytes(signingPrivateKey, 'signingPrivateKey', 64); return b64uEncode(sodium.crypto_sign_detached(agentRosterStatement(statement), signingPrivateKey)); } /** Lado app, portal y backend. Lanza SIGNATURE_INVALID (o INVALID_INPUT) si no verifica. */ export function verifyAgentRoster(sodium, { signature, signingPublicKey, ...statement }) { assertBytes(signingPublicKey, 'signingPublicKey', 32); const stmt = agentRosterStatement(statement); const sig = b64uDecode(signature); assertBytes(sig, 'signature', 64); const ok = sodium.crypto_sign_verify_detached(sig, stmt, signingPublicKey); if (ok !== true) fail(ErrorCodes.SIGNATURE_INVALID, 'la lista de agentes no está firmada por la llave de firma de la empresa'); return true; } // ─── Adjuntos (ADR-033) ──────────────────────────────────────────────────── // // Cada fichero se cifra con su propia clave F (32 bytes aleatorios) en trozos de 64 KiB // con XChaCha20-Poly1305: // nonce_i = nonceBase[0..16) ‖ u64be(i) (F es única por fichero: sin reutilización) // AD_i = SecureChat|v1|att|||<1 si es el último, 0 si no> // El AD liga cada trozo a su fichero, su posición y el final: reordenar, recortar o // añadir trozos falla. F, el nonce base, el nombre, el tipo, el tamaño y un BLAKE2b del // contenido viajan DENTRO de un mensaje normal (contentType `attachment`), así que el // servidor solo guarda bytes opacos y un attachmentId. // (react-native-libsodium no expone secretstream: esta es la construcción equivalente // con lo que sí tiene.) export const ATTACHMENT_CHUNK = 64 * 1024; export const ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024; const ATTACHMENT_ID_RE = /^att_[A-Za-z0-9_-]{8,64}$/; const MIME_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,127}$/; function assertAttachmentId(id) { if (typeof id !== 'string' || !ATTACHMENT_ID_RE.test(id)) fail(ErrorCodes.INVALID_INPUT, 'attachmentId inválido'); } function chunkNonce(base, i) { const n = base.slice(0, NONCE_BYTES); // u64 big-endian en los últimos 8 bytes (i < 2^32 de sobra: 25 MB / 64 KiB = 400) n[16] = 0; n[17] = 0; n[18] = 0; n[19] = 0; n[20] = (i >>> 24) & 255; n[21] = (i >>> 16) & 255; n[22] = (i >>> 8) & 255; n[23] = i & 255; return n; } function chunkAD(attachmentId, i, last) { return `${DOMAIN}|att|${attachmentId}|${i}|${last ? 1 : 0}`; } export function attachmentCiphertextLength(plainSize) { const chunks = Math.max(1, Math.ceil(plainSize / ATTACHMENT_CHUNK)); return plainSize + chunks * 16; } export function attachmentDigest(sodium, bytes) { assertBytes(bytes, 'bytes'); return b64uEncode(sodium.crypto_generichash(32, bytes, null)); } /** * Cifra un fichero. `key` y `nonceBase` solo se inyectan desde los vectores. * Devuelve { ciphertext, meta } donde `meta` va dentro del mensaje cifrado. */ export function encryptAttachment(sodium, { bytes, attachmentId, name, mime, key, nonceBase }) { assertBytes(bytes, 'bytes'); assertAttachmentId(attachmentId); if (bytes.length > ATTACHMENT_MAX_BYTES) fail(ErrorCodes.INVALID_INPUT, 'adjunto demasiado grande'); const k = key === undefined ? sodium.randombytes_buf(KEY_BYTES) : key; const nb = nonceBase === undefined ? sodium.randombytes_buf(NONCE_BYTES) : nonceBase; assertBytes(k, 'key', KEY_BYTES); assertBytes(nb, 'nonceBase', NONCE_BYTES); const chunks = Math.max(1, Math.ceil(bytes.length / ATTACHMENT_CHUNK)); const out = new Uint8Array(attachmentCiphertextLength(bytes.length)); let o = 0; for (let i = 0; i < chunks; i++) { const part = bytes.subarray(i * ATTACHMENT_CHUNK, Math.min(bytes.length, (i + 1) * ATTACHMENT_CHUNK)); // .slice(): algunas implementaciones no aceptan subarrays con offset const ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(part.slice(), chunkAD(attachmentId, i, i === chunks - 1), null, chunkNonce(nb, i), k); out.set(ct, o); o += ct.length; } const meta = { v: 1, kind: 'attachment', attachmentId, name, mime, size: bytes.length, key: b64uEncode(k), nonce: b64uEncode(nb), digest: attachmentDigest(sodium, bytes), chunkSize: ATTACHMENT_CHUNK, }; return { ciphertext: out, meta: validateAttachmentMeta(meta) }; } /** Descifra y comprueba tamaño y huella. Lanza DECRYPT_FAILED ante cualquier manipulación. */ export function decryptAttachment(sodium, { ciphertext, meta }) { assertBytes(ciphertext, 'ciphertext'); const m = validateAttachmentMeta(meta); const key = b64uDecode(m.key); const nb = b64uDecode(m.nonce); if (ciphertext.length !== attachmentCiphertextLength(m.size)) fail(ErrorCodes.DECRYPT_FAILED, 'longitud del adjunto inesperada'); const chunks = Math.max(1, Math.ceil(m.size / ATTACHMENT_CHUNK)); const out = new Uint8Array(m.size); let c = 0; for (let i = 0; i < chunks; i++) { const plainLen = Math.min(ATTACHMENT_CHUNK, m.size - i * ATTACHMENT_CHUNK); const part = ciphertext.slice(c, c + plainLen + 16); c += plainLen + 16; let pt; try { pt = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, part, chunkAD(m.attachmentId, i, i === chunks - 1), chunkNonce(nb, i), key); } catch (e) { fail(ErrorCodes.DECRYPT_FAILED, `trozo ${i} del adjunto no autentica`); } out.set(pt, i * ATTACHMENT_CHUNK); } if (attachmentDigest(sodium, out) !== m.digest) fail(ErrorCodes.DECRYPT_FAILED, 'la huella del adjunto no coincide'); return out; } /** Valida y normaliza la descripción del adjunto que viaja dentro del mensaje. */ export function validateAttachmentMeta(meta) { if (!meta || typeof meta !== 'object') fail(ErrorCodes.INVALID_INPUT, 'meta de adjunto ausente'); if (meta.v !== 1 || meta.kind !== 'attachment') fail(ErrorCodes.UNSUPPORTED_VERSION, 'meta de adjunto de versión desconocida'); assertAttachmentId(meta.attachmentId); if (typeof meta.name !== 'string' || !meta.name.trim() || meta.name.length > 255 || /[\u0000-\u001f/\\]/.test(meta.name)) { fail(ErrorCodes.INVALID_INPUT, 'nombre de adjunto inválido'); } if (typeof meta.mime !== 'string' || !MIME_RE.test(meta.mime)) fail(ErrorCodes.INVALID_INPUT, 'tipo de adjunto inválido'); if (!Number.isInteger(meta.size) || meta.size < 0 || meta.size > ATTACHMENT_MAX_BYTES) fail(ErrorCodes.INVALID_INPUT, 'tamaño de adjunto inválido'); if (meta.chunkSize !== ATTACHMENT_CHUNK) fail(ErrorCodes.UNSUPPORTED_VERSION, 'tamaño de trozo no soportado'); assertBytes(b64uDecode(meta.key), 'key', KEY_BYTES); assertBytes(b64uDecode(meta.nonce), 'nonce', NONCE_BYTES); assertBytes(b64uDecode(meta.digest), 'digest', 32); const out = { v: 1, kind: 'attachment', attachmentId: meta.attachmentId, name: meta.name, mime: meta.mime, size: meta.size, key: meta.key, nonce: meta.nonce, digest: meta.digest, chunkSize: meta.chunkSize, }; if (meta.caption !== undefined && meta.caption !== null) { if (typeof meta.caption !== 'string' || meta.caption.length > 4000) fail(ErrorCodes.INVALID_INPUT, 'pie de adjunto inválido'); out.caption = meta.caption; } if (meta.width !== undefined || meta.height !== undefined) { if (!Number.isInteger(meta.width) || !Number.isInteger(meta.height) || meta.width < 1 || meta.height < 1 || meta.width > 20000 || meta.height > 20000) { fail(ErrorCodes.INVALID_INPUT, 'dimensiones de adjunto inválidas'); } out.width = meta.width; out.height = meta.height; } return out; } /** Texto del mensaje `attachment` (se cifra con encryptMessage como cualquier otro). */ export function attachmentMessageText(meta) { return JSON.stringify(validateAttachmentMeta(meta)); } /** Inversa: parsea y valida. Lanza INVALID_INPUT si el texto no es una meta válida. */ export function parseAttachmentMessage(text) { let obj; try { obj = JSON.parse(text); } catch { fail(ErrorCodes.INVALID_INPUT, 'el mensaje de adjunto no es JSON'); } return validateAttachmentMeta(obj); } // ─── Huellas ─────────────────────────────────────────────────────────────── export function keyFingerprint(sodium, publicKey) { assertBytes(publicKey, 'publicKey', PUBLIC_KEY_BYTES); return sodium.crypto_generichash(32, concatBytes(utf8Encode(`${DOMAIN}|fp|`), publicKey), null); } /** Para mostrar: 16 grupos de 4 hex. */ export function formatFingerprint(fp) { return hexEncode(fp).match(/.{4}/g).join(' '); } export function verifyFingerprint(sodium, publicKey, expectedB64u) { const expected = b64uDecode(expectedB64u); if (!constantTimeEqual(keyFingerprint(sodium, publicKey), expected)) { fail(ErrorCodes.KEY_MISMATCH, 'la huella de la llave de la empresa no coincide con la del link'); } return true; } // ─── Vinculación con el secreto de la invitación ─────────────────────────── /** * El link que viaja por WhatsApp lleva en el fragmento (#) un secreto que * genera la empresa y que nunca llega a nuestro servidor. La app calcula este * MAC sobre su llave pública y la envoltura para la empresa; la empresa lo * verifica. Si nosotros sustituyéramos la llave del usuario, el MAC no cuadra. * Solo cubre el canje por link, no el código tecleado. */ export function inviteBindingTag(sodium, { inviteSecret, conversationId, userPublicKey, wrapForCompany }) { assertBytes(inviteSecret, 'inviteSecret', 32); assertId(conversationId, 'conversationId'); assertBytes(userPublicKey, 'userPublicKey', PUBLIC_KEY_BYTES); if (!wrapForCompany || typeof wrapForCompany.sealed !== 'string') fail(ErrorCodes.INVALID_INPUT, 'wrapForCompany ausente'); const msg = utf8Encode( `${DOMAIN}|invite-binding|${conversationId}|${b64uEncode(userPublicKey)}|${wrapForCompany.keyVersion}|${wrapForCompany.sealed}`, ); return b64uEncode(sodium.crypto_generichash(32, msg, inviteSecret)); } export function verifyInviteBinding(sodium, { tag, ...rest }) { const expected = b64uDecode(inviteBindingTag(sodium, rest)); if (typeof tag !== 'string' || !constantTimeEqual(expected, b64uDecode(tag))) { fail(ErrorCodes.BINDING_INVALID, 'la llave del usuario no está ligada al secreto de la invitación'); } return true; }