diff --git a/SECURITY_PR.md b/SECURITY_PR.md new file mode 100644 index 0000000..82c9f18 --- /dev/null +++ b/SECURITY_PR.md @@ -0,0 +1,49 @@ +# Security fix: enforce E2EE/TEE conversation-integrity client-side + +## Summary + +The E2EE/"TEE-attested" guarantee was not cryptographically enforced on the +client. A malicious or compromised server (the exact party the feature claims +to protect against) could: + +1. Have the client display attacker-authored content under the + `๐Ÿ” Response decrypted end-to-end` banner, because the streamed response + was **never verified against the attested enclave signing key**, and +2. Skip encryption entirely: the `isHexEncrypted()` gate meant a **plaintext** + chunk was printed verbatim while the CLI still asserted it was decrypted + end-to-end, and +3. Pass the broken `verifySignature()` helper for **any** content, because a + 64-hex `signedText` short-circuited to `verified: true` without checking + the hash committed to the response. + +## Root causes + +| # | File | Issue | +|---|------|-------| +| 1 | `src/lib/e2ee.ts` `verifySignature` | Hash-format `signedText` returned `verified: true` without comparing the hash to `sha256(expectedContent)`. | +| 2 | `src/commands/chat.ts` `streamChat` | E2EE responses were displayed/trusted without ever fetching/verifying the enclave signature; `fetchTeeSignature`/`verifySignature` were dead code. | +| 3 | `src/commands/chat.ts` `streamChat` | `isHexEncrypted()` gate let a plaintext chunk through; the "decrypted end-to-end" banner printed unconditionally. | + +## Fix + +**`src/lib/e2ee.ts`** โ€” replace the blanket hash short-circuit with real +verification: compute `sha256` of each expected-content variant and +constant-time-compare it to the signed single-hash / response-half of a +`request:response` hash pair. Mismatch โ‡’ `verified: false`. +*(File was UTF-16 โ€” opaque to `git diff`/SAST; re-encoded to UTF-8 so this +security change is reviewable. This is intentional; it is why `e2ee.ts` shows +as a binaryโ†’text change.)* + +**`src/commands/chat.ts`** โ€” in E2EE mode: +- reject any non-ciphertext chunk (`E2EE protocol violation`), +- **buffer** decrypted content instead of streaming it (do not show + unverified bytes), +- after the stream, `assertTeeResponseSignature()` fetches the TEE signature + and verifies it (via the now-fixed `verifySignature`) against the attested + signing address over the full response; failure aborts and nothing is + shown, +- the `๐Ÿ” Response decrypted end-to-end` banner is now reachable only after + successful verification. + +Nils Putnins / OffSeq Cybersecurity +npu@offseq.com / https://offseq.com / https://radar.offseq.com \ No newline at end of file diff --git a/src/commands/chat.ts b/src/commands/chat.ts index e41d543..b570f20 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -9,6 +9,7 @@ import { chatCompletionStream, listModels, fetchTeeAttestation, + fetchTeeSignature, } from '../lib/api.js'; import { getDefaultModel, @@ -34,6 +35,7 @@ import { encryptMessage, decryptChunk, isHexEncrypted, + verifySignature, zeroFill, } from '../lib/e2ee.js'; import { @@ -536,6 +538,57 @@ function flushThinkingState( return output; } +/** + * Verify a TEE-signed response against the attested signing address before + * the decrypted content is shown to or trusted by the user. + * + * Without this, a malicious or compromised server can stream arbitrary + * content (or, via the isHexEncrypted gate, plaintext) and the CLI would + * present it under the "decrypted end-to-end" banner. The attestation only + * proves which key signs; this closes the loop by proving the *content* was + * signed by that key. + */ +async function assertTeeResponseSignature( + model: string, + completionId: string | undefined, + e2eeContext: E2EEContext, + responseContent: string +): Promise { + const expectedSigningAddress = e2eeContext.signingAddress; + if (!expectedSigningAddress) { + throw new Error( + 'E2EE integrity check failed: attestation did not include a signing address to verify the response against.' + ); + } + if (!completionId) { + throw new Error( + 'E2EE integrity check failed: server did not return a completion id, so the response signature cannot be fetched.' + ); + } + + const sig = await fetchTeeSignature(model, completionId); + const signatureHex = typeof sig.signature === 'string' ? sig.signature : sig.signature?.value; + if (!sig.text || !signatureHex) { + throw new Error( + 'E2EE integrity check failed: server did not return a verifiable signature for this response.' + ); + } + + const result = verifySignature({ + signedText: sig.text, + signatureHex, + expectedSigningAddress, + expectedContent: responseContent, + }); + + if (!result.verified) { + throw new Error( + `E2EE integrity check failed: the response was NOT signed by the attested enclave key ` + + `(${result.error ?? 'signature/content mismatch'}). The displayed content cannot be trusted.` + ); + } +} + async function streamChat( messages: Message[], model: string, @@ -587,34 +640,51 @@ async function streamChat( if (effectiveVeniceParams && Object.keys(effectiveVeniceParams).length > 0) { streamOptions.venice_parameters = effectiveVeniceParams; } + let completionId: string | undefined; + let e2eeCiphertextSeen = false; for await (const chunk of chatCompletionStream(messagesToSend, streamOptions)) { + if (chunk.completionId && !completionId) { + completionId = chunk.completionId; + } if (chunk.content) { if (spinner) clearSpinner(); - // E2EE: Decrypt content if encrypted let displayContent = chunk.content; - if (e2eeContext && isHexEncrypted(chunk.content)) { + if (e2eeContext) { + // In E2EE mode every content chunk MUST be ciphertext. A server + // that returns plaintext here is not honoring the protocol and + // must not have its output shown under the "encrypted" banner. + if (!isHexEncrypted(chunk.content)) { + throw new Error( + 'E2EE protocol violation: server returned unencrypted content for an E2EE model. ' + + 'Refusing to display it as end-to-end encrypted.' + ); + } try { displayContent = decryptChunk(chunk.content, e2eeContext.privateKey); } catch (decryptError) { console.error(c.red('\n[E2EE Decryption Error]')); throw decryptError; } + e2eeCiphertextSeen = true; + // Buffer only: do not display unverified E2EE content. It is + // rendered after the response signature is verified below. + fullContent += displayContent; + } else { + // Process thinking blocks (format or strip) + const { output, state: newState } = processThinkingContent( + displayContent, + thinkingState, + { strip: stripThinking, format }, + c + ); + thinkingState = newState; + + if (output) { + process.stdout.write(output); + } + fullContent += displayContent; } - - // Process thinking blocks (format or strip) - const { output, state: newState } = processThinkingContent( - displayContent, - thinkingState, - { strip: stripThinking, format }, - c - ); - thinkingState = newState; - - if (output) { - process.stdout.write(output); - } - fullContent += displayContent; } if (chunk.tool_calls) { @@ -630,6 +700,28 @@ async function streamChat( } } + // E2EE: verify the response was signed by the attested enclave key + // BEFORE any of it is shown to the user, then render it. + if (e2eeContext) { + if (!e2eeCiphertextSeen) { + throw new Error( + 'E2EE integrity check failed: server returned no encrypted content for an E2EE model.' + ); + } + await assertTeeResponseSignature(model, completionId, e2eeContext, fullContent); + + const { output, state: verifiedState } = processThinkingContent( + fullContent, + thinkingState, + { strip: stripThinking, format }, + c + ); + thinkingState = verifiedState; + if (output) { + process.stdout.write(output); + } + } + // Flush any remaining buffered content (handles unclosed tags) const remaining = flushThinkingState(thinkingState, { strip: stripThinking, format }, c); if (remaining) { diff --git a/src/lib/e2ee.ts b/src/lib/e2ee.ts index 6603840..2d566f0 100644 Binary files a/src/lib/e2ee.ts and b/src/lib/e2ee.ts differ