babylon-toolkit is Babylon's frontend monorepo. It ships two browser dApps —
services/vault (the BTC Vault depositor lifecycle) and
services/simple-staking (the BTC staking reference dApp) — and six
packages published to npm under @babylonlabs-io/* (ts-sdk, babylon-tbv-rust-wasm, core-ui,
wallet-connector, babylon-proto-ts, ledger-vault-signer).
There is no server here. No database, no session store, no privileged API key that moves value. It is tempting to conclude that the security surface is therefore small. It is the opposite: this repository is the software that decides what bytes a user's own wallet signs. It sizes Bitcoin transactions, derives the secrets that are the only material capable of recovering a deposit, pre-signs payout transactions built by a third party, and reveals an HTLC preimage on Ethereum. Every one of those actions is irreversible, is attributed to the user, and has no server-side undo, no admin key, and no reversal path.
Primary objective: every value the user signs or commits on-chain is independently re-derived or asserted against a trusted source — never accepted verbatim from an external party, and never inferred from UI or browser state.
This document describes the security model of this repository: threat model, trust boundaries, critical invariants, and what must be preserved when making changes. It is split into five domains:
- Transaction construction and signing (WASM value boundary, fee model, presigning, wallet flags)
- Vault secrets, activation, and recovery material (frozen derivation API, HTLC secret, artifacts)
- External data trust (vault provider, indexer, RPC, sidecar/utils APIs, screening)
- Browser runtime and delivery (CSP, SRI, local storage, telemetry, static hosting)
- Supply chain and CI (published packages, WASM pin, install policy, workflows)
This repository is public. Do not open a GitHub issue, pull request, or discussion for a security vulnerability.
Report privately through either channel:
- Email
security@babylonlabs.io - GitHub Private Vulnerability Reporting
The organisation-wide policy in
babylonlabs-io/babylon governs
disclosure timelines and researcher conduct — including the requirement not to test against
Babylon's public mainnet, testnet, or frontends. Read it before testing.
Include: the commit hash, affected package/service and code pointers, a minimal proof of concept or precise reproduction steps, expected versus actual behaviour, and a proposed severity using the rubric below.
- This document covers the code in this repository. The vault smart contracts, the
BaBe / vaultd protocol, and the vault provider daemon are out of scope; see
babylonlabs-io/vault-contractsandbabylonlabs-io/btc-vault. - The transaction, vault-secret, external-data, and browser controls below primarily describe
services/vaultand its supporting SDK packages.services/simple-stakingshares the repository's supply-chain controls but has its own signing flows and currently has no CSP meta tag; that missing browser control is a known gap, not an implied inheritance from the vault app. - The backend services this frontend talks to each carry their own
SECURITY.md:babylon-vault-indexer,babylon-sidecar-api,utils-api,babylon-btc-monitor. Read those for the server side of any boundary described here. - Nothing in this repository is an enforcement boundary. The vault app is a static bundle running in a user-controlled browser. Every check it performs — address screening, geofencing, amount caps, kill-switch flags — is advisory, defeated by anyone willing to edit their own JavaScript. Controls that must actually hold belong in the contracts or in a server the user does not control. This document describes what these checks protect users from mistakes and from a hostile counterparty, not what they protect the protocol from.
- The repository terminates no TLS and serves no HTTP. Builds are static artifacts uploaded to
S3 and served through a CDN. Response headers (HSTS, CSP-as-header,
X-Frame-Optionson the real origin), TLS policy, and edge rate limiting are deployment responsibilities — see Delivery and response headers. - The user's wallet, browser, extension set, and OS are trusted and unverified. A malicious extension injected into the app's origin, or a wallet that silently signs something other than what it was handed, defeats every control described here. The mitigations below reduce what a remote attacker or a hostile counterparty can do; they do not survive local compromise.
- There is no authentication of users. There are no accounts and no server-side sessions. The only bearer credential in the app is the vault-provider CWT, which the user's own Bitcoin key mints, scoped to one peg-in.
- Roles: the dApp is a transaction constructor and signing orchestrator. It reads from Ethereum, Bitcoin, an indexer, and a vault provider; it writes only through the user's wallet.
- Security boundaries to preserve:
- Assertion of every WASM-returned value before it reaches a signed transaction
(
packages/babylon-tbv-rust-wasm/src/value-guards.ts,.../src/index.ts,packages/babylon-ts-sdk/src/tbv/core/wasm/value-guards.ts,.../primitives/psbt/assertWasmPeginSizing.ts,.../primitives/psbt/constants.ts,.../primitives/psbt/pegin.ts,.../primitives/psbt/peginInput.ts,.../primitives/psbt/refund.ts,.../utils/transaction/fundPeginTransaction.ts) - Agreement between the SDK fee model and the dApp estimate
(
packages/babylon-ts-sdk/src/tbv/core/utils/fee/constants.ts,.../utils/fee/peginFeeMath.ts,.../utils/utxo/selectUtxos.ts,services/vault/src/hooks/deposit/useEstimatedBtcFee.ts) - Local construction of every PSBT the depositor signs, from on-chain-sourced connector data
(
packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts,.../services/deposit/runDepositorPresignFlow.ts) - The frozen vault-secret derivation API and the
VAULT_WASM_COMMITpin (packages/babylon-ts-sdk/src/tbv/core/vault-secrets/,packages/babylon-tbv-rust-wasm/scripts/build-wasm.js) - Pinning each VP token subject to its authoritative on-chain operation key:
the live key for JSON-RPC and the vault's frozen epoch key for gRPC
(
packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/serverIdentity.ts) - Strict shape validation of everything read back from
localStorage(services/vault/src/storage/peginStorage.ts) - Telemetry scrubbing, and the deliberate absence of Sentry tracing and session replay
(
services/vault/src/utils/telemetry.ts,services/vault/sentry.client.config.ts) - The CSP
<meta>tag as the single source of truth, and the SRI build gate (services/vault/index.html,services/vault/src/build/sriPlugin.ts) - Install policy: frozen lockfile, store integrity verification,
minimumReleaseAge, and theonlyBuiltDependenciesallowlist (.npmrc,pnpm-workspace.yaml)
- Assertion of every WASM-returned value before it reaches a signed transaction
(
- High-risk areas (extra review): the nine critical-path groups enumerated in
CLAUDE.md → CRITICAL PATHS and mirrored in
.github/CODEOWNERS, plus:packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/— the whole untrusted-counterparty surface, includingauth/andvalidators.tsservices/vault/src/services/artifacts/artifactDownloadService.ts— ~1 GB partially-validated recovery payloadservices/vault/src/config/env.ts— every deployment parameter, and the fallback pathservices/vault/index.html— the CSPservices/vault/vite.config.ts,services/vault/src/build/sriPlugin.ts— build integrity.github/workflows/,.npmrc— supply chain
The frontend stores no server-side secrets. Its confidentiality assets are the user's own key material and the derived secrets that key material produces; its integrity assets are every value that reaches a signature.
- Vault-secret material — the vault root, WOTS seed and terminals, HTLC preimages, and the
auth-anchor preimage. These are derived in-browser from a wallet-produced context hash. Anyone
holding them for a vault can impersonate the depositor's recovery position. They are enumerated by
exact field name in the
SENSITIVE_FIELD_NAMESdenylist inservices/vault/src/utils/telemetry.ts; that list is the redaction contract, not a convenience. - Vault-provider bearer tokens (CWTs minted by
auth_createDepositorToken). Scoped to one peg-in, short-lived, held in memory only. They must not reachlocalStorage, a URL, or telemetry. - BaBe Decryptor claimer artifacts — the payload that lets a depositor claim independently of the VP. Downloaded to the user's disk; never transmitted anywhere by this app.
- CI credentials —
SENTRY_AUTH_TOKEN, the AWS OIDC role assumed by.github/workflows/service-release-vault.yml, and the npm publish identity used bypackage-release.yml. Compromise of the AWS role means arbitrary JavaScript served to every vault user; compromise of the npm identity means arbitrary code in every downstream consumer. E2E_WALLET_MNEMONIC/E2E_WALLET_PASSWORD(services/vault/e2e/real/secrets.ts). Real signet/Sepolia funds. Sourced from the environment or a gitignored.env.local; must never be committed, printed, or added to a CI log.
- Bitcoin and Ethereum addresses, public keys, amounts, UTXO sets, and raw transaction hex. None
of these is secret — they are public chain data — but their association with one browser session
is a wallet fingerprint.
scrubStringandredactDatainservices/vault/src/utils/telemetry.tsstrip them from Sentry events and breadcrumbs by regex and by field name.
- Every value that enters a signature or an on-chain commitment: HTLC output values, peg-in
amounts, fee totals, payout amounts, the challenger set,
depositorWotsPkHash, and the OPRETURN auth-anchor preimage. Their correctness _is the security property. - The built bundle on S3 and its SRI hashes. Whoever can write to the bucket decides what code runs in every user's browser with their wallet connected.
- A. Malicious or compromised vault provider. Builds the transaction graph the depositor pre-signs, returns the challenger set, and issues bearer tokens. This is the strongest protocol-level position against a depositor: it supplies the values and the metadata that shape what gets signed. Goal: obtain a signature over a spend the depositor did not intend, or withhold material the depositor needs to recover.
- B. Malicious or compromised indexer / GraphQL endpoint. Defines what the UI believes about
every vault's status. Cannot forge a signature, but can induce a user to take an irreversible
action (activate, broadcast, withdraw, repay) against a false picture of the world — which is
exactly the failure the indexer's own
SECURITY.mdnames as its primary risk. - C. Malicious or degraded RPC / mempool / sidecar / utils-api. Defines "what Ethereum said", "what Bitcoin said", fee rates, provider logos, and the sanctions verdict. A wrong fee rate underfunds a transaction; a wrong contract read redirects the app to the wrong contract set.
- D. Attacker in the page's origin. A malicious or compromised browser extension, a successful XSS, or an attacker who can modify the delivered bundle (S3/CDN write, or a build-pipeline compromise). Owns the app outright: can rewrite what the wallet is asked to sign. This is the highest-impact adversary in this repository and the reason CSP, SRI, and supply-chain policy are load-bearing rather than hygiene.
- E. Malicious or non-conforming wallet. May sign something other than what it was handed, may
ignore
useTweakedSigner: false/autoFinalized: false, or may return success for a signature that is invalid. Wallet support for the non-standard taproot script-path flags is genuinely inconsistent — this is an observed condition, not a hypothetical. - F. Supply chain attacker. A malicious npm release inside the dependency tree, a compromised
GitHub Action, or a hostile revision of the external
vault-wasmrepository thatbuild-wasm.jsclones at build time. - G. Operator misconfiguration. A wrong or missing
NEXT_PUBLIC_*value at build time. Because every deployment parameter — contract addresses, RPC URL, chain id, screening endpoint — is a build-time environment variable baked into a static bundle, a config mistake ships as a release. Historically the most likely failure mode for a frontend.
The user is not an adversary in this model. The user is where every impact lands.
This is the domain where a bug destroys value directly. The governing rules live in CLAUDE.md → CRITICAL PATHS; this section states why they exist. Both documents must be updated together.
packages/babylon-tbv-rust-wasm/src/index.ts has guarded JS functions over a Rust/WASM module that
computes htlcValue = peginAmount + depositorClaimValue + p2aAnchorValue + minPeginFee internally.
JavaScript receives numbers with no inherent validation: wasm-bindgen will happily hand back 0n,
and a 0n HTLC value silently produces a transaction that funds nothing. SDK callers reach those
functions through a lazy boundary, packages/babylon-ts-sdk/src/tbv/core/wasm/index.ts, which forwards
without adding guards of its own — the facade's guards still apply.
The Pre-PegIn path adds an independent TypeScript check. It derives every canonical HTLC output and
the Taproot signing data before it creates a PSBT. It rejects a WASM transaction or signing field
that does not match. The canonical transaction constants for this check are in
packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/constants.ts.
packages/babylon-tbv-rust-wasm/src/challengeAssertOutputConnector.ts, spread into both entries,
returns the scriptPubKey of the ChallengeAssert output connector. It range-checks the timelock and
label hashes before WASM and rejects a return that is not a P2TR scriptPubKey. The presign path does
not use the value on its own: it rebuilds each ChallengeAssert around it and compares the txid with
the vault provider's transaction (see "Presigning the depositor graph").
The same entry (src/index.ts, src/index-node.ts) also exports the wasm-bindgen classes directly.
That export is a second crossing, and it is unguarded. No value is checked at that export. The SDK's
public loadTbvWasm() (@babylonlabs-io/ts-sdk/tbv/core/wasm) returns this engine module, so it also
gives callers the classes. Every class consumer must cross-check at the call site. The only SDK
consumer is packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/refund.ts.
It derives the canonical HTLC and signing data in TypeScript before it emits a refund PSBT.
#2361 records the decision to keep these classes unguarded.
The mitigation is assertWasmBigint / assertPositiveBigintArray
(packages/babylon-tbv-rust-wasm/src/value-guards.ts), applied to every value crossing the boundary
before it is used. The input guard is duplicated at
packages/babylon-ts-sdk/src/tbv/core/wasm/value-guards.ts for callers that must not statically
import the WASM engine. The two copies are pinned to identical behaviour by
packages/babylon-ts-sdk/src/tbv/core/wasm/__tests__/value-guards.test.ts and must be changed
together. Reviewer rule, restated from CLAUDE.md:
Every WASM output consumed by JS must be asserted against expected bounds before use. If a WASM-returned value feeds a signed transaction, cross-check it against an independently computed expected value.
Adding a new WASM getter without a guard, or a new class consumer without call-site cross-checks, is the easiest way to introduce a silent wrong-value bug in this repository. A facade guard can be the only check on a path that does not feed a signed transaction. The independent Pre-PegIn checks must remain in place for transaction outputs and signing data.
Two independent implementations must agree before broadcast:
packages/babylon-ts-sdk/src/tbv/core/utils/fee/constants.ts- shared transaction sizing and safety limitspackages/babylon-ts-sdk/src/tbv/core/utils/fee/peginFeeMath.ts— the shared Pre-PegIn vsize/fee modelpackages/babylon-ts-sdk/src/tbv/core/utils/utxo/selectUtxos.ts— UTXO selection with iterative fee recalculationservices/vault/src/hooks/deposit/useEstimatedBtcFee.ts— the dApp-side estimate, which delegates to the SDK model
A mismatch underfunds the transaction. The cross-check assertion belongs at the broadcast site, not only at the estimator — an estimator that agrees with itself proves nothing.
The real SDK model and dApp estimator are covered by
.github/CODEOWNERS and
.github/workflows/critical-path-check.yml. The
critical-path inventory is hand-maintained in six places: this file, CLAUDE.md, CODEOWNERS, the SDK
ESLint config, critical-path-check.yml, and claude-md-drift.yml. The SDK ESLint config contains
only SDK paths because it applies package-level rules. The other five inventories cover the full
repository. Update each applicable inventory when a path moves or is added. The scheduled drift
workflow checks that listed paths exist and reports missing entries to a tracker issue, but it does
not block a pull request. Section 9 includes
pegin-registration-client.ts and payout-script.ts in the tree. It registers
scriptPubKeyAddress.ts ahead of the remaining optional-BTC work (#2228). That path stays in the
drift workflow's pending list until the file lands.
packages/babylon-ts-sdk/src/tbv/core/services/deposit/signDepositorGraph.ts orchestrates the
depositor's signature over the Payout transaction and one NoPayout transaction per challenger. The
transaction hexes come from the vault provider — adversary A.
The core design decision is that PSBTs are built locally, never accepted from the VP. Every field
that enters the Taproot sighash — witnessUtxo, tapLeafScript, controlBlock, tapInternalKey —
is constructed from on-chain connector parameters. A VP that could supply that metadata could make a
depositor's signature valid for a different spend while the displayed transaction looked correct.
The VP also supplies every Claim/Assert transaction pair. Before any wallet signing prompt, the SDK requires every Assert input 0 to spend output 0 of its own Claim. For the depositor graph it also requires the Claim to have exactly one input spending output 1 of the depositor's authoritative PegIn transaction. A VP/VK claimer funds its Claim from its own wallet, and only the depositor can spend PegIn output 1, so claimer Claims are not pinned to it. Without these checks, a self-consistent payout PSBT can still belong to a recovery chain that this vault never funds.
Each NoPayout also spends output 0 of the challenger's ChallengeAssertX and ChallengeAssertY, and the
depositor's signature commits to those parents. The protocol takes no claimer signature on a
ChallengeAssert, so that commitment is all that binds them. A parent that does not spend this Assert,
or whose output 0 is not the timelocked ChallengeAssert output connector, lets the VP and one
colluding challenger spend Assert:0 through NoPayout with no dispute the depositor can answer, which
invalidates the depositor's Payout. Before any signing prompt the SDK rebuilds each challenger's
ChallengeAssertX and ChallengeAssertY from the Assert, at outputs 1 + i and 1 + K + i where i is
the challenger's position in the sorted local ∪ universal set, with output 0 paying the connector
scriptPubKey the WASM engine derives, and requires the VP's txids to match
(packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/challengeAssert.ts). It also pins NoPayout's
version, locktime, input order and sequences, with timelockChallengeAssert on both ChallengeAssert
inputs (packages/babylon-ts-sdk/src/tbv/core/primitives/psbt/noPayout.ts).
packages/babylon-ts-sdk/src/tbv/core/services/deposit/runDepositorPresignFlow.ts runs these checks
right after it fetches the presign response, before the deposit-terms approval and every Payout
signing prompt, and signDepositorGraph repeats them for callers that use it directly.
The rebuild takes two inputs that the VP relays at presign and that it cannot authenticate: the
challenger's GC output label hashes, which the connector commits to, and the Assert, whose outputs
1..2K commit to each challenger's GC WOTS keys. The rebuilt ChallengeAssert is canonical only for
those values. Nothing in this repository authenticates either today. The presign fingerprint only
proves the VP serves the same hashes and Assert at activation. Closing the gap takes two
activation-gate checks, neither implemented
(services/vault/src/services/artifacts/artifactBinding.ts): check (c) compares the graph's label
hashes and GC WOTS keys with the values rebuilt from each challenger's BaBe artifacts, and check (d)
rebuilds the graph from canonical inputs, which is what ties the Assert's outputs to those keys. The
vault-wasm facade exports neither yet; https://github.com/babylonlabs-io/vault-wasm/issues/12 tracks
check (c). A VP and one colluding challenger can still relay hashes with no known preimage, or an
Assert that commits to WOTS keys other than the challenger's. Either leaves the claimer no
WronglyChallenged answer once timelockChallengeAssert has passed.
Two further invariants, both asymmetric in their failure mode:
- Payout value. Re-derive the expected payout from on-chain or WASM-computed sources and assert equality before signing. Never sign a value handed to us.
- Challenger set.
deriveLocalChallengerscomputesLocalChallengersfrom the on-chain VK list, matching the Rust reference inbtc-vault crates/vault/src/tx_graph/graph.rs. The VP-returnedchallenger_presign_dataset must equallocal ∪ universalexactly. Undersigning leaves recovery material missing for an active challenger; oversigning hands a signature to a key the protocol does not recognise. Neither direction is safe to round off.
Signatures produced are verified against the expected sighash
(primitives/psbt/verifyScriptPathSchnorrSignature.ts) rather than trusted because the wallet
returned success.
packages/babylon-ledger-vault-signer/src/ is the host-side client for the Ledger vault app: APDU
framing, the intent-ceremony TLV encoder the depositor physically approves, the device envelope
gate, and (from #2219) the SIGN_PSBT merkleized-PSBT client. A wrong encoding here puts wrong terms
in front of a hardware signer with a trusted screen — the user approves what we built, so building
it wrong defeats the device. Encodings cite firmware/reference-client sources and carry
golden-vector tests; payload bytes are never logged. The golden vectors are generated from
Ledger's pinned reference client over pinned firmware fixtures — review of vector changes is
by whole-tree re-derivation and byte-identity (see the vendor README), and the generators in
packages/babylon-ledger-vault-signer/scripts/ are treated as critical source.
packages/babylon-ts-sdk/src/tbv/core/utils/signing.ts sets useTweakedSigner: false and
autoFinalized: false for taproot script-path spends. Wallet support is inconsistent and failures
are silent — a wallet that ignores the flags returns a signature that is well-formed and invalid.
Every signature produced with these flags must be validated against the expected sighash before the PSBT is treated as signed. A wallet's success return is not evidence.
packages/babylon-ts-sdk/src/tbv/integrations/aave/utils/vaultSplit.ts sizes split outputs and
requires ordered broadcast. Assert sum(splitOutputs) === totalDeposit - fees before signing, and
assert broadcast ordering with explicit sequence checks — not by relying on array iteration order,
which a future refactor can reorder without any test noticing.
The functions listed under
CLAUDE.md → CRITICAL PATHS §4 are marked
@stability frozen. They feed wallet.deriveContextHash and produce on-chain commitments:
depositorWotsPkHash, the HTLC hashlock, and the OP_RETURN auth-anchor preimage.
Any byte-level change to layout, ordering, label, or HKDF info rotates the secrets and
invalidates every existing deposit. Users cannot derive matching keys, cannot activate, and cannot
resume. This is not a compatibility inconvenience — it is permanent loss of access for in-flight
deposits.
Treat any such change as a hard fork requiring: updated golden vectors in btc-vault
(golden_vectors_pinned), in vault-wasm (lib.rs), in vault-secrets/__tests__/expand.test.ts and in
vault-secrets/__tests__/context.golden.test.ts;
for the wallet-side derivation, updated conformance vectors in
vault-secrets/__tests__/deriveContextHash.vectors.test.ts and a coordinated release with every
external implementation that pins them (Ledger vault app, Keystone firmware, OneKey, UniSat); a
matching revision of docs/specs/derive-context-hash.md / docs/specs/derive-vault-secrets.md, the
readable form those implementers build from; and a migration plan for in-flight deposits.
packages/babylon-tbv-rust-wasm/scripts/build-wasm.js pins VAULT_WASM_COMMIT and clones the
external vault-wasm facade repository at build time. That commit — and the btc-vault revisions
it bundles — is the byte-level source of truth for the HKDF info encoding, labels, and i2osp
prefixes.
Two consequences, both easy to underrate:
- A bump of this constant is equivalent to editing the frozen API. If it changes any expander's output, it rotates every user's secrets. Re-run the JS golden-vector gate on every bump, without exception.
- It is a build-time network dependency on another repository. The build fetches and executes code from a remote git revision. The pin is what makes that safe; a floating branch or a force-pushed tag would not be. See Supply chain.
services/vault/src/services/vault/vaultActivationService.ts submits the preimage that unlocks the
HTLC on-chain. A wrong secret locks funds permanently.
When a hashlock is supplied, the SDK re-checks sha256(secret) === hashlock immediately before
assembling calldata — the last defence before the secret enters simulateContract. The vault app
always supplies the required hashlock; SDK consumers must do the same to receive this pre-check.
Preserve that placement: a check performed earlier in the flow proves nothing about the value that
actually ships. Derive the secret only from the source that generated it; never infer it from UI or
storage state.
services/vault/src/services/artifacts/artifactDownloadService.ts fetches the BaBe Decryptor
artifacts a depositor needs to claim their vault independently of the VP. Two properties matter:
- The payload is roughly 1 GB, so it is streamed and retained as a
Blob. Full schema validation is deliberately deferred until the backend supports streaming delivery. Today only small responses (expected JSON-RPC error envelopes, under 4 KiB) are parsed and validated; large responses are checked structurally, from the first 64 KiB of the JSON-RPC envelope prefix only. - This is a known gap. A malicious VP can return a well-formed envelope wrapping a useless or
corrupted artifact body, and the app will accept it, mark the artifacts downloaded
(
services/vault/src/utils/artifactDownloadStorage.ts), and stop warning the user. The user discovers the problem only when they need to claim. Closing this requires end-to-end validation of the artifact body — treat it as a fund-recovery item, not a robustness improvement.
hasArtifactsDownloaded is a localStorage flag whose failure mode is deliberately safe: on quota
error or private browsing, the write silently fails and the gate keeps warning. Preserve that
direction. Never invert it into "assume downloaded on write failure".
services/vault/src/storage/peginStorage.ts holds the funded Pre-PegIn transaction hex, selected
UTXOs, the derived vault id, and the build-time parameter versions — everything needed to resume or
broadcast a deposit. It is simultaneously:
- The only copy of some of that state. Clearing site data mid-flow can strand a deposit.
- Attacker-writable under adversary D, and user-editable via devtools by anyone.
hasValidSecurityFields therefore strict-checks every field used in a security-relevant path —
id and peginTxHash as bytes32 hex, status against a closed enum, UTXO txid as 64-hex,
vout as a non-negative integer, value as a positive safe integer, scriptPubKey as even-length
raw hex, and unsignedTxHex as either the empty cross-device marker or non-empty hex. The comment on
that function names the concrete DoS it prevents: a non-string id would throw inside
normalizeTransactionId, land in the outer catch, and wipe the entire storage key — one tampered
entry destroying every pending deposit record.
Broadcastable statuses additionally require all four build*Version fields, which are asserted
against on-chain vault registration before any resume broadcast. Entries missing them are filtered
out of getPendingPegins rather than broadcast on stale parameters.
Invariant: no value read from localStorage may reach PSBT construction, vault matching, or ID
normalisation without passing the validator. Adding a field that drives a transaction and skipping
the validator reopens this hole.
All VP traffic goes through the vault-provider proxy (NEXT_PUBLIC_TBV_VP_PROXY_URL,
services/vault/src/utils/rpc/vpProxy.ts), addressed per-VP as ${VP_PROXY_URL}/rpc/${vpAddress}.
The proxy is what dereferences provider-supplied URLs and enforces SSRF policy — the frontend never
fetches a provider-supplied URL directly, and must not start.
Server identity is pinned to the chain.
packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/serverIdentity.ts verifies a
BIP-322 signature by the VP's persistent key over
(SERVER_IDENTITY_DOMAIN, ephemeral_pubkey, expires_at), and pins server_pubkey against the
operation key read from the registry contract. The JSON-RPC subject uses the provider's live key;
the gRPC subject uses the key selected by the vault's frozen VP epoch. After a rejected bearer
exposes a stale JSON-RPC pin, the provider re-reads the authoritative live key and retries the
BIP-322 bootstrap once. The frozen gRPC pin never follows live rotation. A mismatch after that
bounded recovery rejects the token. This is what makes the proxy a transport rather than a trusted
party: a compromised proxy cannot mint an identity it does not hold the on-chain key for.
Related bounds worth preserving:
DEFAULT_MAX_PROOF_LIFETIME_SECS(2h) caps how long a leaked VP ephemeral key stays usable. The bearer token's own TTL is a different trust boundary and does not bound this.MAX_EXPIRES_AT_SECSguards against a bogus far-futureexpires_atlocking the token cache on a bad token forever. It is defined inpackages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/auth/tokenProvider.ts.AUTH_GATED_METHODSandGRPC_AUTH_GATED_METHODS(auth/gatedMethods.ts) are marked@stability frozenand must stay in sync with the VP server. The two sets differ by CWT subject; conflating them yields a token the server's interceptor rejects.
Every VP response is validated at runtime.
packages/babylon-ts-sdk/src/tbv/core/clients/vault-provider/validators.ts checks the
security-relevant fields — status enums, txids, pubkey lengths and hex shape — because the generic
JSON-RPC client casts responses without inspection. VpResponseValidationError deliberately splits
a safe user-facing .message from a technical .detail for logs; keep that separation, and keep
VP_ERROR_PREVIEW_MAX_LEN bounded so a hostile VP cannot flood logs through an error path.
JsonRpcClient caps typed responses at 2 MiB by default and retries only read methods. Write
methods are not retried by default — preserve that; a retried write is a duplicate submission.
callRaw is intentionally uncapped for the artifact stream and is the one path where the size bound
does not apply.
services/vault/src/clients/graphql/client.ts reads vault state from
NEXT_PUBLIC_TBV_GRAPHQL_ENDPOINT with a 30s timeout. The endpoint is unauthenticated and the data
is public; the risk is integrity, per adversary B.
The app's defence is not to trust the indexer for anything irreversible: statuses drive UI and
polling, but the values that enter a signature come from chain reads or local derivation. Reviewer
rule: if a new code path lets an indexer-sourced value reach a transaction, a signature, or an
amount displayed as authoritative for an irreversible action, that is a design change, not a
refactor. services/vault/src/context/deposit/ already encodes this distinction — see
terminalMilestones.ts, which explicitly refuses to classify a vault from a localStorage-only
status and requires an indexer-sourced one, and computeDepositPollingResult.ts, which keeps
network-derived state independent of local storage so every tab converges.
Aave reserve identity follows the same rule. services/vault/src/applications/aave/services/fetchConfig.ts
proves each indexed reserve's underlying, hub, asset ID and decimals against the Core Spoke's
getReserve before any market renders, and fails closed on disagreement. The hub is the contract
every rate and liquidity read targets, so an indexer that rewrote it would falsify the figures a
borrow is decided on. Everything else about a reserve stays indexer-sourced: the paused, frozen and
borrowable flags, collateral risk, dynamic config key and collateral factor, which can trail chain
state by the indexer's refresh interval, and the token symbol and name shown in the asset list and
activity.
services/vault/src/clients/eth-contract/client.ts builds a single viem PublicClient over
NEXT_PUBLIC_ETH_RPC_URL with Multicall3 batching and retryCount: 1 (viem's default of 3
amplifies 429s; React Query is the outer retry layer).
The RPC is a trusted dependency with unverified output — the app performs no header or proof
validation. More importantly: NEXT_PUBLIC_TBV_BTC_VAULT_REGISTRY is the address from which the
rest of the contract set is discovered. A wrong registry address redirects the app to an
attacker's contracts wholesale. Treat that build-time variable with the care of a key
configuration, not a URL.
services/vault/src/config/env.ts validates every NEXT_PUBLIC_* input at module load: required
addresses must be valid, non-zero EVM addresses; required URLs must parse and use http:/https:;
ETH_CHAINID must be exactly 1 or 11155111; BTC_NETWORK must be mainnet or signet. Errors
accumulate into envInitError, which GeoFencingProvider turns into a blocking error modal
before the app is usable.
The subtlety worth understanding: on validation failure the runtime is still initialised, with a
known-invalid signet/Sepolia fallback (https://invalid.local). This is deliberate — modules later
in the import graph read config at evaluation time and an uninitialised runtime would crash before
the blocking modal could render. It is only safe as long as the blocking modal genuinely blocks.
Any change that lets the app render an actionable surface while envInitError is set converts a
loud misconfiguration into a silent one operating on fallback network parameters.
Note the interaction with CSP: ALLOWED_URL_SCHEMES accepts http:, but the CSP connect-src
permits only self, https:, wss:, and localhost. A plaintext non-localhost endpoint therefore
passes env validation and is then blocked at runtime by the browser. That is a safe outcome, but it
fails as a CSP violation rather than as a config error — do not rely on env validation alone to keep
production endpoints on TLS.
services/vault/src/clients/address-screening/verifyAddress.ts calls utils-api's screening
endpoint and allows only low / medium risk. AddressScreeningProvider hard-blocks on network
error and deliberately does not cache that outcome, so a retry can succeed. That direction is
correct and matches the caller contract stated in utils-api's own SECURITY.md.
Two properties that must be stated plainly rather than assumed:
- It is fail-open when unconfigured.
UTILS_API_URLis parsed byparseOptionalUrl, which returnsundefinedfor an absent or malformed value after logging a warning;verifyAddressthen returnstrue— allow — for every address. A typo inNEXT_PUBLIC_TBV_UTILS_APIships a build with screening entirely disabled and no user-visible signal. This is the frontend's equivalent ofutils-api's stub-screener risk, and it deserves the same treatment: a deployment gate, or a startup assertion that refuses to run unscreened in a production environment. - It is not an enforcement boundary. The verdict is cached in
localStorage(addressScreeningStorage.ts, 24h TTL, network-scoped key) and both the cache and the code are fully under the user's control. Client-side screening keeps honest users from tripping a compliance control; it stops nobody who wants past it. The same applies to geofencing (GeoFencingProvider) and to theNEXT_PUBLIC_FF_*kill-switches inconfig/featureFlags.ts: useful incident levers, not access control. Anything that must actually hold belongs on-chain.
Provider names, logo URLs, and similar metadata originate from an on-chain registry any registered provider can write, and reach the UI via the indexer and the sidecar. Treat them as attacker-controlled input.
Today the app is safe here for a structural reason worth preserving explicitly: no HTML-injection
sink — dangerouslySetInnerHTML, an innerHTML write, eval, or new Function — exists in
services/vault/src or in any package's source (the only innerHTML references in the tree are
read-only assertions inside tests), so React's default escaping handles every rendered string. That
property is one convenience PR away from being lost. Introducing any HTML sink for provider-sourced content is a
security change requiring explicit argument, not a rendering detail.
External links use target="_blank"; keep rel="noopener" on all of them.
The vault app's CSP lives solely in the <meta http-equiv="Content-Security-Policy"> tag in
services/vault/index.html. services/vault/vite.config.ts documents why it is not also a
dev-server header: delivering it as a header would additionally govern the inline React Fast Refresh
preamble that @vitejs/plugin-react injects above the meta tag, which carries no nonce and would
white-screen pnpm dev. One source of truth, and it is the one that ships to the CDN.
services/simple-staking/index.html has no CSP meta tag. Unless its deployment adds an equivalent
response header, that dApp does not inherit the vault app's script or connection restrictions. Treat
this as a known gap when reviewing simple-staking browser changes.
Current policy:
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
connect-src 'self' https: wss: http://localhost:* ws://localhost:*;
img-src 'self' data: https: blob:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com;
object-src 'none'; base-uri 'self'; form-action 'self';
worker-src 'self' blob:;
frame-src 'self' https://verify.walletconnect.com https://verify.walletconnect.org https://secure.walletconnect.org;
Reviewer notes:
script-srcis the strong directive and must stay that way. No'unsafe-inline', no'unsafe-eval';'wasm-unsafe-eval'is the narrow grant the Rust/WASM module requires. Adding either unsafe keyword toscript-srcwould neuter the single most valuable control against adversary D and is a blocking review item.connect-src https: wss:is the weakest directive and is effectively "any TLS host". It is wide because the app talks to a per-environment set of RPC, indexer, sidecar, VP-proxy, mempool, and WalletConnect relay hosts that varies by deployment. It means CSP provides no meaningful exfiltration control: injected script can reach any HTTPS host. Narrowing this to an explicit per-environment allowlist is the highest-value CSP hardening available here, and it is the precondition for treating CSP as a containment boundary rather than a script-injection control.- The
http://localhost:*andws://localhost:*carve-outs are load-bearing for development, not leftovers:ws://localhostis the Vite HMR socket (a page's'self'does not reliably match thews:scheme) andhttp://localhost:<port>covers the Playwright mock servers inplaywright.config.ts. Removing them breaks HMR and the e2e suite with silent CSP blocks rather than build errors. They are also a live relaxation in production builds — a reason to prefer an environment-specific CSP over one shared string. style-src 'unsafe-inline'is required by the current styling approach. It is a real weakness (CSS-based exfiltration, UI redress) but a much smaller one than a script grant.
services/vault/src/build/sriPlugin.ts adds integrity + crossorigin to every local script and
module-preload in the built index.html, and throws at build time if any local JS script is left
unprotected. Two details that are correct and non-obvious:
- Hashes are computed from the bytes Vite actually writes to disk, in
closeBundle, not from the in-memorychunk.codeduringgenerateBundle. The entry chunk's sibling-import references are rewritten aftergenerateBundle, so a hash taken there is stale and the browser rejects the entry script — a blank app with no error the build would catch. - The unprotected-script check is a build gate, not a warning. Preserve it; a warning here would be ignored exactly once.
SRI protects against a modified asset served from the CDN. It does not protect index.html
itself — nothing does, on a static host. Whoever can write index.html in the bucket controls the
CSP, the SRI hashes, and the script list. This is why the S3/OIDC write path in
.github/workflows/service-release-vault.yml is treated as key material.
The workflow emits per-file and global sha256sum manifests alongside every upload. Those are the
artifact that makes "is what is being served what we built?" answerable — use them.
SECURITY_HEADERS in vite.config.ts (X-Content-Type-Options, X-Frame-Options: DENY,
Referrer-Policy, Permissions-Policy) applies to the dev and preview servers only. Production
is a static S3 origin behind a CDN.
Known gap: whether those headers — and HSTS, which is not set anywhere in this repository — reach
production users depends entirely on CDN configuration that lives outside this repo. The <meta>
CSP does ship, but X-Frame-Options and HSTS cannot be delivered via <meta>. Verify the edge
configuration sets them; do not infer their presence from this file. A dApp that can be framed is a
clickjacking target with a wallet attached.
services/vault/sentry.client.config.ts is unusually explicit about what it does not enable, and
those absences are the control:
- Performance tracing is off.
beforeSend(and thereforescrubSentryEvent) runs only on error and message events — never on transaction envelopes, which need a separate hook. WithbrowserTracingIntegration, auto-instrumented fetch spans carry full request URLs, and this app fetches${UTILS_API_URL}/address/screening?address=<btc-addr>. Enabling tracing without a transaction-side URL scrubber transmits depositor addresses unredacted. - Session Replay is off, for the same structural reason: replay envelopes bypass
beforeSend, andmaskAllText/maskAllInputsdo not mask request URLs orhrefattributes — both of which carry the depositor's BTC address in this app.
Re-enabling either is a privacy change, not a configuration tweak. Each requires its own redaction hook first.
services/vault/src/utils/telemetry.ts handles the rest: regex scrubbing of BTC/ETH/BBN addresses
and long hex, a field-name denylist for values the regexes cannot see (amounts are numbers; secrets
travel under known keys), and — importantly — binary handling. isBinary and isByteArray exist
because Object.entries on a Uint8Array spreads it into {0: 222, 1: 173, …}, leaking every byte
as a plain number, and because Array.from(uint8Array) (how the WOTS terminals are already stored)
defeats ArrayBuffer.isView entirely.
The denylist is keyed on exact field names deliberately, so redaction never depends on a value happening to be 64+ hex. A rename, truncation, or re-encoding in a future refactor must be accompanied by a denylist update — that is the failure mode this design is guarding against, and it is silent.
The Sentry user id is a random UUID in localStorage, not a wallet address. Keep it that way.
packages/babylon-wallet-connector abstracts injected BTC and ETH wallets plus WalletConnect/Reown
(NEXT_PUBLIC_REOWN_PROJECT_ID, absence of which throws rather than degrading). Injected wallet
objects are attacker-adjacent by nature — any extension can inject one. Validate everything received
from a wallet API before use: addresses, public keys (validateWalletPubkey), and signatures. Never
treat a wallet's success return as proof of a correct signature; see Non-standard wallet signing
flags.
For a frontend, the supply chain is the attack surface: a malicious transitive dependency executes in the same origin as the signing flow, with the user's wallet connected.
.npmrc and pnpm-workspace.yaml encode the current posture:
prefer-frozen-lockfilelocally,--frozen-lockfilein every workflowverify-store-integrity = trueminimumReleaseAge = 1440— no package younger than one day is installed. This is the single most effective control against the common "compromised maintainer publishes, gets yanked within hours" pattern. Do not lower it.onlyBuiltDependencies— an explicit allowlist of packages permitted to run install scripts. Everything else installs without executing arbitrary code. Adding an entry here grants arbitrary code execution on every developer machine and CI runner; it is a security review, not a build fix.engine-strict, plus rootpnpm.overridespinning@walletconnect/loggerand@sats-connect/core>axiosto exact versions and enforcing ajs-yaml >=4.1.1minimum-version floor.
CLAUDE.md additionally requires pinned exact versions (no ^) for new dependencies, especially
crypto packages, and an explicit supply-chain audit before adding one. syncpack enforces a single
version policy across the workspace in CI.
Known gap: there is no pnpm audit (or equivalent SCA) gate in
.github/workflows/verify.yml. Nothing in CI fails on a known
vulnerable transitive dependency. Adding one — even advisory-only at first — is the cheapest
available improvement in this section.
Six packages ship to npm from package-release.yml, which runs with id-token: write for
provenance. Downstream consumers of @babylonlabs-io/ts-sdk and
@babylonlabs-io/babylon-tbv-rust-wasm inherit this repository's transaction-construction and
secret-derivation logic wholesale.
Consequences: a bug in the SDK is not confined to Babylon's own dApp, and a compromise of the npm
publish identity is a supply-chain event for every integrator. Release-affecting changes to
package-release.yml, release.config.js, or tools/ warrant the same scrutiny as a change to a
critical path.
packages/babylon-tbv-rust-wasm/scripts/build-wasm.js clones an external repository at a pinned
commit and compiles it during the build. The pin is the entire control: it is what turns
"execute code from a remote repo" into "execute this exact reviewed revision". A branch reference,
a mutable tag, or a skipped verification would remove it. See also The VAULT_WASM_COMMIT pin —
the same constant is simultaneously a supply-chain pin and a protocol-compatibility pin, and the two
roles have different review requirements. Satisfy both.
- All third-party actions are pinned to full commit SHAs with a version comment. Preserve this; a tag reference is mutable.
verify.ymlruns syncpack, a full build, lint, andnx affected --target=test. Note that tests are affected-scoped while build and lint are not — a change that alters behaviour without touching a project Nx considers affected runs fewer tests than a full sweep.critical-path-check.ymlcomments and labels PRs touching critical paths but does not block. Enforcement of the two-approval rule requires a GitHub ruleset scoped to those path globs withrequired_approving_review_count: 2— CODEOWNERS alone cannot express it, as the comment in.github/CODEOWNERSnotes. Verify that ruleset exists; the file cannot.service-release-vault.ymlassumes an AWS role via OIDC per environment and writes the built bundle to S3. Notecontinue-on-erroris set for the production environment in multi-env runs so a prod OIDC failure cannot block devnet — deliberate, and worth knowing when reading a green run.claude-md-drift.ymlchecks the hand-maintained critical-path inventory weekly, records paths that no longer exist, and reports them to a tracker issue. Paths registered ahead of their code sit in apendinglist that is exempt from the existence check and reported separately once the files land. It detects drift but does not gate merges; acting on the tracker or moving the existence check intoverify.ymlis still a human process.pre-review-check.ymlfails a PR tomainwhose description has no/pre-reviewrecord taken on the PR's branch. It is the only workflow triggered bypull_request_target, so it runs with a write token, fork PRs included, frommain's copy of the workflow andscripts/pre-review/. Never check out or run the PR's code in it: every input comes from the event payload (the description from$GITHUB_EVENT_PATH), and author text reaches the bot's comment only inside a fenced block. It gates merges only once a ruleset makes it a required check; that ruleset lives outside this repository — verify it exists. The gate is bypassable by design: theskip-pre-reviewlabel, a hand-written snapshot line (it proves a record is present, not that a review ran), a PR opened by a bot account (for example with a GitHub App token), which the job skips, and a PR adding its own workflow with a same-named job (visible in its diff). Changes toscripts/pre-review/are tested before merge only bypre-review-scripts.yml, a read-onlypull_requestworkflow.
services/vault/e2e/real/ drives a real wallet against signet and Sepolia using
E2E_WALLET_MNEMONIC / E2E_WALLET_PASSWORD, loaded from the environment or a gitignored
.env.local in the wallet-connector package. Keep the mnemonic funded only with disposable testnet
value, never reuse it for anything else, and never let it reach a CI log or an artifact. The
gitignored local file prevents accidental inclusion by ordinary Git commands; no
repository-configured secret-scanning control is present, so review and credential hygiene remain the
only repository-local safeguards.
- Every WASM-returned value is asserted before use, and any value feeding a signed transaction is cross-checked against an independently computed expectation.
- The SDK fee model and the dApp estimate agree, verified at the broadcast site, not only at the estimator.
- Every PSBT the depositor signs is constructed locally from on-chain-sourced connector data. No PSBT, sighash input, or payout value is accepted from the vault provider verbatim. Before any signing prompt, every VP-supplied Assert input 0 spends output 0 of its own Claim, the depositor graph's Claim has exactly one input spending the authoritative PegIn output 1, and every NoPayout's ChallengeAssert parents are the canonical transactions for that Assert and challenger, given the challenger's output label hashes and the Assert's GC WOTS keys as the VP relays them (neither is authenticated yet).
- The VP-returned challenger set equals
local ∪ universalexactly — no missing entries, no extras — withLocalChallengersderived from the on-chain VK list. - Signatures produced with
useTweakedSigner: false/autoFinalized: falseare verified against the expected sighash before the PSBT is treated as signed. - The vault-secret derivation primitives are byte-frozen. Any change to layout, ordering, label,
HKDF
info, orVAULT_WASM_COMMIToutput is a hard fork requiring golden-vector updates on both the Rust and JS sides and a migration plan. - When a hashlock is supplied,
sha256(secret) === hashlockis checked immediately before activation calldata is assembled. The vault app always supplies it; SDK consumers must do the same. The secret is sourced from its generator, never from UI or storage state. - Split outputs sum exactly to
totalDeposit - feesand broadcast order is asserted explicitly. - Each VP token subject's
server_pubkeyis pinned to its authoritative on-chain operation key — live for JSON-RPC and frozen at the vault's epoch for gRPC — and live-key recovery is limited to one chain re-read and one BIP-322 retry. Every VP RPC response passes runtime validation before any security-relevant field is used. - No value read from
localStoragereaches PSBT construction, vault matching, or ID normalisation without passinghasValidSecurityFields. script-srccarries no'unsafe-inline'and no'unsafe-eval', and the SRI build gate fails the build on any unprotected local script.- No HTML sink (
dangerouslySetInnerHTML,innerHTML,eval,new Function) exists in application or package source. - Sentry tracing and session replay stay disabled until a transaction-side and replay-side URL redaction hook exists; the telemetry field denylist stays in sync with the field names secrets actually travel on.
minimumReleaseAge,verify-store-integrity, frozen lockfiles, theonlyBuiltDependenciesallowlist, and SHA-pinned actions remain in force.- A blocking
envInitErrorblocks. No actionable surface renders while the app is running on fallback network parameters.
| Area | Adversary | Scenario | Impact | Mitigation | Test / evidence |
|---|---|---|---|---|---|
| WASM boundary | F/- | WASM returns a wrong Pre-PegIn value, output, or Taproot signing field | User fund loss | Bounds guards plus independent TypeScript derivation of the output layout and signing data | WASM guards; Pre-PegIn, PegIn input, and refund tests |
| Fee model | — | SDK and dApp fee models diverge; the tx is underfunded | User fund loss (stuck / failed deposit) | Shared peginFeeMath; cross-check at broadcast |
SDK fee + selectUtxos tests |
| Critical-path guard | G | A critical path moves but one hand-maintained inventory keeps the stale path | Integrity (process) | Five full inventories align; SDK ESLint mirrors its SDK subset; existence does not gate merges | SECURITY, CLAUDE, CODEOWNERS, ESLint, two workflows |
| Presigning | A | VP supplies PSBT metadata making a signature valid for a different spend | User fund loss | PSBTs built locally from on-chain connector data only | signDepositorGraph tests |
| Presigning | A | VP supplies an unfunded Claim/Assert chain while requesting valid payout signatures | Deposit stalls; recovery chain is unusable | Bind depositor Claim to PegIn:1 and every Assert:0 to its Claim:0 before every signing prompt | graph fingerprint and depositor-presign tests |
| Presigning | A | VP returns a challenger set with an extra or missing key | Recovery material missing / signature to an unrecognised key | deriveLocalChallengers + exact local ∪ universal equality assert |
signDepositorGraph tests |
| Presigning | A | VP and a colluding challenger supply ChallengeAssert parents that do not spend the Assert, or pay output 0 elsewhere | Loss of independent claim capability — Assert:0 spent through NoPayout, invalidating the depositor's Payout | Rebuild each ChallengeAssertX/Y from the Assert around the WASM-derived output connector and match txids; pin NoPayout inputs and sequences; before every signing prompt | challengeAssert.test.ts, noPayout.test.ts, signDepositorGraph.realGraph.test.ts, runDepositorPresignFlow.test.ts |
| Presigning | A | VP and a colluding challenger relay output label hashes with no known preimage, or an Assert committing to GC WOTS keys other than the challenger's | Loss of independent claim capability — no WronglyChallenged answer once timelockChallengeAssert passes |
Known gap — both are bound only by the presign fingerprint; activation-gate checks (c) and (d) are unimplemented, and vault-wasm does not export check (c) yet (https://github.com/babylonlabs-io/vault-wasm/issues/12) | close with activation-gate checks (c) and (d) |
| Wallet signing | E | Wallet ignores useTweakedSigner: false, returns an invalid signature as success |
User fund loss (silent) | Sighash verification of every produced signature | verifyScriptPathSchnorrSignature tests |
| Vault secrets | F/G | VAULT_WASM_COMMIT bump rotates expander output |
Permanent loss of access for every in-flight deposit | Frozen API; JS + Rust golden-vector gates on every bump | vault-secrets/__tests__/expand.test.ts, golden_vectors_pinned |
| Activation | — | Wrong preimage submitted to activateVaultWithSecret |
Funds permanently locked | SDK pre-check when hashlock is supplied; the vault app always supplies it |
SDK activateVault tests |
| Artifacts | A | VP returns a valid JSON-RPC envelope wrapping a corrupt ~1 GB artifact body | Loss of independent claim capability, discovered only at claim time | Known gap — only the envelope prefix is validated for large payloads | close with end-to-end body validation |
| VP auth | A/D | Compromised proxy impersonates a vault provider | Integrity of the whole deposit flow | BIP-322 identity pinned per subject to the live or frozen-epoch on-chain operation key; bounded live-key refresh; 2h ephemeral-key lifetime cap | serverIdentity.test.ts, tokenRegistry.test.ts |
| VP responses | A | Malformed or hostile VP response is cast without inspection | User fund loss / wedged flow | validators.ts runtime checks; 2 MiB typed-response cap; no retry on writes |
validators.test.ts, json-rpc-client.test.ts |
| Indexer | B | Wrong vault status induces an irreversible user action | User fund loss (indirect) | Signature-bound values never sourced from the indexer; terminalMilestones refuses storage-only classification |
deposit-context tests |
| Indexer | B | Indexer rewrites a reserve's hub, asset ID or decimals but keeps its underlying | Borrow decided on false rates, liquidity or Max amount | Every indexed reserve's underlying, hub, asset ID and decimals proven via Core Spoke getReserve |
fetchConfig.test.ts |
| Config | G | Wrong NEXT_PUBLIC_TBV_BTC_VAULT_REGISTRY points the app at attacker contracts |
User fund loss | Strict env validation; blocking modal on failure — but a valid wrong address passes | deployment review |
| Screening | G | Typo'd or unset NEXT_PUBLIC_TBV_UTILS_API disables screening silently |
Compliance bypass | Known gap — parseOptionalUrl warns and returns undefined; verifyAddress then allows all |
add a production startup gate |
| Screening | — | User edits the localStorage verdict or the bundle |
Compliance bypass | None possible client-side — documented as advisory, enforcement belongs server/contract-side | — |
| Local storage | D | Tampered entry throws in normalizeTransactionId and wipes all pending deposits |
Availability, loss of resume state | hasValidSecurityFields strict shape validation |
peginStorage.test.ts |
| Delivery | D | Attacker with S3/CDN write serves modified index.html |
Total compromise — arbitrary code with the user's wallet connected | OIDC-scoped role; SHA manifests per release; SRI protects assets but not index.html |
deployment review |
| CSP | D | Injected script exfiltrates derived secrets to an arbitrary HTTPS host | Confidentiality of vault secrets | script-src blocks injection; connect-src https: provides no exfiltration control |
narrow connect-src per environment |
| CSP | D | Script injection in simple-staking runs without an application-defined CSP | Wallet action manipulation / data exposure | Known gap — no CSP meta tag; verify whether deployment supplies an equivalent response header | services/simple-staking/index.html, deployment review |
| Headers | D | App is framed for clickjacking; no HSTS on the prod origin | Integrity | Known gap — X-Frame-Options/HSTS are dev-server-only and cannot ship via <meta> |
verify CDN configuration |
| Telemetry | — | Re-enabling tracing or replay transmits depositor addresses | Confidentiality (PII) | Both deliberately disabled with documented reasons | telemetry.test.ts |
| Telemetry | — | Refactor renames a secret-bearing field out of the denylist | Confidentiality of vault secrets (silent) | Exact-field-name denylist; binary and byte-array handling | telemetry.test.ts |
| Supply chain | F | Malicious npm release lands in the tree | Total compromise | minimumReleaseAge=1440, frozen lockfile, store integrity, onlyBuiltDependencies, pinned versions |
.npmrc, syncpack in CI |
| Supply chain | F | Known-vulnerable transitive dependency ships | Varies | Known gap — no pnpm audit/SCA gate in verify.yml |
add advisory gate |
| Supply chain | F | Published SDK carries a transaction-construction bug to integrators | User fund loss, off-repo | Provenance on publish; critical-path review | package-release.yml |
| E2E | F | E2E_WALLET_MNEMONIC leaks into a log or the repo |
Loss of testnet funds; credential hygiene signal | Gitignored .env.local; no repository-configured secret-scanning control |
review |
This section is a shared, versioned artifact. It is intended to be byte-identical in every Babylon repository that carries a
SECURITY.md, so that we and external auditors classify findings the same way everywhere. Change it in one place and propagate to all repositories in the same PR series — do not fork the wording per repo. Repository-specific content belongs in the Severity anchors table below, not in the rubric.Version: 1.0 — 2026-07-27
Severity is Impact × Likelihood. Rate impact assuming the bug is exploited successfully; rate likelihood on the preconditions an attacker actually needs.
| Level | Definition |
|---|---|
| I4 — Catastrophic | Direct loss, theft, or permanent freezing of user or protocol funds. Also: causing an irreversible on-chain action to be taken on false information (a user or automated system acts on service output and cannot undo it). |
| I3 — Severe | Compromise of a service or its credentials; bypass of an authentication, authorisation, or compliance gate; forged or corrupted state that is authoritative for a fund-safety decision but not yet acted on; disclosure of secret material (keys, tokens, DSNs, PII at scale). |
| I2 — Moderate | Corruption or loss of non-authoritative state; sustained outage of a user-facing service; disclosure of non-public but non-secret information; a correctness bug that is detectable and recoverable without user loss. |
| I1 — Minor | Degraded performance or partial availability with a clear recovery path; noisy or misleading failure modes; information disclosure with no practical use to an attacker. |
| I0 — Informational | No security impact. Code quality, defence-in-depth hardening, documentation drift. |
| Level | Definition |
|---|---|
| L3 — High | Remotely triggerable by an unauthenticated actor with no special preconditions, or reachable through normal protocol participation (e.g. registering as a provider). |
| L2 — Medium | Requires a realistic but non-default precondition: a specific race, an operator misconfiguration we have actually seen, a privileged-but-not-trusted position, or an unusual-but-reachable input. |
| L1 — Low | Requires compromise of a trusted component or credential, physical/infrastructure access, or a conjunction of unlikely conditions. |
| L3 High | L2 Medium | L1 Low | |
|---|---|---|---|
| I4 Catastrophic | Critical | Critical | High |
| I3 Severe | Critical | High | Medium |
| I2 Moderate | High | Medium | Low |
| I1 Minor | Medium | Low | Low |
| I0 Informational | Informational | Informational | Informational |
Apply after the matrix, and state explicitly which modifier was applied:
- ↑ one level if the bug is silent — it produces a wrong result with no error, log, or metric, so it can persist undetected.
- ↑ one level if exploitation is not attributable to an actor (no way to identify or rate-limit the offender after the fact).
- ↓ one level if a deployed compensating control outside the repository (edge rate limiting, network isolation, monitoring alert) reliably blocks or detects the path — cite the control.
- No downgrade for "requires a malicious insider" if the insider is a role the system explicitly distrusts in its threat model.
Report these as ordinary issues, not security findings: theoretical weaknesses with no reachable path in this codebase; missing hardening with no exploitable consequence; findings that rely on already-assumed-compromised trusted components (list them as assumption violations instead); automated-scanner output without a demonstrated path; and denial of service that requires resources disproportionate to the impact.
Commit hash · affected component and code pointers · minimal proof of concept or precise reproduction steps · expected versus actual behaviour · proposed severity with the Impact and Likelihood levels that produced it, and any modifier applied · suggested remediation if you have one.
Concrete calibration examples for this repository. These are illustrations, not an exhaustive list.
| Severity | Example finding in this repo |
|---|---|
| Critical | Any path that lets a VP-supplied value reach a signature without independent re-derivation — a payout amount, an HTLC value, or PSBT sighash metadata (I4 × L2, ↑ silent). Removal or weakening of a WASM value guard on a path feeding a signed transaction (I4 × L2). A VAULT_WASM_COMMIT bump that changes expander output and ships without golden-vector verification, invalidating in-flight deposits (I4 × L2). Accepting a corrupt claimer-artifact body because only the envelope prefix is validated, discovered only at claim time (I4 × L1 → High, ↑ silent → Critical). A telemetry refactor that renames a secret-bearing field out of the denylist and silently discloses vault secrets (I3 × L2 → High, ↑ silent → Critical). Write access to the vault S3 bucket or the release OIDC role, i.e. arbitrary JavaScript in every user's browser with a wallet connected (I4 × L1 → High by matrix; ↑ raise to Critical, since it is unattributable and defeats every other control). 'unsafe-inline' or 'unsafe-eval' added to script-src (I3 × L3). |
| High | Undersigning or oversigning the challenger set — recovery material missing for an active challenger, or a signature handed to an unrecognised key (I4 × L1). A wrong-but-valid NEXT_PUBLIC_TBV_BTC_VAULT_REGISTRY shipping to production (I4 × L1). A localStorage-sourced value reaching PSBT construction without passing hasValidSecurityFields (I3 × L2). Enabling Sentry tracing or replay without the corresponding URL scrubber, transmitting depositor addresses (I3 × L2). |
| Medium | Screening silently disabled by a typo'd NEXT_PUBLIC_TBV_UTILS_API (I3 × L2 by impact of the compliance bypass, ↓ because the gate is advisory client-side ⇒ Medium; state the reasoning). Missing X-Frame-Options/HSTS at the CDN, enabling clickjacking of the dApp (I2 × L2). A known-vulnerable transitive dependency shipping because no SCA gate exists (I2 × L2). A tampered localStorage entry wiping all pending deposit records (I2 × L2). |
| Low | An external link with target="_blank" and no rel="noopener" (I1 × L2). connect-src https: remaining broad in an environment where a narrow allowlist is feasible (I1 × L2 — raise if paired with any script-injection path). Verbose error text exposing an internal endpoint URL in a user-facing message (I1 × L2). |
| Informational | claude-md-drift.yml reporting stale critical paths to a tracker issue rather than gating merges. nx affected-scoped tests in verify.yml while build and lint are full-sweep. |
When changing this repository, explicitly consider:
- Does the change touch a path in CLAUDE.md → CRITICAL PATHS? If yes: two code-owner approvals, the author can walk every changed line unaided (for generated golden vectors: reviewer-repeated re-derivation instead), and the per-path rule is satisfied. Say so in the PR description.
- New value crossing the WASM boundary: is it asserted before use? Does anything downstream sign it, and if so is it cross-checked against an independent computation?
- Any change to fee math or UTXO selection: does the other implementation still produce the same fee for a representative fixture? Is the assertion at the broadcast site?
- Any change to PSBT construction or signing: could a VP-supplied value now reach a sighash input or an amount without re-derivation? This is blocking.
- Any change to the challenger-set derivation: does exact
local ∪ universalequality still hold, in both directions? - Any change under
vault-secrets/,wots/, orVAULT_WASM_COMMIT: treat as a hard fork. Golden vectors on both sides, docs, migration plan — or do not merge. - New external data source or a new field from an existing one: is the response validated at runtime? Is there a timeout and a size bound? Does a failure fail closed?
- New
localStoragefield: does anything security-relevant read it, and if so doeshasValidSecurityFieldscover it? - New log line, telemetry event, or error message: could it carry a derived secret, a bearer
token, an address, an amount, or raw transaction bytes? Check
SENSITIVE_FIELD_NAMES. - New CSP requirement: can it be satisfied without touching
script-src? Adding aconnect-srchost is routine; adding a script grant is not. - New script or asset in the built output: does the SRI gate still pass? Did you weaken it to make it pass?
- New dependency: exact pinned version, audited, and does it need an
onlyBuiltDependenciesentry? If it does, justify the install-script grant explicitly. - New env var: is it validated in
config/env.ts? Is a wrong or missing value's failure mode loud or silent? Does it silently disable a control when unset? - New user-facing control that looks like a security boundary (a cap, a block, a gate): is it actually enforced somewhere the user cannot edit? If not, say so in the code and here.
- Any change that alters trust boundaries, the attacker model, mitigations, or required tests MUST update this file in the same PR.
- Any change to WASM value guards, fee-model agreement, PSBT construction, challenger-set derivation,
the frozen vault-secret primitives,
VAULT_WASM_COMMIT, the HTLC activation check, VP response validation or server-identity pinning,localStoragevalidation, the CSP, the SRI gate, telemetry scrubbing, or the install policy MUST be treated as a security-model change. - Closing any of the gaps named above — artifact body validation, the unauthenticated challenger output label hashes and GC WOTS keys, the fail-open screening configuration, either dApp's CSP restrictions, production response headers, or the missing SCA gate — MUST update the corresponding section and the severity anchors.
- This file, CLAUDE.md,
.github/CODEOWNERS,.github/workflows/critical-path-check.yml, and.github/workflows/claude-md-drift.ymlcontain the full critical-path inventory. The SDK ESLint config mirrors only the SDK subset. Change each applicable inventory together. They have drifted before. - Security-relevant PRs SHOULD reference the relevant attack-matrix row(s); if a row is missing, add it.
- The Bug severity classification section is shared across repositories. Changes to it must be
propagated to every repository carrying a
SECURITY.md, with the version bumped.