Skip to content

decryptChunk returns unencrypted response content verbatim; fork predates upstream's fail-closed fix #2

Description

@jooray

Disclosure up front: I maintain jooray/venice-e2ee-proxy, a different proxy over the same Venice protocol, so I am not a neutral party. I ran a comparison of the two and found something I think is worth reporting regardless of who benefits from it. Happy to be told I have misread the code.

1. decryptChunk returns unencrypted response content verbatim

In @axlabs/[email protected], dist/crypto.js:

export async function decryptChunk(clientPrivateKey, hexString) {
    // Short or non-hex content is plaintext passthrough (e.g. whitespace tokens)
    if (!hexString ||
        hexString.length < 154 ||
        !/^[0-9a-f]+$/i.test(hexString)) {
        return hexString;
    }
    const raw = fromHex(hexString);
    // Verify uncompressed EC point prefix to avoid false-positive decryption attempts
    if (raw[0] !== 0x04)
        return hexString;
    ...

Both early returns fail open. Any response chunk that is not valid hex, is under 154 characters, or does not begin with an uncompressed EC point prefix is returned to the caller as-is. There is no allowPlaintext option in the package, so a consumer cannot opt into stricter behaviour.

The consequence is that a server which stops encrypting response chunks, in whole or in part, is indistinguishable from one that does not. The proxy hands the plaintext through to the client and nothing in the logs or the response indicates that the encrypted channel stopped being used. A user reading verifyAttestation=true, fullDcapVerification=on in the startup banner would reasonably conclude their responses were still end-to-end encrypted.

Scoping this honestly, because I do not think it is a critical bug:

  • Prompts are unaffected. The request direction is encrypted client-side either way, so this does not leak what a user typed.
  • The impact is on the response channel and, more importantly, on the signal: silent downgrade rather than a detectable failure.
  • The < 154 threshold is a reasonable lower bound in itself (65-byte ephemeral key plus 12-byte IV is 154 hex characters). The issue is what happens when the check does not match, not the check.

Suggested fix: throw on non-encrypted content by default, and add an explicit opt-in for callers that genuinely need passthrough. Whitespace-only chunks are worth keeping exempt, since they carry no model output.

2. The fork predates upstream's fix for exactly this

This is the part I would not have guessed without checking. Upstream elkimek/venice-e2ee fixed this in its own 0.2.0, per its changelog:

Encrypted responses fail closed. Non-empty plaintext model output is rejected unless the caller deliberately enables the legacy allowPlaintext compatibility option.

@axlabs/[email protected] shares that version number but not that change, so the version string suggests parity that is not there. The fork appears to have branched from around upstream 0.1.x.

Upstream is now at 0.4.1, and a few things have landed since the fork point that are relevant here:

  • GPU attestation against NVIDIA rather than Venice's report of it. src/e2ee/e2ee.service.ts currently gates on attestation.server_verification.nvidia, and the comment there says "No audited JS verifier exists today, and the library does not expose the raw GPU evidence to the session path." That is now out of date: upstream exposes createNvidiaVerifier(), which submits the evidence to NVIDIA's NRAS, requires eat_nonce to equal the nonce the session sent, and optionally verifies the ES384 token signatures against NVIDIA's published key set.
  • Response receipts and trust anchors, letting a client check that a completion actually came from the attested enclave.
  • Tool-call parsing for function calling over E2EE.

I am not suggesting you adopt upstream wholesale, and I recognise the fork exists for reasons I do not know about. But rebasing onto current upstream, or cherry-picking the fail-closed change, would resolve item 1 and refresh the GPU story at the same time.

Thanks for publishing the project. The NestJS structure, the Swagger UI and the validated CLI flags are genuinely nicer packaging than what I have.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions