StealthLedger
Security

The operator cannot read your vault.

Every hosted portfolio tracker asks you to take that on faith. StealthLedger publishes the code that decides it, so you don't have to. The rest of this page is how it works, what it protects against, and how you verify the claim without asking anyone's permission.

SHA-256 · vault-core.js 6b5cf8ef62b832d1…c06f4fa0e Verify

Where the trust boundary sits

Everything sensitive is encrypted in your browser before it touches the network. What crosses the wire is opaque bytes and login metadata — not holdings, not amounts, not notes.

What the server can see

  • Your username (so it knows which encrypted blob to hand back)
  • A scrypt verifier of your login secret — the secret itself never crosses
  • Your encrypted vault blob, as opaque bytes
  • When your vault changed, from roughly which region, and how big it got
  • The coin IDs you ask the price proxy about (never amounts)

What the server cannot see

  • Your holdings, cash, notes, custom labels
  • How much of anything you own
  • Your passphrase, recovery code, or the vault key
  • Anything a decryption path could recover, because it has no key

Primitives — boring on purpose

Standard building blocks, standard parameters, no in-house cryptography. Every operation goes through the browser's native Web Crypto API. There is no custom cipher, no custom mode, and no third-party crypto library beyond a vendored WASM build of scrypt.

Purpose Primitive Parameters
Vault encryption AES-256-GCM 96-bit random IV per write, 128-bit auth tag, AAD binds ciphertext to vault + slot
Passphrase / recovery-code KDF scrypt (RFC 7914) N = 65 536, r = 8, p = 1, dkLen = 32 — ≈ 64 MiB per guess, ~150–250 ms per attempt on a desktop core
Passkey KDF HKDF-SHA-256 Over the WebAuthn PRF output, with a per-slot 32-byte salt and a fixed info string portcalc:passkey-kek:v2
Randomness Web Crypto getRandomValues Never Math.random, never seeded, never derived
Text normalisation NFKC before the KDF So the same human-typed passphrase derives the same key on iOS, macOS, Linux

Vault envelope — a two-tier key hierarchy

A single random 32-byte content key encrypts the payload. Every unlocker (passphrase, passkey, recovery code) independently wraps that same content key under its own key-encryption key. Adding a passkey, rotating a passphrase, or revoking a device rewraps a 32-byte key — the payload is never re-encrypted and never needs to be in the clear.

The envelope stored on the host looks like this:

vault-envelope.json on-disk shape
{
  "v":         2,
  "kind":      "portcalc-vault",
  "vaultId":   "<16 hex chars, non-secret, used as AAD>",
  "seq":       7,
  "updatedAt": "2026-08-30T03:14:15.926Z",
  "content": {
    "alg": "AES-256-GCM",
    "iv":  "<base64 12 bytes>",
    "ct":  "<base64: ciphertext || 16-byte GCM tag>"
  },
  "unlockers": [
    {
      "id":        "<8 bytes hex>",
      "type":      "passphrase",
      "kdf":       { "name": "scrypt", "N": 65536, "r": 8, "p": 1,
                     "salt": "<base64 16 bytes>" },
      "wrap":      { "alg": "AES-256-GCM", "iv": "<base64>", "ct": "<base64>" }
    },
    {
      "id":           "<8 bytes hex>",
      "type":         "passkey",
      "credentialId": "<base64url>",
      "hkdfSalt":     "<base64 32 bytes>",
      "wrap":         { "alg": "AES-256-GCM", "iv": "<base64>", "ct": "<base64>" }
    }
  ]
}

The additional authenticated data is where the belt-and-braces comes in. Every wrap is bound to portcalc-vault:v2:<vaultId>:unlocker:<slotId>:<type>, so an attacker who writes to the store cannot swap a weak recovery wrap into the passkey slot, copy a slot from an older vault, or graft slots between two vaults. Any of those flips the AAD and GCM rejects it.

The actual code your browser runs

These snippets are lifted verbatim from /shared/vault-core.js, the single module that performs every cryptographic operation on your device. The whole file is ~780 lines and reads like a spec on its own.

1. Deriving a key-encryption key from a passphrase

vault-core.js — scryptKek() KDF
export const SCRYPT_PARAMS = Object.freeze(
  { name: 'scrypt', N: 65536, r: 8, p: 1 }
);

async function scryptKek(secret, salt, params = SCRYPT_PARAMS) {
  // NFKC so the same human-typed passphrase derives the same key across
  // an iOS keyboard, a Mac and a Linux terminal.
  const normalized = secret.normalize('NFKC');
  const out = await scryptProvider(normalized, toBytes(salt), {
    N:    params.N,
    r:    params.r,
    p:    params.p,
    dkLen: 32,   // 256-bit KEK
  });
  return toBytes(out);
}

2. AES-256-GCM seal — one function, no custom modes

vault-core.js — aeadSeal() encrypt
export async function aeadSeal(rawKey, plaintext, aad) {
  const key = await importAes(rawKey, ['encrypt']);
  const iv  = randomBytes(12);                     // 96-bit GCM nonce
  const params = { name: 'AES-GCM', iv, tagLength: 128 };
  if (aad !== undefined) params.additionalData = toBytes(aad);
  const ct = await crypto.subtle.encrypt(params, key, toBytes(plaintext));
  return { alg: 'AES-256-GCM',
           iv:  b64(iv),
           ct:  b64(new Uint8Array(ct)) };         // ct = ciphertext || 16-byte tag
}

3. Passkey KEK — HKDF over the WebAuthn PRF output

vault-core.js — hkdfSha256() + passkey slot passkey path
export async function hkdfSha256(ikm, salt, info, length = 32) {
  const base = await crypto.subtle.importKey(
    'raw', toBytes(ikm), 'HKDF', false, ['deriveBits']);
  const bits = await crypto.subtle.deriveBits(
    { name: 'HKDF', hash: 'SHA-256',
      salt: toBytes(salt),
      info: toBytes(info) },
    base, length * 8);
  return new Uint8Array(bits);
}

// Later, when wrapping the content key under a passkey:
const kek = await hkdfSha256(
  prfOutput,
  unb64(slot.hkdfSalt),
  'portcalc:passkey-kek:v2',    // domain-separation info string
  32,
);

4. AAD binds every wrap to its slot

vault-core.js — AAD construction anti-swap
function wrapAad(vaultId, slot) {
  return `portcalc-vault:v2:${vaultId}:unlocker:${slot.id}:${slot.type}`;
}

function contentAad(vault) {
  return `portcalc-vault:v2:${vault.vaultId}:content`;
}

Everything else in the module is plumbing around these four moves: generate a slot id, wrap the content key, unwrap it on unlock, add or remove slots, refuse to remove the last one. The cryptography document walks it in prose; the module source is the full reference.

Verify it yourself

The two files below run every cryptographic operation on your device. Their SHA-256 hashes are pinned to the current deploy. Fetch them, hash them, and confirm the values match — you now know exactly which bytes encrypt your vault.

SHA-256 6b5cf8ef62b832d12cd2110d78490cee92ea90573a59b22be689418c06f4fa0e
Size 30 147 bytes · ~780 lines · no external crypto imports

From a terminal:

verify.sh copy-runnable
curl -sS https://stealthledger.net/shared/vault-core.js | shasum -a 256
# expected:
# 6b5cf8ef62b832d12cd2110d78490cee92ea90573a59b22be689418c06f4fa0e

Why the hash matters. A malicious build of the client is the one attack cryptography alone cannot stop — the browser runs whatever the site serves. Publishing the module hash gives you something to diff against. If the byte-for-byte source in your browser ever stops matching the value on this page, that is a signal worth investigating. This is the residual risk in §4.5 of the threat model, called out honestly.

What's published, and what isn't

This page and the modules it links to cover the security-critical parts of StealthLedger — the code that decides whether the operator can read your vault. Everything else (signup gates, price proxy, rate limits, deploy scripts) is operational glue: a compromise there would matter, but it wouldn't hand anyone a plaintext holding, and none of it is doing anything a competent web team hasn't shipped elsewhere.

Published

Not published (yet)

Grade the crypto, not the marketing. If the envelope holds up, the rest is boring web plumbing. If it doesn't, nothing else matters.

Reporting a vulnerability

If you find something wrong — a flaw in the design, a bug in vault-core.js, or a discrepancy between what this page says and what the code does — please tell me. The module is the source of truth; if it disagrees with this page, the page is wrong and needs to be fixed.

Use the form below and I'll get back to you. Please give me a reasonable window to fix anything critical before public disclosure.

Submissions are stored by the site host and forwarded to the maintainer. Don't paste anything you don't want the maintainer to see. If you need an out-of-band channel or PGP, say so in the report and I'll set one up.

Change log