February 18, 2026

How We Implemented Host-Blind Encryption in a Chrome Extension

A technical deep-dive into building AES-256-CBC client-side encryption in a Manifest V3 Chrome Extension using the WebCrypto API, with cross-platform byte-identical output.

The Constraint: Three Platforms, One Ciphertext

vnsh encrypts data on three different platforms: a POSIX shell script (CLI), a Node.js process (MCP server), and a Chrome Extension (browser). All three must produce byte-identical ciphertext for the same input, key, and IV — otherwise a link created by the CLI wouldn't decrypt in the browser, or vice versa.

This sounds obvious, but AES-256-CBC has subtle compatibility pitfalls across crypto implementations. Here's how we solved each one.

The Three Crypto Stacks

CLI: OpenSSL

The CLI is a zero-dependency shell script. It uses OpenSSL directly:

KEY=$(openssl rand -hex 32)   # 256-bit key
IV=$(openssl rand -hex 16)    # 128-bit IV
openssl enc -aes-256-cbc -K "$KEY" -iv "$IV" < plaintext > ciphertext

Critical detail: we pass -K (uppercase) and -iv as raw hex, not -k (lowercase, which derives a key from a passphrase via EVP_BytesToKey). This gives us direct control over the key material.

MCP Server: Node.js crypto

const cipher = crypto.createCipheriv(
  'aes-256-cbc',
  Buffer.from(keyHex, 'hex'),
  Buffer.from(ivHex, 'hex')
);
const encrypted = Buffer.concat([
  cipher.update(plaintext),
  cipher.final()
]);

Node.js crypto module wraps OpenSSL internally, so compatibility is straightforward. Same PKCS#7 padding by default.

Chrome Extension: WebCrypto API

This is where it gets interesting. The browser's SubtleCrypto API has a different interface:

const key = await crypto.subtle.importKey(
  'raw',
  keyBuffer,        // ArrayBuffer, not hex string
  { name: 'AES-CBC' },
  false,
  ['encrypt', 'decrypt']
);

const ciphertext = await crypto.subtle.encrypt(
  { name: 'AES-CBC', iv: ivBuffer },
  key,
  plaintext         // ArrayBuffer
);

Compatibility Pitfall #1: Padding

OpenSSL and Node.js use PKCS#7 padding by default for CBC mode. WebCrypto's AES-CBC also uses PKCS#7. So far so good.

But here's the trap: if you use OpenSSL with -nopad or Node.js with cipher.setAutoPadding(false), the output changes. We explicitly rely on default padding everywhere and never disable it.

Compatibility Pitfall #2: Key Format

OpenSSL takes hex strings. Node.js takes Buffers. WebCrypto takes ArrayBuffers. The conversion must be exact:

// Hex string to ArrayBuffer (for WebCrypto)
function hexToBuffer(hex: string): ArrayBuffer {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
  }
  return bytes.buffer;
}

A common mistake: using TextEncoder on the hex string instead of parsing it as hex bytes. TextEncoder.encode("deadbeef") gives you the ASCII bytes of the string "deadbeef" (8 bytes), not the 4 bytes 0xDE 0xAD 0xBE 0xEF. This produces valid but incompatible ciphertext.

Compatibility Pitfall #3: The v2 URL Format

vnsh v1 URLs encoded key and IV separately: #k=abc123&iv=def456. This was verbose (~160 chars total). For v2, we concatenate key (32 bytes) + IV (16 bytes) = 48 bytes, then base64url-encode:

// 48 bytes → 64 base64url characters
function encodeSecret(key: ArrayBuffer, iv: ArrayBuffer): string {
  const combined = new Uint8Array(48);
  combined.set(new Uint8Array(key), 0);
  combined.set(new Uint8Array(iv), 32);
  return bufferToBase64url(combined);
}

function decodeSecret(secret: string): { key: ArrayBuffer; iv: ArrayBuffer } {
  const bytes = base64urlToBuffer(secret);
  return {
    key: bytes.slice(0, 32),
    iv: bytes.slice(32, 48)
  };
}

The base64url variant (RFC 4648 §5) replaces + with - and / with _, and strips padding =. This is URL-safe and won't break in URL fragments.

Manifest V3 Constraints

Chrome's Manifest V3 adds restrictions that affect crypto operations:

These constraints are actually good for security — they force us to use the browser's native crypto primitives rather than JavaScript implementations that could be tampered with.

Testing: Cross-Platform Vectors

We maintain a set of test vectors generated by OpenSSL:

// Known plaintext + key + IV → expected ciphertext
{
  "plaintext": "Hello, vnsh!",
  "key": "a1b2c3d4...",
  "iv": "e5f6a7b8...",
  "ciphertext_base64": "kL9mN2pQ..."
}

Every platform's test suite runs against the same vectors. If the CLI produces ciphertext X for input Y, the extension must produce exactly X, and the MCP server must decrypt X back to Y. Our 48 extension tests include 13 dedicated crypto tests verifying this.

The Result

A link created by cat file | vn on a Linux server can be opened in Chrome on macOS and decrypted client-side. The same link works with Claude Code via MCP on Windows. Three platforms, three different crypto APIs, one format, byte-identical output.

The full source is at github.com/raullenchai/vnsh — see extension/src/lib/crypto.ts, mcp/src/crypto.ts, and the CLI's OpenSSL commands in cli/vn.

Try vnsh now — encrypted, ephemeral sharing for developers and AI agents.

Share via CLI Chrome Extension