Blob Wire Format
Every encrypted value in vaultctl is serialized into a compact binary blob, then Base64-encoded for JSON transport. This page documents the byte-level layout, parsing logic, and interop requirements between Go and TypeScript.
General Layout
Offset Size Field
────── ────── ─────────────
0 1 byte version
1 1 byte alg_id
2 varies algorithm-specific payload- version: Currently
0x01. Future versions may change the payload layout. - alg_id: Identifies the encryption algorithm and determines how to parse the rest of the blob.
Algorithm-Specific Layouts
AES-256-GCM (alg_id = 0x01)
The most common blob type. Used for item data, item names, folder names, and encrypted private keys.
Offset Size Field
────── ──────── ─────────────
0 1 byte version (0x01)
1 1 byte alg_id (0x01)
2 12 bytes nonce
14 N bytes ciphertext
14+N 16 bytes GCM auth tagTotal size: 2 + 12 + len(plaintext) + 16 bytes
┌─────┬─────┬──────────────┬──────────────────┬──────────────────┐
│ 01 │ 01 │ nonce │ ciphertext │ GCM tag │
│ 1B │ 1B │ 12B │ var │ 16B │
└─────┴─────┴──────────────┴──────────────────┴──────────────────┘RSA-OAEP-SHA256 (alg_id = 0x02)
Used for wrapping vault keys in shared vaults. No separate nonce or tag — the OAEP padding provides built-in authentication.
Offset Size Field
────── ──────── ─────────────
0 1 byte version (0x01)
1 1 byte alg_id (0x02)
2 512 bytes RSA-OAEP ciphertext (4096-bit key)Total size: 2 + 512 = 514 bytes (fixed for 4096-bit RSA)
┌─────┬─────┬────────────────────────────────────────┐
│ 01 │ 02 │ RSA-OAEP ciphertext │
│ 1B │ 1B │ 512B │
└─────┴─────┴────────────────────────────────────────┘AES-256-KW (alg_id = 0x03)
Used for wrapping vault keys in personal vaults. AES Key Wrap (RFC 3394) includes an 8-byte integrity check value.
Offset Size Field
────── ──────── ─────────────
0 1 byte version (0x01)
1 1 byte alg_id (0x03)
2 N bytes wrapped key (ciphertext + 8B ICV)For a 256-bit (32-byte) vault key, the wrapped output is 40 bytes (32 + 8 ICV):
Total size: 2 + 40 = 42 bytes
┌─────┬─────┬────────────────────────────────────────┐
│ 01 │ 03 │ wrapped key (ciphertext + ICV) │
│ 1B │ 1B │ 40B │
└─────┴─────┴────────────────────────────────────────┘AES Key Wrap does not use a nonce — it is deterministic. The 8-byte integrity check value (ICV) at the end of the wrapped output serves as an authentication tag. If any bit is tampered with, unwrapping fails.
Parsing Pseudocode
function parseBlob(base64: string): DecryptedPayload {
const raw = base64Decode(base64);
const version = raw[0];
if (version !== 0x01) throw new Error(`unsupported version: ${version}`);
const algId = raw[1];
const payload = raw.slice(2);
switch (algId) {
case 0x01: { // AES-256-GCM
const nonce = payload.slice(0, 12);
const ciphertextAndTag = payload.slice(12);
// WebCrypto expects ciphertext+tag concatenated
return { algId, nonce, ciphertextAndTag };
}
case 0x02: { // RSA-OAEP-SHA256
const ciphertext = payload;
return { algId, ciphertext };
}
case 0x03: { // AES-256-KW
const wrappedKey = payload;
return { algId, wrappedKey };
}
default:
throw new Error(`unknown alg_id: ${algId}`);
}
}Serialization Pseudocode
function buildBlob(algId: number, parts: Uint8Array[]): string {
const header = new Uint8Array([0x01, algId]);
const totalLen = 2 + parts.reduce((sum, p) => sum + p.length, 0);
const result = new Uint8Array(totalLen);
result.set(header, 0);
let offset = 2;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return base64Encode(result);
}
// AES-256-GCM example:
const blob = buildBlob(0x01, [nonce, ciphertextAndTag]);Interop Notes (Go / TypeScript)
Both implementations must produce identical wire bytes for a given plaintext, key, and nonce. The following details are critical for round-trip compatibility.
| Aspect | Requirement |
|---|---|
| Base64 encoding | Standard encoding with = padding (base64.StdEncoding in Go, btoa/Buffer in TS) |
| AES-GCM tag position | Tag is appended after ciphertext. Go's cipher.AEAD.Seal() does this by default. WebCrypto's encrypt() returns ciphertext+tag concatenated. |
| AES-GCM tag size | Always 128 bits (16 bytes). Both Go and WebCrypto default to this. |
| RSA-OAEP label | Empty (nil/undefined). Do not set a label parameter. |
| AES Key Wrap | Go: github.com/NickBall/go-aes-key-wrap. TS: WebCrypto wrapKey with AES-KW. Both produce identical output for the same key and KEK. |
| Byte order | Big-endian for all multi-byte fields (standard network byte order). |
| AAD format | UTF-8 encoded strings concatenated with || separator: "vaultId||itemId". Both sides must produce identical AAD bytes. |
| Name padding | PKCS#7 to 32-byte boundaries. Padding must be applied before encryption and stripped after decryption. |
Interop Validation
The blob format is validated by cross-language table-driven tests:
- TypeScript to Go: TypeScript encrypts test vectors, serializes as JSON fixtures, Go parses and decrypts
- Go to TypeScript: Go encrypts test vectors, TypeScript parses and decrypts
- HKDF: Both sides derive identical keys from the same input material
- PKCS#7 padding: Both sides produce byte-identical padded output
Test fixtures are located in testdata/crypto/.