Do not take our word for it. This page gives you a procedure and a small script. Together they let you prove, on your own machine, that a vault project leaves your device as ciphertext, and that the server holds nothing it can open.
The script has no dependencies. It uses only what Node.js ships with. Read it before you run it.
What you will prove
- Your content leaves the device as AES-256-GCM ciphertext under a random 256-bit project key.
- That project key is wrapped under a key derived from your password. The wrap never leaves your device unwrapped.
- The server holds only ciphertext and wrapped keys.
- A wrong password fails on the authentication tag. It does not return wrong plaintext, and no server answer can change that.
Before you start
- Node.js 20 or newer. Check with
node --version. - A vault project, open in the Caudexus web editor.
-
Browser developer tools, open on the Network tab. Press
Cmd+Opt+Ion macOS, orCtrl+Shift+Ion Windows and Linux. - Turn on Preserve log in the Network tab, so a reload keeps the rows.
Step 1: copy the wrapped key bundle
Reload the project. In the Network tab, find the request to
/api/projects/<projectId>/vault/bundle. Open its Response tab and copy the
whole JSON body.
The body holds the salt, the key derivation profile, the wrapped project key, and the
initialization vector for that wrap. Every field is public. None of it opens anything on its
own. A recovery field holds the same wrap under your recovery phrase, when you made
one. The script uses the password wrap.
Step 2: capture what leaves your device
Go to the manuscript. Type a distinctive sentence, one you can recognize later. Wait a moment for the change to sync.
In the Network tab, find the request to
/api/v1/sync/scopes/<scopeId>/submit. Open its request payload. The body is
{"updates": [{"clientSeq": …, "update": "CXV1…"}]}. Copy the
update string. It is base64url.
Note what is NOT there. The body carries no key, no password, and no plaintext. The author and the device come from your session token, not from the body.
Step 3: capture what the server holds (optional)
Reload the project. Find the response to
/api/v1/sync/scopes/<scopeId>/pull, or open History and find
/range. Each row in updates[] carries an update string in
the same envelope shape. Copy one and give it to the script. What the server returns is what
your device sent, byte for byte.
Step 4: decrypt offline
Save the script as verify-vault.mjs. Save the bundle JSON as
bundle.json. Save the update string as update.txt. Run this command.
Type the password when the script asks.
node verify-vault.mjs --bundle bundle.json --update update.txt A correct password prints this:
KDF profile: scrypt-v1
Deriving the key encryption key. This takes a moment on purpose.
Project key unwrapped
Update decrypted: 110 ciphertext bytes → 78 plaintext bytes
Printable text found in the decrypted update:
The lighthouse keeper counted seventeen crows before dawn.
chapter-one The sentence you typed is in that list. The decrypted bytes are the app's internal change record. The script prints every run of readable text it finds, so you can see your sentence.
Now run it again and type the wrong password:
KDF profile: scrypt-v1
Deriving the key encryption key. This takes a moment on purpose.
Password rejected (authentication tag check failed) This is the point of the exercise. The wrong password does not produce wrong text. AES-GCM refuses. Nobody without the password gets a different answer, and neither do we.
The honest limits
The vault protects content, not the fact that you write. The project title stays plaintext, so we can list your projects. Update sizes, timing, and the device that made each change stay visible to the server, because sync needs them. The project key does not rotate: a leaked OLD password, plus a copy of the OLD wrapped envelope, still derives that key and still opens every update it ever protected. A password change re-wraps the same key. It does not re-encrypt your history.
Download the script: verify-vault.mjs. The listing below is the same file.
The script
#!/usr/bin/env node
/**
* verify-vault.mjs — verify Caudexus vault encryption on your own machine.
*
* This script proves three things, offline, with no Caudexus code:
*
* 1. The wrapped key bundle the server holds opens ONLY with your password.
* 2. The bytes your device sends to the server are AES-256-GCM ciphertext
* under a random project key that the server never sees.
* 3. A wrong password fails on the authentication tag. It does not return
* wrong plaintext, and it cannot be made to.
*
* It uses only `node:crypto`, `node:fs`, and `node:readline`. There are no
* dependencies. Read it before you run it. Every step is below in the order it
* happens.
*
* Usage:
* node verify-vault.mjs --bundle bundle.json --update update.txt
*
* Exit codes: 0 = verified, 1 = wrong password, 2 = malformed input.
*/
import { createDecipheriv, hkdfSync, pbkdf2Sync, scryptSync } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
// ─── Constants of the vault protocol ──────────────────────────────────
// These are fixed by the protocol. A change to any of them is a new
// profile name on the envelope.
const KDF_SCRYPT_V1 = 'scrypt-v1';
const KDF_PBKDF2 = 'PBKDF2-SHA256';
const SCRYPT_V1_N = 131072; // 2^17
const SCRYPT_V1_R = 8;
const SCRYPT_V1_P = 1;
const SCRYPT_V1_DK_LEN = 32;
const SCRYPT_V1_MAX_MEM = 256 * 1024 * 1024;
const SCRYPT_V1_HKDF_INFO = 'caudexus-vault-kek-v1';
/** The first 4 bytes of every vault byte envelope. */
const ENVELOPE_MAGIC = 'CXV1';
/** AES-GCM authentication tag length in bytes. */
const TAG_BYTES = 16;
/** AES-GCM initialization vector length in bytes. */
const IV_BYTES = 12;
const USAGE = 'Usage: node verify-vault.mjs --bundle bundle.json --update update.txt';
// ─── Helpers ──────────────────────────────────────────────────────────
/** Decode a base64url string to bytes. */
function fromBase64Url(text) {
const normalized = String(text).trim().replace(/-/g, '+').replace(/_/g, '/');
return Buffer.from(normalized, 'base64');
}
function fail(message, code) {
console.error(`\n${message}`);
process.exit(code);
}
/**
* AES-256-GCM decrypt. The tag is the LAST 16 bytes of the ciphertext,
* which is how WebCrypto lays it out. There is no additional data.
* Throws when the tag does not check.
*/
function aesGcmDecrypt(key, iv, ciphertextWithTag) {
if (ciphertextWithTag.length < TAG_BYTES) throw new Error('ciphertext is too short');
const tag = ciphertextWithTag.subarray(ciphertextWithTag.length - TAG_BYTES);
const body = ciphertextWithTag.subarray(0, ciphertextWithTag.length - TAG_BYTES);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(body), decipher.final()]);
}
// ─── Input ────────────────────────────────────────────────────────────
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === '--bundle' || flag === '--update') {
const value = argv[i + 1];
if (value == null) fail(USAGE, 2);
args[flag.slice(2)] = value;
i++;
}
}
if (args.bundle == null || args.update == null) fail(USAGE, 2);
return args;
}
/** Ask for one line on stdin. The password is the only prompt. */
function askPassword(question) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (line) => {
rl.close();
resolve(line ?? '');
});
});
}
// ─── Step 1: derive the key encryption key from the password ──────────
/**
* Derive the KEK. The profile name on the bundle picks the algorithm.
* `scrypt-v1` is the current profile. `PBKDF2-SHA256` is the legacy
* profile, kept because older bundles still carry it.
*/
function deriveKek(password, wrap) {
const salt = fromBase64Url(wrap.kdfSalt);
if (wrap.kdfAlgorithm === KDF_SCRYPT_V1) {
// NFKC keeps the same phrase equal across devices and keyboards.
const secret = Buffer.from(password.normalize('NFKC'), 'utf8');
const ikm = scryptSync(secret, salt, SCRYPT_V1_DK_LEN, {
N: SCRYPT_V1_N,
r: SCRYPT_V1_R,
p: SCRYPT_V1_P,
maxmem: SCRYPT_V1_MAX_MEM,
});
// HKDF-SHA256 with an empty salt binds the derived key to this use.
const kek = hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(SCRYPT_V1_HKDF_INFO, 'utf8'), 32);
return Buffer.from(kek);
}
if (wrap.kdfAlgorithm === KDF_PBKDF2) {
const iterations = Number(wrap.kdfIterations);
if (!Number.isFinite(iterations) || iterations <= 0) fail('The bundle has no iteration count.', 2);
// The legacy profile takes the raw UTF-8 string. No normalization.
const secret = Buffer.from(password, 'utf8');
return pbkdf2Sync(secret, salt, iterations, 32, 'sha256');
}
fail(`Unsupported KDF profile: ${String(wrap.kdfAlgorithm)}`, 2);
}
// ─── Step 2: print the readable text in the decrypted update ──────────
/**
* The decrypted bytes are the app's internal change record. Inserted text
* sits in it as UTF-8. Print every run of 4 or more printable characters so
* you can find the sentence you typed.
*/
function printableRuns(bytes, minimum = 4) {
const text = bytes.toString('utf8');
const runs = [];
let run = '';
for (const character of text) {
const code = character.codePointAt(0);
const printable = code >= 0x20 && code !== 0x7f && code !== 0xfffd;
if (printable) {
run += character;
continue;
}
if (run.length >= minimum) runs.push(run);
run = '';
}
if (run.length >= minimum) runs.push(run);
return runs;
}
// ─── Main ─────────────────────────────────────────────────────────────
async function main() {
const args = parseArgs(process.argv.slice(2));
// --- Read the bundle -----------------------------------------------
let bundle;
try {
bundle = JSON.parse(readFileSync(args.bundle, 'utf8'));
} catch {
fail('The bundle is not valid JSON.', 2);
}
// The response body wraps the bundle in a `bundle` field. Accept both.
if (bundle != null && bundle.bundle != null) bundle = bundle.bundle;
const wrap = bundle?.password;
if (wrap == null || wrap.wrappedKey == null || wrap.kdfSalt == null) {
fail('The bundle has no `password` wrap. Copy the whole response body.', 2);
}
// --- Read the update string ----------------------------------------
const updateText = readFileSync(args.update, 'utf8').trim();
if (updateText === '') fail('The update file is empty.', 2);
// --- Read the password ---------------------------------------------
const password = await askPassword('\nProject password: ');
console.log(`\nKDF profile: ${wrap.kdfAlgorithm}`);
console.log('Deriving the key encryption key. This takes a moment on purpose.');
// --- Unwrap the project key ----------------------------------------
// The wrapped key is the 32-byte project key encrypted under the KEK,
// followed by the 16-byte AES-GCM tag. A tag failure is the whole
// point: it means the password is wrong.
const kek = deriveKek(password, wrap);
let projectKey;
try {
projectKey = aesGcmDecrypt(kek, fromBase64Url(wrap.wrappedKeyIv), fromBase64Url(wrap.wrappedKey));
} catch {
fail('Password rejected (authentication tag check failed)', 1);
}
if (projectKey.length !== 32) fail('The unwrapped key is not 256 bits.', 2);
console.log('\nProject key unwrapped');
// --- Decrypt the update --------------------------------------------
// The envelope is: "CXV1" (4 bytes) + IV (12 bytes) + ciphertext + tag.
const envelope = fromBase64Url(updateText);
if (envelope.length < 4 + IV_BYTES + TAG_BYTES) fail('The update is too short to be an envelope.', 2);
if (envelope.subarray(0, 4).toString('ascii') !== ENVELOPE_MAGIC) {
fail(`The update does not start with "${ENVELOPE_MAGIC}". This project is not a vault project.`, 2);
}
const iv = envelope.subarray(4, 4 + IV_BYTES);
const body = envelope.subarray(4 + IV_BYTES);
let plaintext;
try {
plaintext = aesGcmDecrypt(projectKey, iv, body);
} catch {
fail('The update failed its authentication tag. It belongs to a different project key.', 1);
}
console.log(`Update decrypted: ${envelope.length} ciphertext bytes → ${plaintext.length} plaintext bytes`);
console.log('\nPrintable text found in the decrypted update:');
const runs = printableRuns(plaintext);
if (runs.length === 0) console.log(' (none)');
for (const run of runs) console.log(` ${run}`);
process.exit(0);
}
main().catch((error) => {
console.error(`\nUnexpected error: ${error?.message ?? error}`);
process.exit(2);
});