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 VerifyEverything 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
What the server cannot see
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 |
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:
{
"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.
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.
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);
}
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
}
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,
);
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.
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.
From a terminal:
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.
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.
Grade the crypto, not the marketing. If the envelope holds up, the rest is boring web plumbing. If it doesn't, nothing else matters.
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.
vault-core.js module hash and the four snippet excerpts. No cryptographic change.