getMessage()]; } } function expectCode(callable $fn, string $code): string { try { $fn(); } catch (CryptoError | SignatureError $e) { if ($e->errorCode === $code) { return "lanzó {$code}"; } throw new \RuntimeException("se esperaba {$code}, lanzó {$e->errorCode}"); } throw new \RuntimeException("se esperaba {$code}, no lanzó"); } $d = [Codec::class, 'b64uDecode']; $e = [Codec::class, 'b64uEncode']; $cid = $V['conversation']['conversationId']; $key = $d($V['conversation']['key']); $kp = fn (string $who) => ['publicKey' => $d($V['keys'][$who]['publicKey']), 'privateKey' => $d($V['keys'][$who]['privateKey'])]; // 1. Derivación determinista X25519 foreach (['user', 'company', 'recoveryDevice'] as $who) { check($results, "seed-keypair/{$who}", function () use ($V, $who, $d, $e) { $k = Envelope::identityKeyPairFromSeed($d($V['keys'][$who]['seed'])); if ($e($k['publicKey']) !== $V['keys'][$who]['publicKey']) throw new \RuntimeException('publicKey distinta'); if ($e($k['privateKey']) !== $V['keys'][$who]['privateKey']) throw new \RuntimeException('privateKey distinta'); }); } // 2. Mensajes: AD, relleno, ciphertext exacto y descifrado foreach ($V['messages'] as $m) { check($results, "message/{$m['name']}", function () use ($m, $cid, $key, $d) { $ad = Envelope::messageAD($cid, $m['senderType'], $m['clientMessageId'], $m['keyVersion']); if ($ad !== $m['ad']) throw new \RuntimeException("AD distinto: {$ad}"); $env = Envelope::encryptMessage($key, $cid, $m['senderType'], $m['clientMessageId'], $m['keyVersion'], $m['text'], $d($m['nonce'])); if ($env['ciphertext'] !== $m['ciphertext']) throw new \RuntimeException('ciphertext distinto al del vector'); if (strlen($d($env['ciphertext'])) !== $m['paddedLength'] + 16) throw new \RuntimeException('longitud con relleno inesperada'); if (Envelope::paddedLength(0) !== 256) throw new \RuntimeException('bloque de relleno inesperado'); $text = Envelope::decryptMessage($key, $cid, $m['senderType'], $m['envelope']); if ($text !== $m['text']) throw new \RuntimeException('texto descifrado distinto'); }); } // 3. Mensajes que DEBEN fallar foreach ($V['negativeMessages'] as $n) { check($results, "negative/{$n['name']}", fn () => expectCode( fn () => Envelope::decryptMessage(isset($n['key']) ? $d($n['key']) : $key, $n['conversationId'], $n['senderType'], $n['envelope']), $n['expectError'], )); } // 4. Envolturas foreach ($V['wraps'] as $w) { check($results, "wrap/{$w['name']}", function () use ($w, $kp, $key) { $k = $kp($w['recipient']); $run = fn () => Envelope::openConversationKey($w['wrapped'], $w['expectConversationId'], $k['publicKey'], $k['privateKey']); if (isset($w['expectError'])) return expectCode($run, $w['expectError']); if (!hash_equals($key, $run())) throw new \RuntimeException('K distinta'); return 'K correcta'; }); } // 5. Firma de plataforma sobre la llave de la empresa $ck = $V['companyKey']; $platformPk = $d($ck['platformPublicKey']); check($results, 'company-key/valid', fn () => Envelope::verifyCompanyKey($ck['companyId'], $ck['keyVersion'], $d($ck['publicKey']), $ck['signature'], $platformPk) ? 'firma válida' : ''); foreach ($ck['negative'] as $neg) { check($results, "company-key/{$neg['name']}", fn () => expectCode( fn () => Envelope::verifyCompanyKey($neg['companyId'], $neg['keyVersion'], $d($neg['publicKey']), $neg['signature'], $platformPk), 'SIGNATURE_INVALID', )); } // 5b. Rotación firmada por la empresa $cr = $V['companyRotation']; $rot = fn (array $x) => Envelope::verifyCompanyRotation($x['signature'], $d($cr['signingPublicKey']), $x['companyId'], $x['fromKeyVersion'], $d($x['fromPublicKey']), $x['toKeyVersion'], $d($x['toPublicKey'])); check($results, 'company-rotation/valid', fn () => $rot($cr) ? 'firma válida' : ''); foreach ($cr['negative'] as $neg) { check($results, "company-rotation/{$neg['name']}", fn () => expectCode(fn () => $rot($neg), $neg['expectError'])); } // 5b'. Lista de agentes firmada por la empresa (misma llave de firma que la rotación) $ar = $V['agentRoster']; $arPk = $d($ar['signingPublicKey']); $arSk = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($d($cr['signingSeed']))); $arAgents = fn (array $x) => array_map(fn ($a) => ['agentKeyId' => $a['agentKeyId'], 'publicKey' => $d($a['publicKey'])], $x['agents']); foreach ($ar['valid'] as $c) { check($results, "agent-roster/{$c['name']}", function () use ($c, $arPk, $arSk, $arAgents) { $stmt = Envelope::agentRosterStatement($c['companyId'], $c['rosterVersion'], $c['issuedAt'], $arAgents($c)); if ($stmt !== $c['statement']) throw new \RuntimeException("enunciado distinto: {$stmt}"); if (Envelope::signAgentRoster($arSk, $c['companyId'], $c['rosterVersion'], $c['issuedAt'], $arAgents($c)) !== $c['signature']) { throw new \RuntimeException('firma distinta a la del vector'); } return Envelope::verifyAgentRoster($c['signature'], $arPk, $c['companyId'], $c['rosterVersion'], $c['issuedAt'], $arAgents($c)) ? 'firma válida' : ''; }); } foreach ($ar['negative'] as $neg) { check($results, "agent-roster/{$neg['name']}", fn () => expectCode( fn () => Envelope::verifyAgentRoster($neg['signature'], $arPk, $neg['companyId'], $neg['rosterVersion'], $neg['issuedAt'], $arAgents($neg)), $neg['expectError'], )); } foreach ($V['agentWraps'] as $w) { check($results, "agent-wrap/{$w['name']}", function () use ($w, $ar, $d, $key) { $k = $ar['agentKeys'][$w['recipient']]; $run = fn () => Envelope::openConversationKey($w['wrapped'], $w['expectConversationId'], $d($k['publicKey']), $d($k['privateKey'])); if (isset($w['expectError'])) return expectCode($run, $w['expectError']); if (!hash_equals($key, $run())) throw new \RuntimeException('K distinta'); return 'K correcta'; }); } // 5c. Adjuntos $att = $V['attachments']; $pattern = function (int $n): string { $b = ''; for ($i = 0; $i < $n; $i++) $b .= chr(($i * 31 + 7) & 255); return $b; }; $aKey = $d($att['key']); $aNonce = $d($att['nonceBase']); $jsonEq = fn (array $a, array $b) => json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) === json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); foreach ($att['cases'] as $c) { check($results, "attachment/{$c['name']}", function () use ($c, $pattern, $aKey, $aNonce, $jsonEq) { $plain = $pattern($c['size']); $r = Envelope::encryptAttachment($plain, $c['attachmentId'], $c['meta']['name'], $c['meta']['mime'], null, $aKey, $aNonce); if (strlen($r['ciphertext']) !== $c['ciphertextLength']) throw new \RuntimeException('longitud distinta'); if (Envelope::attachmentDigest($r['ciphertext']) !== $c['ciphertextDigest']) throw new \RuntimeException('ciphertext distinto al del vector'); if (!$jsonEq($r['meta'], $c['meta'])) throw new \RuntimeException('meta distinta'); $back = Envelope::decryptAttachment($r['ciphertext'], Envelope::parseAttachmentMessage(Envelope::attachmentMessageText($c['meta']))); if ($back !== $plain) throw new \RuntimeException('descifrado distinto'); }); } $big = null; foreach ($att['cases'] as $c) if ($c['name'] === 'three-chunks') $big = $c; $ct = Envelope::encryptAttachment($pattern($big['size']), $big['attachmentId'], $big['meta']['name'], $big['meta']['mime'], null, $aKey, $aNonce)['ciphertext']; $CH = 65536 + 16; $tamper = [ 'flipped-byte' => function () use ($ct, $CH, $big) { $t = $ct; $t[$CH + 5] = chr(ord($t[$CH + 5]) ^ 1); return [$t, $big['meta']]; }, 'truncated-last-chunk' => fn () => [substr($ct, 0, 2 * $CH), ['size' => 2 * 65536] + $big['meta']], 'swapped-chunks' => fn () => [substr($ct, $CH, $CH) . substr($ct, 0, $CH) . substr($ct, 2 * $CH), $big['meta']], 'other-attachmentId' => fn () => [$ct, ['attachmentId' => 'att_vectorOTHER'] + $big['meta']], 'wrong-digest' => fn () => [$ct, ['digest' => $att['cases'][0]['meta']['digest']] + $big['meta'] + $att['cases'][0]['meta']], 'wrong-size' => fn () => [$ct, ['size' => $big['size'] - 1] + $big['meta']], ]; foreach ($tamper as $name => $make) { check($results, "attachment-negative/{$name}", function () use ($make) { [$t, $meta] = $make(); return expectCode(fn () => Envelope::decryptAttachment($t, $meta), 'DECRYPT_FAILED'); }); } check($results, 'attachment-negative/meta-with-slash-in-name', fn () => expectCode( fn () => Envelope::parseAttachmentMessage(json_encode(['name' => '../x'] + $big['meta'])), 'INVALID_INPUT', )); // 6. Huella check($results, 'fingerprint/company', function () use ($ck, $d, $e) { $fp = $e(Envelope::keyFingerprint($d($ck['publicKey']))); if ($fp !== $ck['fingerprint']) throw new \RuntimeException("huella distinta: {$fp}"); }); // 7. Vinculación con el secreto de la invitación $ib = $V['inviteBinding']; check($results, 'invite-binding/tag', function () use ($ib, $d) { $tag = Envelope::inviteBindingTag($d($ib['inviteSecret']), $ib['conversationId'], $d($ib['userPublicKey']), $ib['wrapForCompany']); if ($tag !== $ib['tag']) throw new \RuntimeException('tag distinto'); return Envelope::verifyInviteBinding($tag, $d($ib['inviteSecret']), $ib['conversationId'], $d($ib['userPublicKey']), $ib['wrapForCompany']) ? 'verifica' : ''; }); check($results, 'invite-binding/substituted-user-key', fn () => expectCode( fn () => Envelope::verifyInviteBinding($ib['tag'], $d($ib['inviteSecret']), $ib['conversationId'], $d($V['keys']['recoveryDevice']['publicKey']), $ib['wrapForCompany']), 'BINDING_INVALID', )); // Firmas HMAC (fixtures/signing.json, generado con sdk-node) $sig = []; foreach ($S['webhook']['cases'] as $c) { check($sig, "webhook/{$c['name']}", function () use ($c, $S) { $run = fn () => Webhook::verify($c['rawBody'], $c['header'], $S['secret'], $S['webhook']['toleranceSeconds'], $S['webhook']['now']); if ($c['expectError'] !== null) return expectCode($run, $c['expectError']); $ev = $run(); if (($ev['eventId'] ?? null) !== 'evt_01JG7X9QK2M3N4P5R6S7T8V9W0') throw new \RuntimeException('evento distinto'); return 'firma válida'; }); } foreach ($S['requests'] as $r) { check($sig, "request/{$r['name']}", function () use ($r, $S) { $h = Webhook::signRequest($r['method'], $r['pathAndQuery'], $r['rawBody'], $S['secret'], $r['timestamp']); if ($h !== $r['expectedHeader']) throw new \RuntimeException("cabecera distinta: {$h}"); }); } $report = function (string $label, array $rs): bool { $failed = array_filter($rs, fn ($r) => !$r[1]); foreach ($rs as [$name, $pass, $detail]) { printf(" %s %s%s\n", $pass ? '✔' : '✘', $name, $detail !== '' ? " · {$detail}" : ''); } printf("%s: %d/%d\n\n", $label, count($rs) - count($failed), count($rs)); return !$failed; }; printf("PHP %s · libsodium %s\n\n", PHP_VERSION, SODIUM_LIBRARY_VERSION); $ok = $report('v1.json', $results); $ok = $report('signing.json', $sig) && $ok; exit($ok ? 0 : 1);