/** * Ejecuta los vectores compartidos (vectors/v1.json) contra una instancia de * sodium. Lo usan los tests de Node y la app, así que ambos lados comprueban * exactamente las mismas expectativas. * * Devuelve una lista de { name, pass, detail }. No lanza: un vector que falla * es un resultado, no una excepción, para que la app pueda mostrarlos todos. */ import { CryptoError, b64uDecode, b64uEncode, decryptMessage, encryptMessage, identityKeyPairFromSeed, inviteBindingTag, keyFingerprint, messageAD, openConversationKey, paddedLength, verifyCompanyKey, verifyCompanyRotation, encryptAttachment, decryptAttachment, attachmentDigest, parseAttachmentMessage, attachmentMessageText, verifyInviteBinding, agentRosterStatement, signAgentRoster, verifyAgentRoster, utf8Decode, } from './envelope.js'; function eqBytes(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function check(results, name, fn) { try { const detail = fn(); results.push({ name, pass: true, detail: detail || '' }); } catch (e) { results.push({ name, pass: false, detail: e && e.message ? e.message : String(e) }); } } function expectCode(fn, code) { try { fn(); } catch (e) { if (e instanceof CryptoError && e.code === code) return `lanzó ${code}`; throw new Error(`se esperaba ${code}, lanzó ${e && e.code ? e.code : e && e.message}`); } throw new Error(`se esperaba ${code}, no lanzó (¿devolvió algo en silencio?)`); } function keysFrom(V, who) { const k = V.keys[who]; return { publicKey: b64uDecode(k.publicKey), privateKey: b64uDecode(k.privateKey) }; } export function runVectors(sodium, V) { const results = []; const conversationId = V.conversation.conversationId; const key = b64uDecode(V.conversation.key); // 1. Derivación determinista de llaves X25519 for (const who of ['user', 'company', 'recoveryDevice']) { check(results, `seed-keypair/${who}`, () => { const kp = identityKeyPairFromSeed(sodium, b64uDecode(V.keys[who].seed)); if (b64uEncode(kp.publicKey) !== V.keys[who].publicKey) throw new Error('publicKey distinta'); if (b64uEncode(kp.privateKey) !== V.keys[who].privateKey) throw new Error('privateKey distinta'); }); } // 2. Mensajes: AD, relleno, ciphertext exacto y descifrado for (const m of V.messages) { check(results, `message/${m.name}`, () => { const ad = messageAD({ conversationId, senderType: m.senderType, clientMessageId: m.clientMessageId, keyVersion: m.keyVersion }); if (ad !== m.ad) throw new Error(`AD distinto: ${ad}`); const env = encryptMessage(sodium, { key, conversationId, senderType: m.senderType, clientMessageId: m.clientMessageId, keyVersion: m.keyVersion, text: m.text, nonce: b64uDecode(m.nonce), }); if (env.ciphertext !== m.ciphertext) throw new Error('ciphertext distinto al del vector'); if (b64uDecode(env.ciphertext).length !== m.paddedLength + 16) throw new Error('longitud con relleno inesperada'); if (paddedLength(0) !== 256) throw new Error('bloque de relleno inesperado'); const text = decryptMessage(sodium, { key, conversationId, senderType: m.senderType, envelope: m.envelope }); if (text !== m.text) throw new Error('texto descifrado distinto'); }); } // 3. Mensajes que DEBEN fallar for (const n of V.negativeMessages) { check(results, `negative/${n.name}`, () => expectCode(() => decryptMessage(sodium, { key: n.key ? b64uDecode(n.key) : key, conversationId: n.conversationId, senderType: n.senderType, envelope: n.envelope, }), n.expectError)); } // 4. Envolturas selladas en Node, abiertas acá for (const w of V.wraps) { check(results, `wrap/${w.name}`, () => { const kp = keysFrom(V, w.recipient); const run = () => openConversationKey(sodium, { wrapped: w.wrapped, conversationId: w.expectConversationId, ...kp }); if (w.expectError) return expectCode(run, w.expectError); const opened = run(); if (!eqBytes(opened, key)) throw new Error('K distinta'); return 'K correcta'; }); } // 5. Firma de plataforma sobre la llave de la empresa const ck = V.companyKey; const platformPublicKey = b64uDecode(ck.platformPublicKey); check(results, 'company-key/valid', () => verifyCompanyKey(sodium, { companyId: ck.companyId, keyVersion: ck.keyVersion, publicKey: b64uDecode(ck.publicKey), signature: ck.signature, platformPublicKey, }) && 'firma válida'); for (const neg of ck.negative) { check(results, `company-key/${neg.name}`, () => expectCode(() => verifyCompanyKey(sodium, { companyId: neg.companyId, keyVersion: neg.keyVersion, publicKey: b64uDecode(neg.publicKey), signature: neg.signature, platformPublicKey, }), 'SIGNATURE_INVALID')); } // 5b. Rotación firmada por la empresa (ADR-028). Opcional en vectores antiguos. const cr = V.companyRotation; if (cr) { const args = (x) => ({ companyId: x.companyId, fromKeyVersion: x.fromKeyVersion, fromPublicKey: b64uDecode(x.fromPublicKey), toKeyVersion: x.toKeyVersion, toPublicKey: b64uDecode(x.toPublicKey), signature: x.signature, signingPublicKey: b64uDecode(cr.signingPublicKey), }); check(results, 'company-rotation/valid', () => verifyCompanyRotation(sodium, args(cr)) && 'firma válida'); for (const neg of cr.negative) { check(results, `company-rotation/${neg.name}`, () => expectCode(() => verifyCompanyRotation(sodium, args(neg)), neg.expectError)); } } // 5b'. Lista de agentes firmada por la empresa (ADR-038). Opcional en vectores antiguos. const ar = V.agentRoster; if (ar) { const signingPublicKey = b64uDecode(ar.signingPublicKey); const fields = (x) => ({ companyId: x.companyId, rosterVersion: x.rosterVersion, issuedAt: x.issuedAt, agents: x.agents.map((a) => ({ agentKeyId: a.agentKeyId, publicKey: b64uDecode(a.publicKey) })), }); // La llave de firma de la empresa es la de companyRotation (misma seed). // Si la implementación no expone firma (solo verificación), se comprueba solo verificar. const canSign = typeof sodium.crypto_sign_seed_keypair === 'function' && typeof sodium.crypto_sign_detached === 'function'; const signing = cr && canSign ? sodium.crypto_sign_seed_keypair(b64uDecode(cr.signingSeed)) : null; for (const c of ar.valid) { check(results, `agent-roster/${c.name}`, () => { const stmt = utf8Decode(agentRosterStatement(fields(c))); if (stmt !== c.statement) throw new Error(`enunciado distinto: ${stmt}`); if (signing && signAgentRoster(sodium, { ...fields(c), signingPrivateKey: signing.privateKey }) !== c.signature) { throw new Error('firma distinta a la del vector'); } return verifyAgentRoster(sodium, { ...fields(c), signature: c.signature, signingPublicKey }) && 'firma válida'; }); } for (const neg of ar.negative) { check(results, `agent-roster/${neg.name}`, () => expectCode(() => verifyAgentRoster(sodium, { ...fields(neg), signature: neg.signature, signingPublicKey, }), neg.expectError)); } // Envolturas para agentes: solo las abre la llave de ese agente. for (const w of V.agentWraps || []) { check(results, `agent-wrap/${w.name}`, () => { const k = ar.agentKeys[w.recipient]; const kp = { publicKey: b64uDecode(k.publicKey), privateKey: b64uDecode(k.privateKey) }; const run = () => openConversationKey(sodium, { wrapped: w.wrapped, conversationId: w.expectConversationId, ...kp }); if (w.expectError) return expectCode(run, w.expectError); if (!eqBytes(run(), key)) throw new Error('K distinta'); return 'K correcta'; }); } } // 5c. Adjuntos (ADR-033). Opcional en vectores antiguos. const att = V.attachments; if (att) { const pattern = (n) => { const b = new Uint8Array(n); for (let i = 0; i < n; i++) b[i] = (i * 31 + 7) & 255; return b; }; const aKey = b64uDecode(att.key); const aNonce = b64uDecode(att.nonceBase); for (const c of att.cases) { check(results, `attachment/${c.name}`, () => { const plain = pattern(c.size); const { ciphertext, meta } = encryptAttachment(sodium, { bytes: plain, attachmentId: c.attachmentId, name: c.meta.name, mime: c.meta.mime, key: aKey, nonceBase: aNonce, }); if (ciphertext.length !== c.ciphertextLength) throw new Error('longitud distinta'); if (attachmentDigest(sodium, ciphertext) !== c.ciphertextDigest) throw new Error('ciphertext distinto al del vector'); if (JSON.stringify(meta) !== JSON.stringify(c.meta)) throw new Error('meta distinta'); const back = decryptAttachment(sodium, { ciphertext, meta: parseAttachmentMessage(attachmentMessageText(c.meta)) }); if (!eqBytes(back, plain)) throw new Error('descifrado distinto'); }); } // Manipulaciones sobre el caso de 3 trozos: todas deben fallar const big = att.cases.find((c) => c.name === 'three-chunks'); if (big) { const { ciphertext } = encryptAttachment(sodium, { bytes: pattern(big.size), attachmentId: big.attachmentId, name: big.meta.name, mime: big.meta.mime, key: aKey, nonceBase: aNonce, }); const CH = 65536 + 16; const tamper = { 'flipped-byte': () => { const t = ciphertext.slice(); t[CH + 5] ^= 1; return { ct: t, meta: big.meta }; }, 'truncated-last-chunk': () => ({ ct: ciphertext.slice(0, 2 * CH), meta: { ...big.meta, size: 2 * 65536 } }), 'swapped-chunks': () => { const t = ciphertext.slice(); t.set(ciphertext.slice(CH, 2 * CH), 0); t.set(ciphertext.slice(0, CH), CH); return { ct: t, meta: big.meta }; }, 'other-attachmentId': () => ({ ct: ciphertext, meta: { ...big.meta, attachmentId: 'att_vectorOTHER' } }), 'wrong-digest': () => ({ ct: ciphertext, meta: { ...att.cases[0].meta, ...big.meta, digest: att.cases[0].meta.digest } }), 'wrong-size': () => ({ ct: ciphertext, meta: { ...big.meta, size: big.size - 1 } }), }; for (const [name, make] of Object.entries(tamper)) { check(results, `attachment-negative/${name}`, () => { const { ct, meta } = make(); return expectCode(() => decryptAttachment(sodium, { ciphertext: ct, meta }), 'DECRYPT_FAILED'); }); } check(results, 'attachment-negative/meta-with-slash-in-name', () => expectCode(() => parseAttachmentMessage(JSON.stringify({ ...big.meta, name: '../x' })), 'INVALID_INPUT')); } } // 6. Huella check(results, 'fingerprint/company', () => { const fp = b64uEncode(keyFingerprint(sodium, b64uDecode(ck.publicKey))); if (fp !== ck.fingerprint) throw new Error(`huella distinta: ${fp}`); }); // 7. Vinculación con el secreto de la invitación const ib = V.inviteBinding; const ibArgs = { inviteSecret: b64uDecode(ib.inviteSecret), conversationId: ib.conversationId, userPublicKey: b64uDecode(ib.userPublicKey), wrapForCompany: ib.wrapForCompany, }; check(results, 'invite-binding/tag', () => { const tag = inviteBindingTag(sodium, ibArgs); if (tag !== ib.tag) throw new Error('tag distinto'); return verifyInviteBinding(sodium, { ...ibArgs, tag }) && 'verifica'; }); check(results, 'invite-binding/substituted-user-key', () => expectCode(() => verifyInviteBinding(sodium, { ...ibArgs, userPublicKey: b64uDecode(V.keys.recoveryDevice.publicKey), tag: ib.tag, }), 'BINDING_INVALID')); return results; } export function summarize(results) { const failed = results.filter((r) => !r.pass); return { total: results.length, passed: results.length - failed.length, failed: failed.length, failures: failed }; }