Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions SECURITY_PR.md
Original file line number Diff line number Diff line change
@@ -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
124 changes: 108 additions & 16 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
chatCompletionStream,
listModels,
fetchTeeAttestation,
fetchTeeSignature,
} from '../lib/api.js';
import {
getDefaultModel,
Expand All @@ -34,6 +35,7 @@ import {
encryptMessage,
decryptChunk,
isHexEncrypted,
verifySignature,
zeroFill,
} from '../lib/e2ee.js';
import {
Expand Down Expand Up @@ -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<void> {
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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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 <think> tags)
const remaining = flushThinkingState(thinkingState, { strip: stripThinking, format }, c);
if (remaining) {
Expand Down
Binary file modified src/lib/e2ee.ts
Binary file not shown.