aere-quantum/crypto-inventory/README.md

25 KiB

Aere crypto inventory

A cryptographic inventory of source code. It reads a directory, finds where cryptography is used (algorithms, key sizes, curves, modes, TLS settings, JWT algorithms, key and certificate files), classifies each use by its exposure to a quantum computer, suggests a migration target, and writes the result as a CBOM (Cryptography Bill of Materials) in CycloneDX 1.6 format.

It is a single Node.js script with no dependencies: only built-in node:* modules. Nothing from the scanned tree is executed, and no network request is made.

node tools/crypto-inventory/inventar.mjs scan ./my-service --out cbom.json --summary

What it detects

Detection is pattern-based on the source text, after comments (and Python docstrings) are removed. An API name that appears only inside a comment or inside a string literal is not reported as a use.

Language What is recognized
JavaScript / TypeScript node:crypto (createHash, createHmac, createCipheriv, createSign/createVerify, generateKeyPair(Sync) for rsa, rsa-pss, dsa, ec, ed25519, x25519, dh, ml-dsa-*, ml-kem-*, slh-dsa-*, createECDH, getDiffieHellman, publicEncrypt, pbkdf2, hkdf, crypto.sign/verify, createPrivateKey/createPublicKey, X509Certificate); WebCrypto subtle (RSA-OAEP, RSASSA-PKCS1-v1_5, RSA-PSS, ECDSA, ECDH, Ed25519, X25519, AES-GCM/CBC/CTR/KW, HMAC, HKDF, PBKDF2, digest); TLS options minVersion, maxVersion, secureProtocol, ecdhCurve; libraries from imports: ethers, web3, viem (secp256k1 ECDSA), secp256k1, elliptic, node-forge, jsonwebtoken/jose (JWS algorithms), @noble/curves, @noble/post-quantum, @noble/hashes, crypto-js, tweetnacl/libsodium, node-rsa, bcrypt/argon2, @signalapp/libsignal-client (PrivateKey.generate, IdentityKeyPair.generate, KEMKeyPair.generate, hkdf, Aes256GcmSiv.new, PreKeyBundle.new / processPreKeyBundle, anchored on the package import)
Python cryptography.hazmat (rsa, ec, dsa, dh, ed25519, x25519, padding, hashes, algorithms/modes, AEAD classes); PyCryptodome (Crypto/Cryptodome: RSA, DSA, ECC, ciphers and modes, PKCS1_OAEP, pss, DSS, hash .new); hashlib (including hashlib.new and pbkdf2_hmac); hmac with digestmod; ecdsa; PyJWT / python-jose algorithms; oqs (liboqs-python); ssl protocol constants and TLSVersion
Java KeyPairGenerator, Signature, Cipher, KeyAgreement, MessageDigest, Mac, KeyGenerator, KeyFactory, SecretKeyFactory, KEM, SSLContext getInstance("..."); ECGenParameterSpec, NamedParameterSpec; setEnabledProtocols; JSSE system properties (jdk.tls.namedGroups, protocol lists); Bouncy Castle imports and post-quantum parameter sets (org.bouncycastle.pqc, MLKEMParameters, MLDSAParameters, SLHDSAParameters, FalconParameters, ...); the libsignal Java/Android API (org.signal.libsignal, anchored on its import or package: ECKeyPair.generate, IdentityKeyPair.generate, KEMKeyPair.generate(KEMKeyType.KYBER_1024), HKDF.deriveSecrets, new Aes256GcmSiv / Aes256GcmEncryption / Aes256Ctr32, new PreKeyBundle)
Go imports of crypto/rsa, crypto/ecdsa, crypto/elliptic, crypto/ecdh, crypto/ed25519, crypto/md5, crypto/sha1, crypto/des, crypto/rc4, crypto/mlkem, crypto/tls, golang.org/x/crypto/..., go-ethereum crypto, CIRCL ML-KEM/ML-DSA; calls with parameters (rsa.GenerateKey size, ecdsa.GenerateKey curve, hmac.New hash, mlkem.GenerateKey768); tls.Config MinVersion, MaxVersion, CurvePreferences (including tls.X25519MLKEM768)
Rust use/extern crate resolution of names to crates; static calls Type::method (Sha256::new, SigningKey::generate, Aes256Gcm::new_from_slice, RsaPrivateKey::new(rng, 2048), MlDsa65::key_gen, MlKem768::generate); generic parameters (Hmac::<Sha256>, Hkdf::<Sha512>, type HmacSha256 = Hmac<Sha256>, SigningKey::<Sha2_128s>); path functions (blake3::hash, pbkdf2::pbkdf2_hmac::<Sha256>, pqcrypto_kyber::kyber1024::keypair, rustls::crypto::ring::default_provider); ring constants (aead::AES_256_GCM, signature::ED25519, agreement::X25519); jsonwebtoken::Algorithm::*, oqs::Algorithm::*, libsignal kem::KeyType::Kyber1024; RustCrypto modes (ecb::Encryptor<Aes128>, cbc::Decryptor<Aes256>); snow Noise patterns (Noise_XX_25519_ChaChaPoly_SHA256); openssl and the BoringSSL bindings boring / boring-signal with the same API (MessageDigest::*, Cipher::*, Rsa::generate, EcKey::generate + Nid, PKey::generate_ed25519, SslConnector::builder, set_min_proto_version); secp256k1 .sign_ecdsa/.sign_schnorr; libcrux (libcrux_ml_kem::mlkem1024::generate_key_pair, the pre-standard kyber768 / kyber1024 modules, libcrux_ml_dsa::ml_dsa_65::sign); x25519_dalek::x25519(...); HmacReset; a mode type brought in by name (use cbc::Decryptor; Decryptor::<Aes256>::new); the hybrid Noise pattern Noise_NKhfs_25519+Kyber1024_... as one hybrid key exchange; a name brought in by a glob import (use libsignal_protocol::*) is resolved by trying the glob-imported crates in order
Swift names are resolved through import: CryptoKit / swift-crypto (SHA256.hash, SHA384, SHA512, Insecure.MD5, Insecure.SHA1, HMAC<SHA256>, AES.GCM.seal/open (key size from a SymmetricKey(size:) on the same line), AES.KeyWrap, ChaChaPoly.seal, Curve25519.KeyAgreement.PrivateKey(), Curve25519.Signing.PrivateKey(), P256/P384/P521.Signing and .KeyAgreement, HKDF<SHA256>.deriveKey, SymmetricKey(size:), _RSA from _CryptoExtras); CommonCrypto (CC_SHA256, CC_MD5, CCCrypt / CCCryptorCreate with kCCAlgorithm*, kCCOptionECBMode, kCCKeySizeAES*, CCHmac with kCCHmacAlg*, CCKeyDerivationPBKDF with kCCPRFHmacAlg*); Security.framework (SecKeyCreateRandomKey with kSecAttrKeyTypeRSA / kSecAttrKeyTypeECSECPrimeRandom and kSecAttrKeySizeInBits, read from the call or from an attribute dictionary defined in the same file; SecKeyCreateSignature / SecKeyVerifySignature / SecKeyCreateEncryptedData / SecKeyCreateDecryptedData with .rsaSignatureMessagePKCS1v15SHA256, .rsaSignatureMessagePSSSHA256, .ecdsaSignatureMessageX962SHA256, .rsaEncryptionOAEPSHA256, ...; SecTrustEvaluateWithError / SecPolicyCreateSSL as certificate-chain validation); the client's TLS (tlsMinimumSupportedProtocolVersion / tlsMaximumSupportedProtocolVersion, sec_protocol_options_set_min/max_tls_protocol_version, NWProtocolTLS.Options()); CryptoSwift (AES(key:blockMode:), SHA2(variant:), .md5(), HMAC(key:variant:), PBKDF2, ChaCha20, Poly1305, RSA(keySize:), Blowfish); swift-sodium / Clibsodium (sodium.box, .sign, .keyExchange, .secretBox, .aead.xchacha20poly1305ietf, .pwHash, .genericHash, crypto_box_*, crypto_sign_*, crypto_aead_*, ...); the libsignal Swift API (import LibSignalClient, or import SignalFfi and the Sources/LibSignalClient/ target directory for the library's own sources: IdentityKeyPair.generate(), PrivateKey.generate(), KEMKeyPair.generate() (Kyber-1024: the Swift API takes no key type, the bridge generates kem::KeyType::Kyber1024), KEMPublicKey(...), Aes256GcmSiv(key:), Aes256GcmEncryptedData, Aes256GcmEncryption / Aes256GcmDecryption, Aes256Ctr32, hkdf(...), PreKeyBundle(...) / processPreKeyBundle(...))
Kotlin the Java rules with Kotlin syntax (no new, no ;, val/var, arrayOf(...), ECGenParameterSpec("...")): JCA getInstance("..."), SSLContext, setEnabledProtocols, Bouncy Castle imports, the libsignal Java/Android API without new (ECPrivateKey.generate(), ECKeyPair.generate(), KEMKeyPair.generate(KEMKeyType.KYBER_1024), KEMPublicKey(bytes), HKDF.deriveSecrets, Aes256GcmSiv(key), PreKeyBundle(...), SealedSessionCipher(...)) and the library's own native bridge inside org.signal.libsignal (Native.ECPrivateKey_Generate(), Native.ECPrivateKey_Agree / _Sign / _HpkeOpen, Native.KyberKeyPair_Generate(), Native.HKDF_DeriveSecrets, Native.Aes256GcmSiv_New); plus rules added for the JVM in 0.4.0 that apply to Java too: Android Keystore (KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore") with the size, curve, block mode and padding read from the KeyGenParameterSpec.Builder passed to initialize / init, inline or through a variable), Tink (KeyTemplates.get("AES256_GCM"), AeadKeyTemplates.X, PredefinedAeadParameters.X; *Config.register() and the import count as import-level evidence), OkHttp (ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS), .tlsVersions(TlsVersion.TLS_1_2), .cipherSuites(CipherSuite.TLS_...), CertificatePinner.Builder()), and certificate-chain validation (CertPathValidator.getInstance("PKIX"), TrustManagerFactory.getInstance, X509TrustManager, checkServerTrusted, KeyStore.getInstance("AndroidCAStore"))
Any text file complete PEM blocks: certificates (parsed for subject, validity and key algorithm), public keys, private keys
Manifests package.json, requirements*.txt, pyproject.toml, pom.xml, build.gradle(.kts), libs.versions.toml, go.mod, Cargo.toml, Package.swift, Podfile, *.podspec: cryptographic libraries that are declared (pkg:swift/github.com/apple/swift-crypto@3.0.0, pkg:cocoapods/CryptoSwift@1.8, pkg:maven/org.signal/libsignal-android@0.105.1)

In Go an unused import does not compile, so an import of a crypto package is evidence of use; the import-level finding is dropped when the same file has a call-level finding that covers it. A blank import (_ "crypto/sha512") is reported with a note that it only registers an implementation.

In Rust (added in 0.3.0, 2026-10-18, after the first run on a tree of 1,320 Rust files returned zero Rust findings) the use declarations decide what a name means: SigningKey is Ed25519 when it comes from ed25519-dalek, ML-DSA from ml-dsa, SLH-DSA from slh-dsa, ECDSA from p256/k256, RSA from rsa. A name like that with no use naming its crate is reported as unknown with the candidates listed, not guessed. An unused import is only a warning in Rust, so an imported crypto crate is reported as an import finding only when the file has no recognized call from it. Not recognized: methods on values (.sign(), .encrypt()) without their constructor in the same file, macros, and a project's own wrapper types (the file that calls the crate directly is the one reported).

In Swift and Kotlin (added in 0.4.0, 2026-10-18, after the run on libsignal showed 321 files and 20.5% of the source lines not analyzed at all) the import lines decide what a name means, as the use lines do in Rust: SHA256.hash is CryptoKit only in a file that imports CryptoKit or Crypto, IdentityKeyPair.generate() is libsignal only with import LibSignalClient (or, inside the library, import SignalFfi or the Sources/LibSignalClient/ target directory, because Swift does not declare the module in the file). A third-party library imported without a recognized call (LibSignalClient, CryptoSwift, Sodium) is reported once as import-level evidence; the system frameworks (CryptoKit, CommonCrypto, Security) are not, since Security is imported for the keychain in almost every app. Certificate-chain validation (SecTrustEvaluate*, JCA CertPathValidator("PKIX"), X509TrustManager, OkHttp pinning) is classified as a classical-signature exposure: the chain is signed by the CA with RSA or ECDSA, which a quantum computer could forge; it is an authentication exposure, not a key exchange, and nothing in that code alone can change it. Swift and Kotlin block comments nest; Swift string interpolation \( ) and Kotlin templates ${ } may contain strings and brackets, so the lexer scans them with an explicit stack (iteratively: a file made of "\( repeated would be tens of thousands of levels deep). The content of a string, interpolation included, is never a use. Not recognized: methods on values without their constructor in the same file (generateSignature, signalEncrypt), a project's own wrapper types, a module imported through an Objective-C bridging header, #if DEBUG as a scope, Kotlin extension functions over JCA.

When the algorithm is given by a variable, the tool resolves it only when the file assigns that name exactly once, to a plain string literal, and the name is not a function parameter or loop variable (template literals whose ${...} parts all resolve this way are also resolved). The finding then records where the value came from (resolvedFrom). In every other case the finding is unknown: the tool does not guess. At most 64 distinct variables are resolved per file; past that, variables in that file are reported as unknown, and the file is counted (files-over-resolution-limit in the CBOM, a line in the text summary).

Cost on hostile input

The scanner reads repositories it did not write, so its cost is kept linear in the size of each file, also for input built to be slow. Version 0.1.0, which was never published, had six places where a crafted file cost quadratic time, minutes for a single file of the default maximum size: PEM block search, JavaScript regex literals, Python triple-quoted strings on a long line, variable resolution repeated for every call, open-ended parameter-list patterns used by that resolution, and the assigned-variable lookup in Java. An adversarial review found them on 2026-09-29 and they were rewritten for 0.2.0; the rewritten forms give the same findings as the old ones: on 2026-09-29, test/echivalenta-0.1.0.mjs (which takes 0.1.0 from git history) found no difference on about 4,500 source files of the repository where the tool is developed, including three large npm packages, nor on 300,000 generated inputs to the lexer and the PEM search. test/proba-timp.mjs keeps the cost linear, and test/control-negativ-timp.mjs shows that it turns red when a quadratic form comes back. One cost remains bounded rather than linear: the arguments of a call are read for at most 4,000 characters, so a file made of thousands of unclosed calls is slower (about 10 seconds per megabyte on the machine where it was measured) but still finishes.

Classification

Every finding carries one class, a written reason and, when action is needed, a recommendation.

Class Meaning
quantum-vulnerable Broken by Shor's algorithm on a cryptographically relevant quantum computer: RSA, DSA, finite-field DH, ECDSA, ECDH, EdDSA and X25519/X448 on any classical curve, secp256k1, pairing-based schemes. Also TLS configurations that allow TLS 1.2, which has no standardized post-quantum key exchange.
weak-now Unsafe against classical attackers today: MD5, SHA-1, DES, 3DES, RC4, RC2, 64-bit-block ciphers, ECB mode, SSL and TLS below 1.2, RSA/DH below 2048 bits, curves below 112-bit security, JWT none. When the algorithm is also quantum-vulnerable (for example RSA-1024, or an RSA signature over SHA-1), the finding says so (quantumVulnerable: true).
quantum-safe ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205), Falcon, hybrid key-exchange groups such as X25519MLKEM768, AES, ChaCha20-Poly1305, SHA-2, SHA-3, HMAC and KDFs over them. For AES-128 the reason notes that Grover's algorithm gives at most a quadratic speed-up (NIST category 1) and that AES-256 is preferred for long-lived data.
unknown The algorithm cannot be determined statically: it comes from a parameter, from configuration, from a key loaded at run time, or from a library that offers many algorithms without a recognized call.

Recommendations are concrete but do not pretend a migration is simple. Examples: key exchange in TLS moves to X25519MLKEM768; signatures move to ML-DSA-65 or a hybrid classical+ML-DSA signature (the X.509 composite signature format was still an IETF draft when the rules were written); secp256k1 in a wallet moves at the account level to an account with a post-quantum owner, which is a protocol change and not a library swap; for JWT the tool says plainly that no final post-quantum JWS standard exists yet. Statements about standards and runtime defaults reflect the time the rules were written; check the current status before acting on them.

A finding states which algorithm appears where. It does not judge intent: a TLS scanner that deliberately offers classical groups to test a server will be reported like any other client.

Output

CBOM (CycloneDX 1.6)

  • bomFormat: "CycloneDX", specVersion: "1.6", serialNumber (urn:uuid:...), version: 1.
  • metadata.timestamp, metadata.tools.components (this tool and its version), metadata.component (the scanned directory), metadata.properties (scan statistics: files seen, analyzed, not read and why, excluded directories, limits).
  • One component of type: "cryptographic-asset" per distinct asset, with a stable bom-ref derived from its content:
    • cryptoProperties.assetType: algorithm, protocol, certificate or related-crypto-material.
    • algorithmProperties: primitive, parameterSetIdentifier, curve, mode, padding, cryptoFunctions, classicalSecurityLevel, nistQuantumSecurityLevel (0 for quantum-vulnerable and weak algorithms; omitted when not defined).
    • protocolProperties: type: "tls", version.
    • certificateProperties: subject, issuer, validity, format.
    • relatedCryptoMaterialProperties: type (private-key or public-key), format, size. The value of key material is never written.
    • oid where it is standard and certain.
    • evidence.occurrences[]: location (path relative to the scanned directory), line, symbol (the API), additionalContext (the matched call, not the source line).
    • properties in the aere:crypto-inventory: namespace: classification, quantum-vulnerable, reason, recommendation, languages, evidence-kinds, resolved-from.
  • Declared libraries from manifests are components of type: "library" with purl, the manifest location, and aere:crypto-inventory:declared-only = true. Declaration is not use: a library can be declared and never called, and the cryptographic-asset components are the evidence of use.

--deterministic derives the serial number from the content and omits the timestamp, so two runs on the same tree produce identical files.

Text summary (--summary)

Files seen, analyzed per language, checked for PEM only, and not read, with the reason (binary, too large, over the file limit, unreadable), plus excluded directories and symlinks not followed. Nothing is skipped silently: the scan fails with an accounting error if the categories do not add up to the number of files seen. Then counts per class and per language, the most frequent assets, the files with quantum-vulnerable findings, and the declared libraries.

Command line

node inventar.mjs scan <dir> [options]

  --out <file>            write the CBOM to <file> (default: stdout, unless --summary is given)
  --summary               print the text summary
  --fail-on <list>        exit 1 when findings match: vulnerable, weak, unknown, any (comma-separated)
  --max-file-bytes <n>    skip and count files larger than n bytes (default 1048576)
  --max-files <n>         read at most n files; the rest are counted, not read (default 50000)
  --exclude-dir <name>    do not descend into directories with this name (repeatable)
  --no-default-excludes   also descend into .git, node_modules, __pycache__, .venv, venv, ...
  --deterministic         reproducible output (content-derived serial number, no timestamp)
  --findings-json <file>  write the raw findings, one object per occurrence

Exit codes: 0 success, 1 the --fail-on condition is met, 2 usage or scan error.

--fail-on vulnerable matches every finding a quantum computer would break, including weak-now findings that are also quantum-vulnerable.

In CI

- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with:
    node-version: 24
- name: Cryptographic inventory
  run: node tools/crypto-inventory/inventar.mjs scan . --out cbom.json --summary --fail-on vulnerable
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: cbom
    path: cbom.json

Start with --fail-on weak if the goal is to block known-broken algorithms first, and add vulnerable once a migration plan exists; failing a build on every ECDSA use from day one usually only teaches people to disable the check.

As a module

import { scan, buildCbom, renderSummary, checkFailOn, classify } from './inventar.mjs';

const result = scan('./src', { maxFileBytes: 1 << 20 });
const cbom = buildCbom(result, { deterministic: true });
console.log(renderSummary(result));

What it does NOT do

  • It does not see cryptography inside compiled binaries (.class, .jar, .so, .dll, executables) or inside transitive dependencies. It reads the source you point it at, and lists the libraries your manifests declare.
  • It does not find hand-written implementations (for example a local Keccak or AES routine), calls made through wrappers or reflection, dynamically computed imports, or algorithms chosen in configuration files at run time. Those appear as unknown at best, or not at all.
  • It does not prove that a use is exploitable, reachable or security-relevant. MD5 used as a cache key is reported like MD5 used for signatures; the reason text tells you what to check.
  • It is static pattern analysis. Supported languages: JavaScript/TypeScript, Python, Java, Go, Rust, Swift and Kotlin. Scala, C#, C, C++ and Objective-C files are only checked for PEM blocks. Files that are not UTF-8 text (including UTF-16) are treated as binary and counted as not read.
  • It does not execute anything from the scanned tree, and it makes no network requests.

Tests

node test/proba.mjs                 # 68 probes: fixtures per language with the exact expected findings
node test/control-negativ.mjs       # breaks the scanner in a copy, one rule at a time (35 mutations), and
                                    # requires the matching probe to fail while the rest of the suite still runs
node test/proba-timp.mjs            # 13 hostile inputs, each at two sizes: the cost must grow linearly
node test/control-negativ-timp.mjs  # puts each quadratic form back (and one Swift comment rescan) and requires the cost probe to turn red
node test/echivalenta-0.1.0.mjs     # same findings as 0.1.0 (needs the git history; skipped without it); detectors
                                    # added after 0.1.0 (Rust, Swift, Kotlin, the libsignal Java/Node API, the JVM rules of 0.4.0)
                                    # are counted apart, and a finding that 0.1.0 had and the current version lacks is always a difference

Measured on 2026-10-18 on the libsignal tree (1,788 files; 596 Rust, 343 Java, 173 TypeScript, 210 Swift, 111 Kotlin): before the additions of that day the inventory returned 450 findings, all in Rust and Python, and zero for Java and TypeScript, because libsignal does not call JCA or node:crypto but its own API over the native bridge; Swift and Kotlin (20.5% of the source lines) were not analyzed at all. After the 0.3.0 additions: 759 findings (Java 262). The Node side of libsignal itself still returns zero, because its own tree imports by relative path, not by the package name the detector anchors on.

Measured later on 2026-10-18, same tree, with the Swift and Kotlin detectors of 0.4.0: all 1,440 source files are analyzed (no source line left out by construction); 980 findings; Swift 0 to 134 in 65 files (84 calls, 50 import-level; 128 in test paths, 6 in production: X25519 49, Kyber-1024 8, AES-256-GCM 9, AES-256-CTR 4, HKDF 4, AES-256-GCM-SIV 1, session setup 9); Kotlin 0 to 67 in 52 files (22 calls, 2 configurations, 43 import-level; 20 in test paths, 47 in production: X.509 chain validation 7, Kyber-1024 6, X25519 5, XEdDSA 2, HKDF 2, MD5 1); Java 262 to 282 through the JVM rules shared with Kotlin. Eleven files were read by hand against the inventory (six Swift, five Kotlin): eight correct at the first run, three with a false negative each (the static form Aes256Ctr32.process, a library source without any import, the Curve25519 bridge calls for signing, agreement and HPKE), all three fixed the same hour with their form added to the fixtures. Of 38 Swift/Kotlin files whose code mentions a cryptographic API name, 8 have no finding, each for a stated reason (function definitions, handle wrappers, sender keys, randomness).

Fixtures mark each expected finding on its own line (EXPECT: <name> | <class>), and a probe fails on a missing finding, an extra finding, a wrong line or a wrong class. Negative fixtures cover comments, docstrings, strings that mention crypto APIs, a file without cryptography, algorithms from unresolvable variables, a binary file and the size and count limits. The private-key probe generates a key at run time (none is stored in the repository) and checks that no part of it appears in any output.

Developed and tested on Node.js 24; older versions are not tested.