DEV Community

Felipe Rohde
Felipe Rohde

Posted on

Building a Vaultaire-style encrypted vault in TypeScript with WebCrypto

this week I posted about Vaultaire on X. it's an encrypted photo and file vault for iPhone, built by one person, Rasty Turek (@synopsi). he says the first version took about three weeks, mostly by prompting a coding agent, and in an Indie Hackers interview he said it went from $26,384 in gross App Store sales in July to $41,425 in August 2026

the money part got the attention. but what I actually got curious about was the crypto. "encrypted vault" is a category full of apps that just put a PIN screen in front of your camera roll. Vaultaire's site is pretty specific about what it does instead: files encrypted on the device with AES-256-GCM under a random master key, and the pattern you draw derives a key that unwraps that master key. their about page also mentions PBKDF2-HMAC-SHA512, the iOS Keychain and client-side encrypted CloudKit records

the app is Swift. I don't write Swift, so I rebuilt the same idea in TypeScript with WebCrypto to understand it. this is not Vaultaire's code, just the same pattern (it's called envelope encryption) in a language I use every day. everything below runs on Node 20+ and in the browser, no dependencies

the idea in two keys

there are two keys and they do different jobs:

  • master key: random AES-256-GCM key. it encrypts every file. it never changes, and it's never stored in plain form
  • key encryption key (KEK): derived from the pattern with PBKDF2. its only job is to wrap and unwrap the master key

why not just derive one key from the pattern and encrypt files with it? because then changing the pattern means re-encrypting every photo. with two keys, changing the pattern is just unwrapping the master key with the old KEK and wrapping it again with the new one. a few hundred bytes instead of gigabytes

it also means the thing an attacker copies off the device is a pile of ciphertext plus a small header. no key anywhere

creating and opening a vault

the header is the only thing you need to store next to the encrypted files: a salt, the iteration count and the wrapped master key

// vault.ts
const enc = new TextEncoder();
const PBKDF2_ITERATIONS = 220_000; // OWASP's current number for PBKDF2-HMAC-SHA512

export type VaultHeader = {
  v: 1;
  salt: string; // base64
  iterations: number;
  wrappedKey: string; // base64, master key wrapped with AES-KW
};

const toB64 = (b: ArrayBuffer | Uint8Array) =>
  Buffer.from(b instanceof Uint8Array ? b : new Uint8Array(b)).toString("base64");
const fromB64 = (s: string) => new Uint8Array(Buffer.from(s, "base64"));

// the pattern is just a list of dot indexes on a 3x3 grid, like [0, 4, 8, 5]
export const patternToSecret = (dots: number[]) => dots.join("-");

async function deriveKek(secret: string, salt: Uint8Array, iterations: number) {
  const base = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    "PBKDF2",
    false,
    ["deriveKey"]
  );

  return crypto.subtle.deriveKey(
    { name: "PBKDF2", hash: "SHA-512", salt, iterations },
    base,
    { name: "AES-KW", length: 256 },
    false,
    ["wrapKey", "unwrapKey"]
  );
}

export async function createVault(secret: string) {
  const masterKey = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // extractable, otherwise we can't wrap it
    ["encrypt", "decrypt"]
  );

  const salt = crypto.getRandomValues(new Uint8Array(16));
  const kek = await deriveKek(secret, salt, PBKDF2_ITERATIONS);
  const wrapped = await crypto.subtle.wrapKey("raw", masterKey, kek, "AES-KW");

  const header: VaultHeader = {
    v: 1,
    salt: toB64(salt),
    iterations: PBKDF2_ITERATIONS,
    wrappedKey: toB64(wrapped),
  };

  return { header, masterKey };
}

export async function openVault(header: VaultHeader, secret: string) {
  const kek = await deriveKek(secret, fromB64(header.salt), header.iterations);

  try {
    return await crypto.subtle.unwrapKey(
      "raw",
      fromB64(header.wrappedKey),
      kek,
      "AES-KW",
      { name: "AES-GCM", length: 256 },
      false,
      ["encrypt", "decrypt"]
    );
  } catch {
    return null; // wrong pattern, AES-KW integrity check failed
  }
}
Enter fullscreen mode Exit fullscreen mode

a few notes on the choices:

  • I use AES-KW for wrapping. it has its own integrity check, so a wrong pattern doesn't give you a garbage key, it just throws. that's how openVault knows the pattern was wrong without storing any hash of it
  • 220,000 iterations is what the OWASP password storage cheat sheet lists for PBKDF2-HMAC-SHA512 right now. on a phone this takes a moment, and that's the point. it's what makes guessing patterns offline slow
  • the iteration count lives in the header, so I can raise it later and old vaults still open
  • the master key comes back as non-extractable from openVault. for a "change pattern" flow you'd unwrap it with extractable: true once, wrap it with the new KEK, and drop it

one honest caveat: a 3x3 pattern is a tiny secret compared to a real passphrase. PBKDF2 slows an attacker down, it doesn't make a short pattern strong. Vaultaire's own App Store page says longer patterns with several turns are harder to guess, and that's the right way to say it

encrypting files

once you have the master key, each file is one encrypt call:

// files.ts
const IV_BYTES = 12;
const enc = new TextEncoder();

export async function encryptFile(key: CryptoKey, fileId: string, data: Uint8Array) {
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv, additionalData: enc.encode(fileId) },
    key,
    data
  );

  // store iv + ciphertext (the 16-byte GCM tag is already at the end)
  const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
  out.set(iv, 0);
  out.set(new Uint8Array(ciphertext), IV_BYTES);
  return out;
}

export async function decryptFile(key: CryptoKey, fileId: string, blob: Uint8Array) {
  const iv = blob.subarray(0, IV_BYTES);
  const ciphertext = blob.subarray(IV_BYTES);

  const plain = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv, additionalData: enc.encode(fileId) },
    key,
    ciphertext
  );

  return new Uint8Array(plain);
}
Enter fullscreen mode Exit fullscreen mode

two things here that are easy to get wrong:

  • a fresh random 12-byte IV for every file. reusing an IV with the same GCM key is the classic way to break GCM. NIST's guidance for random IVs is to stay under 2^32 encryptions per key, which a photo vault is not going to hit
  • the file id goes in as additional data. it's not encrypted, but it's authenticated. if someone swaps the blob of photo-001 with the blob of photo-002 on disk, decryption fails instead of silently showing the wrong file

for big videos you don't want to load the whole file in memory. the usual move is to split it in chunks (say 1 MB), encrypt each chunk with its own IV, and put the file id plus the chunk index in the additional data so chunks can't be reordered. same functions, just in a loop

decoy vaults for free

this is the part I thought was clever. Vaultaire lets a different pattern open a different vault, and the app never shows a list of vaults. with this design that falls out almost for free: each vault is just another header with its own salt and its own wrapped master key

// decoy.ts
import { openVault, type VaultHeader } from "./vault";

export async function unlock(headers: VaultHeader[], secret: string) {
  for (let i = 0; i < headers.length; i++) {
    const key = await openVault(headers[i], secret);
    if (key) return { index: i, key };
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

you draw a pattern, the app tries it against every header, and whatever unlocks is the vault you see. nothing in the header says which one is the "real" one. a pattern that unlocks nothing just looks like a wrong pattern

if you build this, try every header even after a match, or at least make sure the timing doesn't leak how many vaults exist. my loop above returns early, which is fine for a blog post and not fine for an app whose whole point is hiding things

quick test

// test.ts
import { createVault, openVault, patternToSecret } from "./vault";
import { encryptFile, decryptFile } from "./files";
import { unlock } from "./decoy";

const realPattern = patternToSecret([0, 4, 8, 5, 2]);
const decoyPattern = patternToSecret([6, 3, 0, 1]);

const real = await createVault(realPattern);
const decoy = await createVault(decoyPattern);

const blob = await encryptFile(
  real.masterKey,
  "photo-001",
  new TextEncoder().encode("hello vault")
);

const key = await openVault(real.header, realPattern);
const plain = await decryptFile(key!, "photo-001", blob);
console.log(new TextDecoder().decode(plain)); // hello vault

console.log(await openVault(real.header, decoyPattern)); // null

try {
  await decryptFile(key!, "photo-002", blob);
} catch (e) {
  console.log((e as Error).name); // OperationError
}

const headers = [real.header, decoy.header];
console.log((await unlock(headers, decoyPattern))?.index); // 1
console.log((await unlock(headers, realPattern))?.index); // 0
Enter fullscreen mode Exit fullscreen mode

running it with npx tsx test.ts (add "type": "module" to package.json for the top-level await) prints exactly what the comments say. the header is a tiny JSON object you can store anywhere, including a cloud backup, because without the pattern it's useless

what I'd still need for a real app

this is the core, not a product. a real vault also needs:

  • somewhere safe to keep things on the device (Vaultaire mentions the iOS Keychain, on the web you'd look at non-extractable keys in IndexedDB)
  • encrypted metadata too: file names, folders and thumbnails leak a lot if only the file bytes are encrypted
  • a backup story that only ever uploads ciphertext
  • rate limiting on unlock attempts in the UI, on top of the PBKDF2 cost

what I like about this case is that the tech isn't exotic. it's a few WebCrypto calls that have been in browsers for years. from his own write-up and the Shipped & Paid page, the hard part was everything around it: App Store rules, ASO, localization. the crypto is maybe a weekend if you understand why each piece is there

sources

I post indie app cases and build logs like this on X and Instagram

Top comments (0)