Sample audits · Open-source projects and documentation, eight more

trustwallet/wallet-core

Trust Wallet's cross-platform key and signing library.

Auditedtrustwallet/wallet-core at commit d40d24a63d92619167903369308bf0e2f7eb3a59
Date11 October 2026
How it rancloud session, full audit, Standard review
Verdict after reviewPass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes)
0
High after review
6
Medium after review
46
Low after review
1
Info after review
0
excluded on review
How to read this page

Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 29 findings were first rated Medium or High; 6 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Code locations link to the file and line at the audited commit.

Medium after review (6)

21. Medium CBOR decoder recursion depth is unbounded and controlled by the input
First rating: High · Reviewed rating: Medium · Review: rated too high
Location: src/Cbor.cpp:309 (also line 249)
From the report
Evidence
uint32_t elemLen = nextElem.getTotalLen();
...
uint32_t dataLen = skipClone(typeDesc.byteCount).getTotalLen();
Why it matters

getTotalLen and getCompoundLength recurse once per nesting level, and isValid and getCompoundElements follow the same pattern. A few kilobytes of input such as 0x81 0x81 0x81 … or 0xC0 0xC0 … makes one stack frame per byte. That input is reached through Cardano address parsing after base58 decoding, and through Signer::plan validation of auxiliary data. A stack overflow cannot be caught by the surrounding catch, so it crashes the host app, especially on small-stack worker threads.

Suggested fix, not tested

Add a nesting-depth limit (for example 64) to every recursive walker, or make the length walk iterative. Reject input that exceeds it.

Review: Defect real: Decode::getTotalLen (Cbor.cpp:225-253) calls getCompoundLength, which calls getTotalLen on each child, and the tag case at :249 calls itself directly. There is no depth counter (grep for depth/nesting/MAX finds nothing), so 0x81 0x81 ... recurses once per input byte. Reachable through the public API: AddressV2::parseAndCheck (Cardano/AddressV2.cpp:20) does Base58::decode then Cbor::Decode(...).getArrayElements(), and isValid is the path behind TWAnyAddressIsValid for Cardano. Signer.cpp:71 also runs isValid() on caller-supplied auxiliary data. It needs a base58 string of a few KB on a small-stack thread, or tens of KB on an 8 MB stack, so it is a remote-input crash with no key or signing impact. Fair MEDIUM. Upstream: 2 searches, none found (the first returned only open third-party audit issue #4706, which does not cover CBOR depth).

Upstream: No exact upstream report; related items: #4707 (PR, merged), #4706 (issue, open).

24. Medium ABI array decoder has no budget for the declared element count
First rating: High · Reviewed rating: Medium · Review: rated too high
From the report
Evidence
for elem_idx in 0..len {
    let res = decode_param(kind, tail, new_offset)...;
    tokens.push(res.token);
Why it matters

len comes from the calldata (up to 2^32−1) and is not checked against the remaining data. For element types that consume no bytes (an empty tuple, tuple[]), a 32-byte input pushes billions of tokens. For bytes[] or string[], every element offset can point at the same large blob, which is copied once per element. A 256 KB calldata then allocates about a gigabyte. This is reachable from the public ABI decode functions, and with panic = "abort" the result is a host-process abort or OOM kill.

Suggested fix, not tested

Before the loop, require len × minimum element size ≤ remaining bytes. Reject zero-sized element types, and keep a total decoded-bytes and token budget per call, as the encoder already does with its 4096 cap.

Review: Defect real: ParamType::Array reads len as up to 2^32-1 and loops for elem_idx in 0..len (decode.rs:199) with no check against remaining data. The report's headline claim, "a 256 KB calldata allocates about a gigabyte", is correct only as amplification: each dynamic element still consumes a 32-byte head word (new_offset = offset + WORD_LEN), so the loop is bounded by data.len()/32. With every offset pointing to one blob, take_bytes copies roughly D^2/128 bytes, which is about 0.5 GB at 256 KB and about 128 MB at 128 KB. The zero-size path (tuple[] with an empty tuple) needs a hostile ABI definition, and I found no validation that rejects empty tuples, so it is real but needs the ABI to be attacker-influenced. The public entry points (tw_ethereum_abi_decode_params and decode_value) take the ABI from the app and the calldata from outside. Allocation failure aborts regardless of panic = "abort". Fair MEDIUM (DoS, input-size dependent). Upstream: 1 search ("abi decode array length memory"): 3 results, none about this (#4784 Cardano CIP-20, #3862 abi.encodePacked, #2293 Nervos).

Upstream: No exact upstream report; related items: #4717 (PR, merged).

25. Medium Pactus decoder allocates the declared length before checking the input
First rating: High · Reviewed rating: Medium · Review: rated too high
From the report
Evidence
let len = *VarInt::decode(r)?;
let mut buf = vec![0; len as usize];
r.read_exact(&mut buf)?;
Why it matters

About 15 bytes of transaction hex with a memo length of 2^40 make the library try a terabyte allocation. This is reachable from the transaction-hash utility. Under panic = "abort" the host app is killed.

Suggested fix, not tested

Read through r.take(len) and verify the byte count, or cap len at the protocol maximum before allocating.

Review: Defect real: decode_var_slice does let len = *VarInt::decode(r)?; vec![0; len as usize] (decode.rs:12-13) before read_exact. The memo is decoded at transaction/mod.rs:146, and PactusTransactionUtil::calc_tx_hash_impl (modules/transaction_util.rs:23-25) hex-decodes and deserializes caller input, which TWTransactionUtilCalcTxHash exposes. A memo length of 2^40 asks for a 1 TiB allocation; failure is an unconditional handle_alloc_error abort, so panic = "abort" is not even needed. The weakness is real but limited: it is a hash utility on one niche coin, with a DoS-only effect. Fair MEDIUM.

Upstream: No exact upstream report; related items: #4592 (PR, merged).

28. Medium Build dependency downloaded over plain HTTP and installed as root
First rating: High · Reviewed rating: Medium · Review: rated too high
From the report
Evidence
wget -O "$LCOV_DEB" http://mirrors.kernel.org/ubuntu/pool/universe/l/lcov/lcov_1.15-1_all.deb
sudo apt-get install "$LCOV_DEB"
Why it matters

A local .deb installed this way has no signature verification. Anyone positioned on the network path, or a compromised mirror, can substitute the package. Its maintainer scripts then run as root on developer machines and on every Linux CI runner that builds the wallet library.

Suggested fix, not tested

Use HTTPS and verify a pinned SHA-256 before installing, or use the distribution package or a checksummed source archive.

Review: A local .deb has no apt signature check, so an on-path attacker gets root during install. It is called from linux-ci, flutter-ci, codegen-v2 and the sonarcloud workflows, so it also runs on developer Ubuntu boxes. It is not library code and does not touch shipped artifacts directly. Exploitation needs a network position on a plain-HTTP fetch, and GitHub-hosted runners are ephemeral, so HIGH is too high. Fair MEDIUM, a reasonable supply-chain hygiene issue.

Upstream: No exact upstream report; related items: #4854 (PR, merged).

43. Medium Keystore account parsing reads absent keys and indexes past the end
First rating: Medium · Reviewed rating: Medium · Review: confirmed
Location: src/Keystore/Account.cpp:31 (also lines 34–35 and 43)
From the report
Evidence
if (json[CodingKeys::derivationPath].is_object()) {
...
coin = TWCoinType(uint32_t(derivationPath.indices[1].value));
Why it matters

json is const, so operator[] on a missing key is undefined behaviour. An account with neither coin nor a two-level path indexes an empty vector. A malformed backup file can crash the host app during import, and a crash cannot be caught by the import's catch (...).

Suggested fix, not tested

Check contains() before every read, and reject accounts without a coin or a path of sufficient depth.

Review: Account.cpp:28-43 reads json[CodingKeys::derivationPath] on a const json without checking the key exists (UB, an assert only in debug). If coin is absent, derivationPath.indices[1].value indexes an empty vector (a null-pointer read, which crashes). An activeAccounts entry such as {"address":"x"} is enough. It is reached from keystore import (TWStoredKeyImportJSON, TWStoredKeyLoad) through StoredKey::loadJson, where accounts.emplace_back(accountJSON) has no per-account try (StoredKey.cpp:459-461). The enclosing catch (...) cannot catch a segfault. It is a deterministic crash on every platform from a tiny crafted file, which is worse than #49 on ARM.

Upstream: No matching report among the project's newest 1,000 issues and pull requests (11 October 2026).

49. Medium Crafted scrypt parameters cause a division by zero on keystore import
First rating: High · Reviewed rating: Medium · Review: rated too high
From the report
Evidence
if ((r > std::numeric_limits<uint32_t>::max() / 128 / p) ||
    (n > std::numeric_limits<uint32_t>::max() / 128 / r)) {
Why it matters

validate() runs on JSON from the imported keystore file. With "p": 0 (or "r": 0) it divides by zero. On x86 hosts (desktop, server, simulators) this raises SIGFPE, and catch (...) cannot stop it. On every platform it is undefined behaviour. A single malicious "backup file" crashes the importing app.

Suggested fix, not tested

Reject p == 0 and r == 0 before any division, and set sane bounds on n, dklen and the PBKDF2 iteration count.

Review: Defect real and trivially triggered: ScryptParameters(json) (ScryptParameters.cpp:114-139) assigns p and r from the JSON and calls validate(). validate() at :87-88 evaluates max / 128 / p and max / 128 / r with no zero check (the earlier r*p >= 1<<30 check does not catch 0). It is reached from keystore import through the C API (TWStoredKeyImportJSON, TWStoredKeyLoad). Unsigned division by zero traps on x86 (desktop/server/emulator) and in wasm. From general ISA knowledge (not checked here), AArch64 returns 0, so most phones would fall into the "overflow" error instead of crashing. The C++ catch (...) cannot stop a hardware trap, and the compiler may assume p != 0 since this is UB. The crash needs a user to import a crafted file. Fair MEDIUM.

Upstream: Already reported upstream: #4897 (PR, open), #4898 (PR, closed unmerged).

Low after review (46)

1. Low ed25519 signing is a hand-ported implementation
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
let k = Scalar::from_hash(h);
let s = k * self.key + r;
Ok(Signature { R, s })
Why it matters

Signing is assembled in-house from curve primitives, a port of an old ed25519-dalek release with a pluggable hasher. The signing method also takes the public key as a separate argument. Hand-maintained signature code is the most expensive place to carry a subtle bug, and a past advisory already concerned this exact function. No defect was demonstrated.

Suggested fix, not tested

Use a maintained signature crate (for example ed25519-dalek 2.x) for the standard SHA-512 variant. Keep custom code only for the non-standard hashers, isolated in one small module. Add the RFC 8032 and Wycheproof vectors to CI, and make sure the variant that takes a caller-chosen public key cannot be reached from outside the crate.

2. Low Cardano extended-key scalar construction is flagged as unverified
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
/// TODO make sure if this is the right way to create an extended secret key (extsk).
pub(crate) fn with_extended_secret(secret: H256, extension: H256) -> Self {
    let key = Scalar::from_bytes_mod_order(secret.take());
Why it matters

The code's own comment says the reduction changes the secret, and that it is not certain this is correct. Key-material derivation that is knowingly unverified is a latent risk.

Suggested fix, not tested

Resolve the TODO. Use a vetted BIP32-Ed25519 implementation, or add reference test vectors that prove the signing scalar and public key match the reference.

3. Low secp256k1 ECDH is a hand-written variant with a non-standard KDF
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
fn diffie_hellman(private: &SigningKey, public: &VerifyingKey) -> AffinePoint {
    let public_point = ProjectivePoint::from(*public.as_affine());
    (public_point * secret_scalar).to_affine()
Why it matters

The key agreement does the scalar multiplication by hand and hashes the compressed point with its tag byte, instead of using the library's shared-secret API. This is hard to audit and easy to mismatch with peers.

Suggested fix, not tested

Build on k256::ecdh::diffie_hellman. If the legacy output must be kept, layer it on top and label it as a compatibility-only KDF, with test vectors.

4. Low Polkadot signer silently replaces wrong-length chain hashes with zeros
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let genesis_hash = input.genesis_hash.as_ref().try_into().unwrap_or_default();
let current_hash = input.block_hash.as_ref().try_into().unwrap_or_default();
Why it matters

A genesis or block hash of the wrong length becomes an all-zero hash, and the transaction is signed over it. The caller gets a valid-looking signature that the network rejects, or one bound to the wrong chain context, with no error.

Suggested fix, not tested

Return an invalid-value error when the length is not 32. Allow an empty block hash only where the era makes it legitimate.

Review: try_into().unwrap_or_default() at tw_polkadot/entry.rs:44-45 and tw_polymesh/entry.rs:51-52 turns a wrong-length genesis or block hash into 32 zero bytes (verified). The defect is real, but no chain has an all-zero genesis hash, so the signature cannot be replayed anywhere. It is a caller-error case that the chain rejects, and a bad-input hygiene issue rather than a vulnerability. Both are shipped library code.

5. Low Polymesh signer has the same zero-hash fallback
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
let genesis_hash = input.genesis_hash.as_ref().try_into().unwrap_or_default();
let current_hash = input.block_hash.as_ref().try_into().unwrap_or_default();
Why it matters

This is the same defect as finding 4, in the Polymesh entry point.

Suggested fix, not tested

As in finding 4.

Review: try_into().unwrap_or_default() at tw_polkadot/entry.rs:44-45 and tw_polymesh/entry.rs:51-52 turns a wrong-length genesis or block hash into 32 zero bytes (verified). The defect is real, but no chain has an all-zero genesis hash, so the signature cannot be replayed anywhere. It is a caller-error case that the chain rejects, and a bad-input hygiene issue rather than a vulnerability. Both are shipped library code.

6. Low Pactus transaction id hides encoding failures
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
blake2_b(&self.sign_bytes().unwrap_or_default(), 32).unwrap_or_default()
Why it matters

If encoding fails, the id becomes the hash of an empty buffer. That is the same plausible-looking constant for every failed transaction.

Suggested fix, not tested

Return a Result from id() and propagate the error.

7. Low FFI private-key signing returns an empty signature on error
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
// Return an empty signature if an error occurs.
let sig = private.0.sign(message_to_sign, curve).unwrap_or_default();
CByteArray::from(sig)
Why it matters

A host that does not check the length can treat a failed signature as a success.

Suggested fix, not tested

Return the existing status-carrying result type, or a null array, and document that callers must check it.

8. Low AES-CBC PKCS7 unpadding does not validate the padding
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
const byte paddingSize = result[result.size() - 1];
if (paddingSize <= result.size()) {
    const size_t unpaddedSize = result.size() - paddingSize;
Why it matters

A final padding byte of 0, or any value above 16, is accepted. The function then silently returns the wrong plaintext (untrimmed, or truncated by up to 255 bytes) instead of failing. The padding bytes themselves are never checked. Callers of TWAESDecryptCBC cannot tell corrupt or wrong-key ciphertext from good data.

Suggested fix, not tested

Require 1 <= paddingSize <= 16 and check that every padding byte equals paddingSize, otherwise throw. Prefer an authenticated mode (AES-GCM, or encrypt-then-MAC) for new uses.

Review: Confirmed at Encrypt.cpp:79-84: padding byte 0 or greater than the buffer is accepted, and the padding bytes are not checked. The report's "truncated by up to 255 bytes" is bounded by the ciphertext length, and decryption is unauthenticated CBC anyway, so unpadding is not a validity check. Adding strict checking would create a padding oracle if callers surface the error, so this is not clearly a net security gain. Keystore decryption uses CTR with a MAC, not this function. It is public via TWAESDecryptCBC. I saw an open third-party issue (#4706, F-07) rating the same problem Low.

9. Low Every keystore failure reaches the user as "invalid password"
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
} catch (...) {
    return nullptr;
}
Why it matters

At the C boundary, a wrong password, a corrupt file, an unsupported cipher and an unsupported KDF all become nullptr. The Swift wrapper turns every nil into Error.invalidPassword. A user with a damaged or unsupported keystore is told to retype a password that is in fact correct, and may give up on the funds.

Suggested fix, not tested

Expose the failure cause, as an error code or a result type carrying the existing DecryptionError values. Map each cause to a distinct, understandable error in the Swift, Kotlin and TypeScript wrappers.

Review: TWStoredKey.cpp:246/271/280 have catch (...) { return nullptr; }, and Swift exportPrivateKey (KeyStore.swift:276) maps nil to Error.invalidPassword. All true. This is UX and diagnostics, not a security flaw, and fails closed.

10. Low Missing PBKDF2 iteration count silently becomes the default
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
if (json.count(CodingKeys::iterations) != 0) {
    ...
    iterations = json[CodingKeys::iterations];
Why it matters

A keystore with a missing or misspelt c field is decrypted with 262144 iterations. It then fails as "invalid password" instead of being reported as malformed. Similar silent defaults exist for a missing scrypt salt and for a missing coin (the Ethereum fallback).

Suggested fix, not tested

Treat a missing iteration count as a malformed file, or as an explicitly flagged legacy case.

11. Low Jetifier is still enabled in the Android build
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
android.enableJetifier=true
Why it matters

Jetifier slows every build. It is only needed because of the pre-AndroidX test dependency android.arch.core:core-testing:1.1.1 (android/app/build.gradle:48).

Suggested fix, not tested

Switch to androidx.arch.core:core-testing and set android.enableJetifier=false.

12. Low Android sample app pairs an outdated Kotlin plugin with AGP 7
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
ext.kotlin_version = '1.3.50'
classpath 'com.android.tools.build:gradle:7.2.1'
Why it matters

Kotlin Gradle plugin 1.3.50 predates Gradle 7, which the sample uses (7.3.3). Toolchain mismatches at that boundary show up as confusing build errors for anyone copying the sample.

Suggested fix, not tested

Move the sample to the same AGP, Gradle and Kotlin set as android/ (AGP 8.8.0, Gradle 8.10.2, Kotlin 2.1.0).

13. Low Keystore decryption ignores the stored derived-key length
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
derivedKey.resize(scryptParams->defaultDesiredKeyLength);
scrypt(..., derivedKey.data(), scryptParams->defaultDesiredKeyLength);
Why it matters

The file's dklen is parsed, written back, and used when encrypting, but decryption always uses the default 32. The MAC key comes from the end of the derived key, so any keystore with dklen other than 32 is rejected as "invalid password" even with the right password.

Suggested fix, not tested

Use the parsed dklen in decrypt(), and fall back to the default only when the field is absent. Validate that dklen is at least the cipher key length.

Review: decrypt() uses defaultDesiredKeyLength (EncryptionParameters.cpp:161, 167) while encryption uses scryptParams.desiredKeyLength (:105). A keystore with dklen other than 32 would be rejected as a wrong password. It is an interop limitation that fails closed, with no secrecy impact. Standard files use dklen 32.

14. Low Unknown keystore type is silently read as a private key
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
if (json.count(CodingKeys::SK::type) != 0 &&
    json[CodingKeys::SK::type].get<std::string>() == TypeString::mnemonic) {
    type = StoredKeyType::mnemonicPhrase;
} else {
    type = StoredKeyType::privateKey;
Why it matters

The encrypted payload holds either a mnemonic or a raw key, and only type says which. Any unknown or misspelt value is coerced to "private key".

Suggested fix, not tested

Accept exactly mnemonic and private-key, treat a missing value as legacy, and reject anything else.

15. Low C API payload decryptors do not check the key kind
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
const auto data = key->impl.payload.decrypt(passwordData);
return TWDataCreateWithBytes(data.data(), data.size());
Why it matters

TWStoredKeyDecryptPrivateKey on a mnemonic wallet returns the mnemonic's ASCII bytes as if they were a key. TWStoredKeyDecryptMnemonic on a private-key wallet returns raw bytes as a string. The Swift layer currently uses the first as a generic password check, so this is a contract ambiguity rather than a demonstrated bug.

Suggested fix, not tested

Add kind-checked variants (or document that these functions are kind-agnostic), and keep a separate, explicit password-verification call.

16. Low Encoded private-key export falls back to the mnemonic payload
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
auto data = payload.decrypt(password);
const auto dataHex = TW::hex(data);
Why it matters

On a mnemonic wallet without an encoded payload, this returns the hex of the mnemonic phrase as an "encoded private key".

Suggested fix, not tested

Throw unless the wallet is a private-key wallet.

17. Low Private-key derivation from an extended key accepts an xpub
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
hdnode_private_ckd(&node, path.change());
hdnode_private_ckd(&node, path.address());
return PrivateKey(Data(node.private_key, node.private_key + 32), curve);
Why it matters

deserialize() accepts public versions and leaves private_key zeroed. getPrivateKeyFromExtended then derives from a zero parent, ignores the derivation's return value, and returns a "private key". Anyone holding the xpub can compute it. Funds sent to its address are not safe.

Suggested fix, not tested

Fail unless the version is a private version, and check every hdnode_private_ckd return value.

Review: The mechanics are right: deserialize leaves private_key zero for public versions (HDWallet.cpp:376-378), hdnode_private_ckd return values are ignored, and PrivateKey(...) accepts the result, so the output is derivable from public data. But getPrivateKeyFromExtended has no TW* wrapper (grep: only HDWallet.cpp/.h and tests/common/HDWallet/HDWalletTests.cpp), so it is not reachable through the C, Swift, Kotlin or TS APIs. The report's "funds sent to its address are not safe" only applies to a C++ embedder that calls this internal function with an xpub. It is internal API hardening.

18. Low Adding an account that already exists silently drops the new details
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
if (getAccount(coin, address).has_value()) {
    // address already present
    return;
Why it matters

The "already present" check uses only coin and address. Accounts loaded from legacy files (empty public key and xpub, default path) are never enriched by a later, more complete addAccount.

Suggested fix, not tested

Update missing or different fields on the existing account, or report that it already exists.

19. Low Filled-in address and public key are applied to a copy, not the stored account
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
Account accountLval = account.value();
return fillAddressIfMissing(accountLval, wallet);
Why it matters

The stored account keeps the empty values, so they are re-derived on every call, and the in-memory and persisted states diverge.

Suggested fix, not tested

Write the filled values back to the stored account through one setter.

20. Low Account uniqueness is enforced on add but not on load
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
for (auto& accountJSON : json[CodingKeys::SK::activeAccounts]) {
    accounts.emplace_back(accountJSON);
}
Why it matters

addAccount keeps (coin, address) unique. The public loadJson appends without de-duplicating and without clearing existing accounts.

Suggested fix, not tested

Route loading through the same guard and check the invariants after loading.

22. Low CBOR validation does quadratic work and WebAuthn renders every map key
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
uint32_t len = getCompoundLength(countMultiplier);
...
if (!nextElem.isValid()) { return false; }
idx += nextElem.getTotalLen();
Why it matters

At every level the subtree is walked again, and WebAuthn.cpp calls dumpToString() on every key of an attacker-supplied attestation object. Moderately sized inputs cost far more CPU than their size suggests.

Suggested fix, not tested

Validate in one pass that returns the consumed length under a shared step budget. Compare map keys by type and value instead of rendering them, and cap the number of entries.

Review: Mostly a duplicate of #21: isValid (Cbor.cpp:400) calls getCompoundLength at each nesting level, which is quadratic only for deeply nested input, and that input hits the #21 stack limit first. WebAuthn does call dumpToString() per key, but only in findIntKey (WebAuthn.cpp:98) over the COSE key map. findStringKey uses getString(). The cost is linear per key and called a handful of times.

23. Low CBOR element count is truncated in one walker but not the other
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: src/Cbor.cpp:299 (compare line 402)
From the report
Evidence
uint32_t count = typeDesc.isIndefiniteValue ? 0 : (uint32_t)(typeDesc.value * countMultiplier);
Why it matters

For a declared count of 2^32, the length walker sees an empty array while the validator expects 2^32 elements. The two parts of the decoder disagree on what the structure contains.

Suggested fix, not tested

Reject any declared count larger than the remaining bytes, and compute the count in one place.

26. Low Base58 decoding of address strings has no length cap
First rating: Medium · Reviewed rating: Low · Review: rated too high
Location: src/Base58.h:26
From the report
Evidence
if (string.empty()) { return {}; }
...
Rust::CByteArrayResultWrapper res = Rust::decode_base58(string.c_str(), alphabet);
Why it matters

Address validation decodes the whole string before checking its size, and base58 decoding is quadratic. A pasted or deep-linked multi-megabyte "address" blocks the calling thread for a long time.

Suggested fix, not tested

Reject strings longer than the largest legitimate payload (for example 128 characters, or about 512 for Cardano legacy addresses) before decoding.

Review: Base58.h:26 shows no length cap and the decode is quadratic (the Rust side was not read in full). A multi-MB string would block a thread, but apps normally bound address fields. It is hardening.

27. Low Waves signer emits 64-bit amounts as bare JSON numbers
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
jsonTx["fee"] = fee;
jsonTx["amount"] = amount;
Why it matters

Values above 2^53 are possible and lose precision when a JavaScript host round-trips the JSON. The node then rejects the signature.

Suggested fix, not tested

If the node API requires numbers, document that a BigInt-safe parser is needed. Otherwise also expose the amounts as decimal strings.

29. Low Build inputs are downloaded and compiled with no checksum
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
Location: tools/download-dependencies:18 (also lines 29, 40, 51)
From the report
Evidence
curl -fSsOL https://github.com/google/googletest/releases/download/v$GTEST_VERSION/googletest-$GTEST_VERSION.tar.gz
Why it matters

googletest, libcheck, nlohmann/json and protobuf are fetched over HTTPS but never hash-checked. The cache check also trusts any file already on disk.

Suggested fix, not tested

Store a SHA-256 next to each version in tools/dependencies-version and verify it before unpacking, including for cached files.

30. Low CI scanner binary runs with repository secrets and no checksum
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
curl -sSfL "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/${TARGET}" --output "${TARGET}"
unzip -q "${TARGET}"
Why it matters

The scanner runs with SONAR_TOKEN and GITHUB_TOKEN in the environment.

Suggested fix, not tested

Pin and verify the archive's SHA-256, as the same workflow already does for another jar.

31. Low emsdk is cloned at a moving branch tip and boost headers are unverified
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
git clone https://github.com/emscripten-core/emsdk.git
Suggested fix, not tested

Check out a fixed emsdk commit and verify the boost zip's SHA-256.

32. Low CocoaPods release spec has no checksum for its download
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
s.source = {
    http: '${download_url}'
  }
Suggested fix, not tested

Add sha256: with the tarball's hash, as the Swift package release script already does.

33. Low Dev containers and some CI steps install unverified binaries
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
RUN curl -sSL --proto '=https' --proto-redir '=https' \
	https://download.swift.org/swift-5.8-release/ubuntu2204/swift-5.8-RELEASE/swift-5.8-RELEASE-ubuntu22.04.tar.gz \
Suggested fix, not tested

Verify a pinned checksum, as the same Dockerfile already does for rustup-init. Replace the deprecated apt-key add.

34. Low Release scripts report success after the publish step failed
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
./gradlew :wallet-core-kotlin:publishAllPublicationsToGitHubPackagesRepository -Pversion="$version" || echo "Warning: GitHub Packages publishing failed"
...
echo "Kotlin build uploaded"
Why it matters

A failed publish is turned into a warning, the script exits 0 and prints that the build was uploaded. A release can ship with no artifact and nobody notices.

Suggested fix, not tested

Let the publish failure stop the script, then confirm that the artifact exists at the registry.

Review: Confirmed (kotlin-release:25, android-release:29: || echo "Warning...", then "build uploaded"). The failure is printed as a warning, and Maven-local publishing happens first. It is maintainer tooling with no user impact.

35. Low Code generator aborts on an existing test directory before its "skip" check
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
fs::create_dir(coin_tests_dir)?;
if tw_coin_type_tests_path.exists() {
    println!("[SKIP] {tw_coin_type_tests_path:?} already exists");
Why it matters

create_dir fails when the directory exists, so the skip branch never runs. Scaffolding aborts part-way, after Coin.cpp and the enums have already been edited.

Suggested fix, not tested

Use create_dir_all, as the sibling generators do.

Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.

36. Low Re-running the blockchain scaffold duplicates enum, include and case entries
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
EntryGenerator::generate(coin)?;
TWCoinTypeGenerator::generate_coin_type_variant(coin)?;
BlockchainDispatcherGenerator::generate_new_blockchain_type_dispatching(coin)?;
Why it matters

The first step skips an existing chain, but the next steps insert enum values, includes, entries and case labels unconditionally. Re-running for an existing chain breaks the build with duplicates. The Rust workspace member insertion has the same problem.

Suggested fix, not tested

Check whether each line is already present before inserting it, as the older Ruby editor does.

Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.

37. Low Mobile test skeleton generator overwrites existing test files
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
FileUtils.mkdir_p folder
path = File.join(folder, fileName)
File.write(path, result)
Why it matters

Running the generator for an existing coin (its own usage text suggests ethereum) replaces hand-written Kotlin and Swift tests with skeletons. Uncommitted work is lost.

Suggested fix, not tested

Skip or abort when the file exists unless an explicit overwrite flag is given, as coin_test_gen.rb does.

Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.

38. Low Shared template writer truncates existing files by default
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
let file_to_write = fs::File::create(write_to_path)?;
Why it matters

Safety depends on each of roughly 20 callers checking first, and several check only the parent directory.

Suggested fix, not tested

Open with create_new(true) and make overwriting an explicit option.

39. Low Dependency cache setup is copied into eight workflows and has drifted
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
key: ${{ runner.os }}-${{ runner.arch }}-internal-${{ hashFiles('tools/install-dependencies') }}-${{ hashFiles('tools/dependencies-version') }}
Why it matters

Three workflows include the system-dependency script in the cache key and five do not. A toolchain change can therefore leave stale caches in five pipelines.

Suggested fix, not tested

Move the cache, install and codegen steps into one composite action or reusable workflow.

40. Low Publish-and-list steps duplicated between release scripts
First rating: Low · Reviewed rating: Low · Review: location checked, kept as rated
From the report
Evidence
echo "Publishing to Maven Local..."
Suggested fix, not tested

When fixing finding 34, move the shared publish steps into tools/library so the fix is applied once.


41. Low NUL byte in ABI JSON aborts the host process
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
TWString(CString::new(s).expect("CString::new(String) should never fail"))
Why it matters

The function-signature helper builds its output from a name parsed out of caller-supplied ABI JSON. A name containing \u0000 makes CString::new fail. Because the workspace builds with panic = "abort", the whole app terminates instead of receiving null.

Suggested fix, not tested

Make the conversion fallible (return null, or strip or reject interior NULs), and reject NULs in ABI names.

Review: TWString::from(String) does CString::new(s).expect(...) (tw_string.rs:55). A \u0000 in a function name, plain string in function.rs signature(), reaches it via tw_ethereum_abi_get_function_signature and aborts. It is real, but it requires a hostile ABI JSON, a development-time input in nearly all uses.

42. Low AES-CBC encrypt with zero padding and empty input writes out of bounds
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
const auto resultSize = data.size() + padding;
for (idx = 0; idx < resultSize - blockSize; idx += blockSize) {
    aes_cbc_encrypt(data.data() + idx, result.data() + idx, blockSize, iv.data(), &ctx);
Why it matters

With empty data and zero padding, resultSize is 0 and resultSize - blockSize underflows to a huge value. The loop then reads and writes past empty buffers, causing a crash or memory corruption. TWAESEncryptCBC passes caller input straight through.

Suggested fix, not tested

Return early for empty input, and write the bound without unsigned subtraction (idx + blockSize < resultSize).

Review: Confirmed: paddingSize(0,16,Zero) is 0 (the unit test says so), so resultSize - blockSize underflows size_t (Encrypt.cpp:42-43) and the loop calls aes_cbc_encrypt on an empty vector (null pointers). That is a deterministic crash, not a corruption primitive, and it needs the unusual combination of empty plaintext and zero-padding mode from the caller. LOW.

44. Low Keystore file is overwritten in place
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
auto stream = std::ofstream(path);
...
stream << jsonData;
Why it matters

The public store() truncates the existing wallet file before writing. A crash, kill or full disk mid-write leaves an empty or truncated keystore, which may be the only copy of the encrypted seed.

Suggested fix, not tested

Always write to a temporary file in the same directory, fsync it, then rename it over the target. storeWithTemporaryFile already renames but does not fsync.

Review: store() truncates (StoredKey.cpp:507-519), true. But TWStoredKey.h:288-291 documents exactly this and tells callers to prefer TWStoredKeyStoreWithTemporaryFile, and the Swift keystore (KeyStore.swift:405) uses the temporary-file variant. Missing fsync is a valid hardening note. The wasm fs-storage.ts is the Node reference storage.

45. Low Android random source has no error handling
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
cachedJVM->AttachCurrentThread(&env, nullptr);
...
env->CallVoidMethod(random, nextBytes, array);
Why it matters

None of the JNI calls is checked, and no exception check follows nextBytes. If any step fails, the zero-initialised array is copied out as "random" bytes for keys, salts or IVs. Threads attached here are never detached.

Suggested fix, not tested

Check each result and ExceptionCheck(), and call std::terminate() on failure, as the /dev/urandom branch already does. Detach threads attached here.

Review: Random.cpp:51-68 does no checks on AttachCurrentThread, FindClass, NewObject, or exceptions after nextBytes. The zero-array outcome requires SecureRandom.nextBytes to throw, which is practically unheard of, and the null-class cases crash rather than yield weak keys. It is correct to harden (the /dev/urandom branch terminates on failure) but it is not a likely vulnerability.

46. Low Wallet id is used unsanitised in a file path
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
getFilename(id): string {
  return this.directory + id + ".json";
}
Why it matters

importWallet() stores a wallet under wallet.id taken from the imported JSON. An id such as ../../x reads, writes or deletes .json files outside the keystore directory.

Suggested fix, not tested

Validate ids against a strict pattern (UUID or [A-Za-z0-9._-]+), or resolve the path and confirm that it stays inside the directory.

Review: The wallet id is concatenated into a path (fs-storage.ts:16-17), true, but importWallet(wallet) takes a JS object from the host app and the .json suffix is forced; this is a Node-only reference implementation, and the browser extension uses ExtensionStorage. delete(id, password) ignores the password (:40-41, extension-storage.ts delete), but the password is not an access control for code that already holds the storage handle, so the issue is a misleading signature. The extension-storage id-list read-modify-write race (:29-36) is real, but the wallet record is stored under its own key, so a lost index entry hides a wallet from loadAll() rather than deleting data.

48. Low Wallet-id index update can lose wallets under concurrent writes
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
return this.getWalletIds().then((ids) => {
  if (ids.indexOf(id) === -1) { ids.push(id); }
  return this.storage.set({ [id]: wallet, [this.walletIdsKey]: ids });
Why it matters

Two overlapping set() or delete() calls each read the old id list, and the last writer wins. A stored wallet can disappear from loadAll().

Suggested fix, not tested

Serialise storage mutations through a promise queue, or derive the list from the stored keys.

Review: The wallet id is concatenated into a path (fs-storage.ts:16-17), true, but importWallet(wallet) takes a JS object from the host app and the .json suffix is forced; this is a Node-only reference implementation, and the browser extension uses ExtensionStorage. delete(id, password) ignores the password (:40-41, extension-storage.ts delete), but the password is not an access control for code that already holds the storage handle, so the issue is a misleading signature. The extension-storage id-list read-modify-write race (:29-36) is real, but the wallet record is stored under its own key, so a lost index entry hides a wallet from loadAll() rather than deleting data.

50. Low Failed public-key derivation silently returns the parent key
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
hdnode_public_ckd(&node, path.change());
hdnode_public_ckd(&node, path.address());
hdnode_fill_public_key(&node);
Why it matters

On failure, for example with a hardened index or a private extended key whose public key is empty, the node is left unchanged. The account-level key is then returned as the derived child. The caller shows or uses the wrong receive address without any error.

Suggested fix, not tested

Return std::nullopt when either derivation call returns 0.

Review: HDWallet.cpp:288-290 ignores hdnode_public_ckd results. hdnode_public_ckd_cp returns 0 for hardened indices (bip32.c, verified), so the node stays unchanged. The input has to be a hardened final index or a private xprv, which is caller error. It is reachable via TWHDWalletGetPublicKeyFromExtended. It is a missing error return, not a vulnerability.

51. Low Backward-compatibility sign-off gate passes when a PR is opened
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
// scan-and-flag posts the reminder → that triggers an issue_comment event
// → this job runs, finds the reminder, and fails until a sign-off exists.
if (!hasReminder) { core.info('No BC-risk reminder posted; nothing to verify.');
Why it matters

The reminder is posted with the default workflow token, and comments made with that token do not trigger new workflow runs. The cascade the gate relies on never happens. On PR open the verify job runs alongside the scan, finds no reminder, and reports success. A PR touching keystore, proto or registry paths can merge without sign-off if it is not pushed again.

Suggested fix, not tested

Make the verify job depend on the scan job (needs:), or compute the flagged paths inside it, and fail whenever flags exist without a sign-off.

Review: #51's mechanism is right: the verify job has no needs and passes when no reminder exists yet (workflow lines 222-232), and comments made with GITHUB_TOKEN do not trigger new runs. The gap is narrow, because any later push, edit or comment re-runs verify and then fails correctly, and line 3 of the file says the check only blocks if configured as a required status check. #52 is by design ("reviewers judge", line 155). This is process tooling for backward-compatibility review, not a security gate.

52. Low Backward-compatibility sign-off is accepted from any commenter
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
const signoff = comments.find(c => {
  const tokenMatch = c.body.match(tokenRegex);
  if (!tokenMatch) return false;
Why it matters

Any account that can comment, including the PR author, can satisfy the gate with the token and 60 characters of text.

Suggested fix, not tested

Require author_association of OWNER, MEMBER or COLLABORATOR, preferably not the PR author, or use CODEOWNERS review on these paths.

Review: #51's mechanism is right: the verify job has no needs and passes when no reminder exists yet (workflow lines 222-232), and comments made with GITHUB_TOKEN do not trigger new runs. The gap is narrow, because any later push, edit or comment re-runs verify and then fails correctly, and line 3 of the file says the check only blocks if configured as a required status check. #52 is by design ("reviewers judge", line 155). This is process tooling for backward-compatibility review, not a security gate.

53. Low Blockchain scaffold script calls a non-existent path
First rating: Medium · Reviewed rating: Low · Review: rated too high
From the report
Evidence
pushd codegen
codegen/bin/newcoin-mobile-tests $1
popd # codegen
Why it matters

After pushd codegen the path resolves to codegen/codegen/bin/…, which does not exist (searched; only codegen/bin/newcoin-mobile-tests exists). The mobile test skeletons are never generated. Because the script has no set -e or argument check, the failure is easy to miss.

Suggested fix, not tested

Call bin/newcoin-mobile-tests "$1", add set -euo pipefail, and require an argument.


Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.

Info after review (1)

47. Info Wallet deletion takes a password but never checks it
First rating: Medium · Reviewed rating: Info · Review: rated too high
From the report
Evidence
delete(id: string, password: string): Promise<void> {
  return fs.unlink(this.getFilename(id));
}
Why it matters

The API implies an authentication check, as the Swift keystore performs, but any caller can delete a wallet.

Suggested fix, not tested

Decrypt the stored key with the password before deleting, or remove the parameter so the contract is honest.

Review: The wallet id is concatenated into a path (fs-storage.ts:16-17), true, but importWallet(wallet) takes a JS object from the host app and the .json suffix is forced; this is a Node-only reference implementation, and the browser extension uses ExtensionStorage. delete(id, password) ignores the password (:40-41, extension-storage.ts delete), but the password is not an access control for code that already holds the storage handle, so the issue is a misleading signature. The extension-storage id-list read-modify-write race (:29-36) is real, but the wallet record is stored under its own key, so a lost index entry hides a wallet from loadAll() rather than deleting data.

Want this for your code?

Upload a ZIP or link a public GitHub repo, pick the areas and the depth, and get findings by severity with fixes.