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:
- No background pages: Service workers are ephemeral. We can't keep crypto keys in memory between operations. Each encrypt/decrypt is stateless.
- No eval(): The strict CSP means no dynamic code generation. All crypto runs through the built-in WebCrypto API.
- No remote code: We can't load external crypto libraries at runtime. Everything is bundled at build time via Vite.
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.