First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.npmrc
|
||||||
|
dist-cjs/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
stages: [test]
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
image: node:22-alpine
|
||||||
|
script: [npm ci, npm run build, npm test]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `checkClaims()` and `formatClaims()` check advertised cryptographic claims
|
||||||
|
against the keys, ciphers and transport settings described.
|
||||||
|
- `end-to-end-encrypted` and `zero-knowledge` fail when a key is held by the
|
||||||
|
server, an operator or a third party, whatever the cipher strength.
|
||||||
|
- `encrypted-at-rest`, `encrypted-in-transit`, `passwords-hashed` and
|
||||||
|
`forward-secrecy` are each checked against the element that would enforce
|
||||||
|
them.
|
||||||
|
- Broken primitives (MD5, SHA-1, DES, 3DES, RC4, ECB) are reported even when
|
||||||
|
nothing was claimed about them.
|
||||||
|
- A claim with nothing describing it is reported as not assessed, never as a
|
||||||
|
pass.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/claim-check.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
|
||||||
|
that fails without your fix, and check that it does fail before you submit.
|
||||||
|
- Measure, do not assume. Two modules with the same line count can be
|
||||||
|
different programs.
|
||||||
|
- Keep dependencies minimal. Every new dependency needs a reason.
|
||||||
|
- Explain why in comments, not what.
|
||||||
|
|
||||||
|
## Before you open a merge request
|
||||||
|
|
||||||
|
Run both of these and make sure they pass:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Extant 2000 LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# ClaimCheck
|
||||||
|
|
||||||
|
Checks the cryptographic claims a system makes, such as "end-to-end
|
||||||
|
encrypted", against the keys, ciphers and transport settings it actually
|
||||||
|
uses.
|
||||||
|
|
||||||
|
## What it does and why
|
||||||
|
|
||||||
|
A common pattern: a feature is described as encrypted, and it is. AES-256-GCM,
|
||||||
|
correctly applied, ciphertext at rest. It is still not end-to-end encrypted,
|
||||||
|
because the server holds the key. Every cryptographic choice is sound, the
|
||||||
|
claim is false, and no review of the algorithm would catch it.
|
||||||
|
|
||||||
|
That asymmetry is what this library is built around. **Cipher strength is the
|
||||||
|
easy half and rarely the part that is wrong. The part that goes wrong is key
|
||||||
|
custody: who can read the plaintext.** A claim of "end-to-end" or
|
||||||
|
"zero-knowledge" is a claim about custody. An operator holding the key
|
||||||
|
falsifies it no matter how strong the cipher is.
|
||||||
|
|
||||||
|
In the case above, ClaimCheck fails the end-to-end claim and names who holds
|
||||||
|
the key. It passes the at-rest claim, because that one is true. Only the
|
||||||
|
claim that goes beyond its custody fails.
|
||||||
|
|
||||||
|
## What it checks
|
||||||
|
|
||||||
|
| Claim | Checked against |
|
||||||
|
|---|---|
|
||||||
|
| `end-to-end-encrypted` | Key custody. Fails if `server`, `operator` or `third-party` holds any key. |
|
||||||
|
| `zero-knowledge` | The same custody rule. |
|
||||||
|
| `encrypted-at-rest` | An at-rest cipher exists, and it is not broken and not ECB. |
|
||||||
|
| `encrypted-in-transit` | A pinned minimum TLS version of 1.2 or higher. |
|
||||||
|
| `passwords-hashed` | A password KDF (bcrypt, scrypt, argon2, PBKDF2), not a fast hash and not reversible encryption. |
|
||||||
|
| `forward-secrecy` | Every listed cipher suite uses an ephemeral (ECDHE or DHE) key exchange. |
|
||||||
|
|
||||||
|
Broken primitives (MD5, SHA-1, DES, 3DES, RC4, any ECB mode) are reported
|
||||||
|
even when nothing was claimed about them. Not advertising a property is no
|
||||||
|
reason to leave MD5 in a signature path.
|
||||||
|
|
||||||
|
A key held only in an `hsm` does not break a custody claim, because the
|
||||||
|
operator cannot read it. A `third-party` holder does: "end-to-end with a
|
||||||
|
vendor in the middle" is the marketing sense of the phrase, not the
|
||||||
|
technical one.
|
||||||
|
|
||||||
|
## Missing evidence is not a pass
|
||||||
|
|
||||||
|
A claim with nothing in the inventory to support it is reported as NOT
|
||||||
|
ASSESSED. "No key custody was described" is not evidence that custody is
|
||||||
|
safe. It only shows that custody was not described. The same applies to a
|
||||||
|
TLS setup with no pinned minimum version: the result would depend on a
|
||||||
|
runtime default that can change, so it is not assessed.
|
||||||
|
|
||||||
|
The library is pure. It takes a description of your system and returns
|
||||||
|
findings, with no filesystem access, network calls or crypto calls. The
|
||||||
|
output is in the `@extant2000/evidence-record` format, and every conclusion
|
||||||
|
cites the file or location it rests on.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { checkClaims, formatClaims, type CryptoInventory } from '@extant2000/claim-check'
|
||||||
|
|
||||||
|
const inventory: CryptoInventory = {
|
||||||
|
claims: ['end-to-end-encrypted', 'encrypted-at-rest', 'passwords-hashed'],
|
||||||
|
keys: [{
|
||||||
|
keyId: 'NOTES_KEY',
|
||||||
|
heldBy: ['server', 'operator'], // list every party that can obtain it
|
||||||
|
location: 'config/app.env',
|
||||||
|
protects: 'note bodies',
|
||||||
|
}],
|
||||||
|
ciphers: [
|
||||||
|
{ algorithm: 'AES', mode: 'GCM', keyBits: 256, purpose: 'at-rest', where: 'src/notes/store.ts', line: 88 },
|
||||||
|
{ algorithm: 'argon2id', purpose: 'password', where: 'src/auth.ts', line: 30 },
|
||||||
|
],
|
||||||
|
transport: [{ minTlsVersion: '1.2', where: 'config/tls.yml' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = checkClaims(inventory) // structured report
|
||||||
|
console.log(formatClaims(inventory)) // the same report as text
|
||||||
|
```
|
||||||
|
|
||||||
|
`heldBy` decides the custody checks, and an optimistic answer defeats the
|
||||||
|
library. If the operator can read the environment variable that holds the
|
||||||
|
key, list `operator`.
|
||||||
|
|
||||||
|
For this inventory the end-to-end claim fails at critical severity, citing
|
||||||
|
`config/app.env`, while the at-rest and password claims pass.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/claim-check
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||||
|
Do not open a public issue for a security problem.
|
||||||
|
|
||||||
|
We aim to acknowledge every report within 5 business days.
|
||||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/claim-check",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Checks advertised cryptographic claims, such as end-to-end encryption, against key custody, ciphers and TLS settings.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist-cjs/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"dist-cjs",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitlab.com/extant2000/claim-check.git"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@extant2000/evidence-record": "^0.1.2"
|
||||||
|
},
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist-cjs/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"author": "Extant 2000 LLC",
|
||||||
|
"homepage": "https://gitlab.com/extant2000/claim-check",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/claim-check/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+430
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* ClaimCheck — verify the cryptography a system actually enforces, against
|
||||||
|
* the cryptography it advertises.
|
||||||
|
*
|
||||||
|
* The typical case: a feature is described as encrypted, and it is (AES,
|
||||||
|
* properly applied, ciphertext at rest). It is NOT end-to-end encrypted,
|
||||||
|
* because the server holds the key. Every individual cryptographic choice
|
||||||
|
* is sound; the CLAIM is still false, and no amount of checking the
|
||||||
|
* algorithm would catch it.
|
||||||
|
*
|
||||||
|
* That is the asymmetry this library is built around. Cipher strength is the
|
||||||
|
* easy half and rarely the one that is wrong. The half that goes wrong is
|
||||||
|
* KEY CUSTODY: who can read the plaintext. A claim of "end-to-end" or
|
||||||
|
* "zero-knowledge" is a claim about custody, and it is falsified by an
|
||||||
|
* operator with the key regardless of how strong the cipher is.
|
||||||
|
*
|
||||||
|
* Boundary with `@extant2000/policy-enforced`: that library asks whether a
|
||||||
|
* written policy holds in code, by pattern. This one asks whether a
|
||||||
|
* cryptographic claim survives its own key custody. Keep the split
|
||||||
|
* deliberate — they merge by accident otherwise.
|
||||||
|
*
|
||||||
|
* Pure: takes a described inventory, returns findings. No filesystem, no
|
||||||
|
* network, no crypto calls, so it is safe to run anywhere and its output is
|
||||||
|
* reproducible evidence rather than a reading taken at a moment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Determination,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
type Severity,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
/** Claims a system can make about its cryptography. */
|
||||||
|
export type CryptoClaim =
|
||||||
|
/** Only the endpoints can read the plaintext. A custody claim. */
|
||||||
|
| 'end-to-end-encrypted'
|
||||||
|
/** The operator cannot read the plaintext. A custody claim. */
|
||||||
|
| 'zero-knowledge'
|
||||||
|
/** Stored bytes are ciphertext. Says nothing about who holds the key. */
|
||||||
|
| 'encrypted-at-rest'
|
||||||
|
/** Bytes on the wire are ciphertext. */
|
||||||
|
| 'encrypted-in-transit'
|
||||||
|
/** Credentials are irreversible, not merely encrypted. */
|
||||||
|
| 'passwords-hashed'
|
||||||
|
/** Session keys do not compromise past traffic. */
|
||||||
|
| 'forward-secrecy'
|
||||||
|
|
||||||
|
/** Who can obtain the key material. */
|
||||||
|
export type KeyHolder = 'client' | 'server' | 'operator' | 'hsm' | 'third-party'
|
||||||
|
|
||||||
|
export interface KeyCustody {
|
||||||
|
keyId: string
|
||||||
|
/**
|
||||||
|
* Every party that can obtain this key. Be honest here — the point of the
|
||||||
|
* library is defeated by an optimistic answer, and "the operator can read
|
||||||
|
* the env var" counts.
|
||||||
|
*/
|
||||||
|
heldBy: KeyHolder[]
|
||||||
|
/** Where the key lives, cited in findings: an env var, a file, a KMS arn. */
|
||||||
|
location: string
|
||||||
|
/** What the key protects. */
|
||||||
|
protects: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CipherPurpose = 'transit' | 'at-rest' | 'password' | 'token' | 'signature'
|
||||||
|
|
||||||
|
export interface CipherUse {
|
||||||
|
algorithm: string
|
||||||
|
/** Block mode, when it applies: `GCM`, `CBC`, `ECB`. */
|
||||||
|
mode?: string
|
||||||
|
keyBits?: number
|
||||||
|
purpose: CipherPurpose
|
||||||
|
/** Source location, cited in findings. */
|
||||||
|
where: string
|
||||||
|
line?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransportConfig {
|
||||||
|
minTlsVersion?: string
|
||||||
|
/** Named suites, when the caller enumerates them. */
|
||||||
|
cipherSuites?: string[]
|
||||||
|
where: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CryptoInventory {
|
||||||
|
claims: CryptoClaim[]
|
||||||
|
keys?: KeyCustody[]
|
||||||
|
ciphers?: CipherUse[]
|
||||||
|
transport?: TransportConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parties whose possession of a key falsifies a custody claim.
|
||||||
|
*
|
||||||
|
* `hsm` is absent deliberately: a key that never leaves an HSM is not
|
||||||
|
* readable by the operator, which is the property the claim is about. A
|
||||||
|
* `third-party` IS disqualifying — "end-to-end" with a vendor in the middle
|
||||||
|
* is the marketing sense of the phrase, not the technical one.
|
||||||
|
*/
|
||||||
|
const CUSTODY_DISQUALIFIERS: KeyHolder[] = ['server', 'operator', 'third-party']
|
||||||
|
|
||||||
|
/** Algorithms that are broken, or were fine once and are not any more. */
|
||||||
|
const BROKEN: Record<string, string> = {
|
||||||
|
md5: 'collision-broken since 2004',
|
||||||
|
sha1: 'collision-broken (SHAttered, 2017)',
|
||||||
|
des: '56-bit key, brute-forceable',
|
||||||
|
'3des': 'Sweet32 birthday attack on 64-bit blocks',
|
||||||
|
rc4: 'biased keystream, prohibited by RFC 7465',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fast hashes: correct for integrity, catastrophic for passwords. */
|
||||||
|
const FAST_HASHES = ['md5', 'sha1', 'sha256', 'sha512', 'sha3']
|
||||||
|
|
||||||
|
/** Password KDFs. Anything else storing a password is a finding. */
|
||||||
|
const PASSWORD_KDFS = ['bcrypt', 'scrypt', 'argon2', 'argon2i', 'argon2d', 'argon2id', 'pbkdf2']
|
||||||
|
|
||||||
|
const norm = (s: string) => s.toLowerCase().replace(/[-_\s]/g, '')
|
||||||
|
|
||||||
|
function cipherEvidence(c: CipherUse) {
|
||||||
|
return evidence.file(c.where, c.line, `${c.algorithm}${c.mode ? `-${c.mode}` : ''}`, `used for ${c.purpose}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a claim of exclusive custody — "end-to-end", "zero-knowledge".
|
||||||
|
*
|
||||||
|
* This is the check that matters. It ignores the cipher entirely: a claim
|
||||||
|
* that the operator cannot read the plaintext is falsified by the operator
|
||||||
|
* holding the key, and AES-256-GCM does not change that.
|
||||||
|
*/
|
||||||
|
function custodyFinding(claim: CryptoClaim, inv: CryptoInventory): Finding {
|
||||||
|
const keys = inv.keys ?? []
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return {
|
||||||
|
id: claim,
|
||||||
|
summary: `"${claim}" is claimed, but no key custody was described`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'A custody claim can only be checked against custody. Describe who holds each key — an undescribed key is not a safe one.',
|
||||||
|
evidence: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compromising = keys.filter(k => k.heldBy.some(h => CUSTODY_DISQUALIFIERS.includes(h)))
|
||||||
|
|
||||||
|
if (compromising.length === 0) {
|
||||||
|
return {
|
||||||
|
id: claim,
|
||||||
|
summary: `"${claim}" holds — no key is reachable by the server, an operator, or a third party`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: keys.map(k =>
|
||||||
|
evidence.file(k.location, undefined, k.heldBy.join(', '), `key "${k.keyId}" protects ${k.protects}`)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: claim,
|
||||||
|
summary: `"${claim}" is FALSE — ${compromising.length} key(s) are held by a party the claim excludes`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: 'The data may be encrypted correctly and the claim still be untrue. Whoever holds the key can read the plaintext, so this is encryption at rest, not end-to-end. Say the former.',
|
||||||
|
evidence: compromising.map(k =>
|
||||||
|
evidence.file(
|
||||||
|
k.location,
|
||||||
|
undefined,
|
||||||
|
k.heldBy.join(', '),
|
||||||
|
`key "${k.keyId}" protects ${k.protects} and is held by ${k.heldBy.filter(h => CUSTODY_DISQUALIFIERS.includes(h)).join(', ')}`,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ciphersFor(inv: CryptoInventory, purpose: CipherPurpose): CipherUse[] {
|
||||||
|
return (inv.ciphers ?? []).filter(c => c.purpose === purpose)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A claim that something is ciphertext, checked against a cipher doing it. */
|
||||||
|
function cipherClaimFinding(
|
||||||
|
claim: CryptoClaim,
|
||||||
|
purpose: CipherPurpose,
|
||||||
|
inv: CryptoInventory,
|
||||||
|
): Finding {
|
||||||
|
const uses = ciphersFor(inv, purpose)
|
||||||
|
|
||||||
|
if (uses.length === 0) {
|
||||||
|
return {
|
||||||
|
id: claim,
|
||||||
|
summary: `"${claim}" is claimed, but no ${purpose} cipher was found`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'Nothing in the described inventory performs this encryption. That is not proof it is absent — it is proof it was not shown.',
|
||||||
|
evidence: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = uses.filter(u => BROKEN[norm(u.algorithm)] || norm(u.mode ?? '') === 'ecb')
|
||||||
|
if (bad.length > 0) {
|
||||||
|
return {
|
||||||
|
id: claim,
|
||||||
|
summary: `"${claim}" is enforced with a broken primitive`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: bad.map(u =>
|
||||||
|
norm(u.mode ?? '') === 'ecb'
|
||||||
|
? `${u.algorithm}-ECB leaks plaintext structure — identical blocks encrypt identically`
|
||||||
|
: `${u.algorithm}: ${BROKEN[norm(u.algorithm)]}`).join('; '),
|
||||||
|
evidence: bad.map(cipherEvidence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: claim,
|
||||||
|
summary: `"${claim}" holds — ${uses.length} ${purpose} cipher(s), none broken`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: uses.map(cipherEvidence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password storage.
|
||||||
|
*
|
||||||
|
* A fast hash is the right tool for integrity and the wrong one for a
|
||||||
|
* password: SHA-256 is fast by design, which is precisely the property an
|
||||||
|
* attacker holding the hash file wants. "Encrypted" is also wrong —
|
||||||
|
* encryption is reversible and password storage must not be.
|
||||||
|
*/
|
||||||
|
function passwordFinding(inv: CryptoInventory): Finding {
|
||||||
|
const uses = ciphersFor(inv, 'password')
|
||||||
|
|
||||||
|
if (uses.length === 0) {
|
||||||
|
return {
|
||||||
|
id: 'passwords-hashed',
|
||||||
|
summary: '"passwords-hashed" is claimed, but no password hashing was found',
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
evidence: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weak = uses.filter(u => !PASSWORD_KDFS.includes(norm(u.algorithm)))
|
||||||
|
if (weak.length > 0) {
|
||||||
|
return {
|
||||||
|
id: 'passwords-hashed',
|
||||||
|
summary: `"passwords-hashed" is FALSE for ${weak.length} of ${uses.length} store(s)`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: weak.map(u => FAST_HASHES.includes(norm(u.algorithm))
|
||||||
|
? `${u.algorithm} is a fast hash — correct for integrity, catastrophic for passwords. Use bcrypt, scrypt or argon2id.`
|
||||||
|
: `${u.algorithm} is not a password KDF. If it is reversible, this is not hashing at all.`).join('; '),
|
||||||
|
evidence: weak.map(cipherEvidence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'passwords-hashed',
|
||||||
|
summary: `"passwords-hashed" holds — ${uses.length} store(s), all using a password KDF`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: uses.map(cipherEvidence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TLS_ORDER = ['1.0', '1.1', '1.2', '1.3']
|
||||||
|
|
||||||
|
function transitFinding(inv: CryptoInventory): Finding {
|
||||||
|
const configs = inv.transport ?? []
|
||||||
|
|
||||||
|
if (configs.length === 0) {
|
||||||
|
return {
|
||||||
|
id: 'encrypted-in-transit',
|
||||||
|
summary: '"encrypted-in-transit" is claimed, but no transport configuration was described',
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
evidence: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const weak = configs.filter(c => {
|
||||||
|
const i = TLS_ORDER.indexOf(c.minTlsVersion ?? '')
|
||||||
|
return i >= 0 && i < TLS_ORDER.indexOf('1.2')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (weak.length > 0) {
|
||||||
|
return {
|
||||||
|
id: 'encrypted-in-transit',
|
||||||
|
summary: '"encrypted-in-transit" is enforced with a deprecated TLS floor',
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'TLS 1.0 and 1.1 are deprecated (RFC 8996). A floor below 1.2 means a downgrade is available to anyone who can influence the handshake.',
|
||||||
|
evidence: weak.map(c => evidence.file(c.where, undefined, `minTlsVersion ${c.minTlsVersion}`)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unspecified = configs.filter(c => !c.minTlsVersion)
|
||||||
|
if (unspecified.length === configs.length) {
|
||||||
|
return {
|
||||||
|
id: 'encrypted-in-transit',
|
||||||
|
summary: '"encrypted-in-transit" is claimed, but no minimum TLS version is pinned',
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'medium',
|
||||||
|
detail: 'Without a floor the answer depends on the runtime default, which is not a property of this system and can change under it.',
|
||||||
|
evidence: configs.map(c => evidence.file(c.where, undefined, 'no minTlsVersion set')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'encrypted-in-transit',
|
||||||
|
summary: `"encrypted-in-transit" holds — TLS floor at ${configs.map(c => c.minTlsVersion).filter(Boolean).join(', ')}`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: configs.map(c => evidence.file(c.where, undefined, `minTlsVersion ${c.minTlsVersion ?? 'unset'}`)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardSecrecyFinding(inv: CryptoInventory): Finding {
|
||||||
|
const suites = (inv.transport ?? []).flatMap(c => (c.cipherSuites ?? []).map(s => ({ suite: s, where: c.where })))
|
||||||
|
|
||||||
|
if (suites.length === 0) {
|
||||||
|
return {
|
||||||
|
id: 'forward-secrecy',
|
||||||
|
summary: '"forward-secrecy" is claimed, but no cipher suites were enumerated',
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'medium',
|
||||||
|
detail: 'Forward secrecy is a property of the key exchange, so it can only be read off the negotiated suites.',
|
||||||
|
evidence: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ephemeral key exchange is the whole property: a static-RSA suite means
|
||||||
|
// one leaked server key retroactively decrypts every captured session.
|
||||||
|
const nonPfs = suites.filter(s => !/ECDHE|DHE/i.test(s.suite))
|
||||||
|
if (nonPfs.length > 0) {
|
||||||
|
return {
|
||||||
|
id: 'forward-secrecy',
|
||||||
|
summary: `"forward-secrecy" is FALSE — ${nonPfs.length} suite(s) use a non-ephemeral key exchange`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'Without an ephemeral exchange, one leaked server key retroactively decrypts every session that was ever captured.',
|
||||||
|
evidence: nonPfs.map(s => evidence.file(s.where, undefined, s.suite, 'no ECDHE/DHE key exchange')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'forward-secrecy',
|
||||||
|
summary: `"forward-secrecy" holds — all ${suites.length} suite(s) use an ephemeral exchange`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: suites.map(s => evidence.file(s.where, undefined, s.suite)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingFor(claim: CryptoClaim, inv: CryptoInventory): Finding {
|
||||||
|
switch (claim) {
|
||||||
|
case 'end-to-end-encrypted':
|
||||||
|
case 'zero-knowledge':
|
||||||
|
return custodyFinding(claim, inv)
|
||||||
|
case 'encrypted-at-rest':
|
||||||
|
return cipherClaimFinding(claim, 'at-rest', inv)
|
||||||
|
case 'encrypted-in-transit':
|
||||||
|
return transitFinding(inv)
|
||||||
|
case 'passwords-hashed':
|
||||||
|
return passwordFinding(inv)
|
||||||
|
case 'forward-secrecy':
|
||||||
|
return forwardSecrecyFinding(inv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findings about the inventory itself, independent of any claim.
|
||||||
|
*
|
||||||
|
* A broken primitive is worth reporting even when nothing was claimed about
|
||||||
|
* it — the absence of a marketing sentence is not a reason to leave MD5 in
|
||||||
|
* a signature path.
|
||||||
|
*/
|
||||||
|
function unclaimedFindings(inv: CryptoInventory): Finding[] {
|
||||||
|
const claimed = new Set(inv.claims)
|
||||||
|
const covered = new Set<CipherPurpose>()
|
||||||
|
if (claimed.has('encrypted-at-rest')) covered.add('at-rest')
|
||||||
|
if (claimed.has('passwords-hashed')) covered.add('password')
|
||||||
|
|
||||||
|
return (inv.ciphers ?? [])
|
||||||
|
.filter(c => !covered.has(c.purpose))
|
||||||
|
.filter(c => BROKEN[norm(c.algorithm)] || norm(c.mode ?? '') === 'ecb')
|
||||||
|
.map((c, i) => ({
|
||||||
|
id: `unclaimed-${i}`,
|
||||||
|
summary: `${c.algorithm}${c.mode ? `-${c.mode}` : ''} used for ${c.purpose}`,
|
||||||
|
determination: 'fail' as Determination,
|
||||||
|
severity: (c.purpose === 'signature' ? 'critical' : 'high') as Severity,
|
||||||
|
detail: BROKEN[norm(c.algorithm)] ?? 'ECB mode leaks plaintext structure',
|
||||||
|
evidence: [cipherEvidence(c)],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check every claim against the inventory that is supposed to support it. */
|
||||||
|
export function checkClaims(inv: CryptoInventory): CapabilityReport {
|
||||||
|
const claims = inv.claims ?? []
|
||||||
|
const described = (inv.keys?.length ?? 0) + (inv.ciphers?.length ?? 0) + (inv.transport?.length ?? 0)
|
||||||
|
|
||||||
|
if (claims.length === 0 && described === 0) {
|
||||||
|
return {
|
||||||
|
capability: 'ClaimCheck',
|
||||||
|
scope: 'nothing described',
|
||||||
|
examined: 0,
|
||||||
|
findings: [],
|
||||||
|
notes: ['No claims and no cryptography were described, so nothing was checked.'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
capability: 'ClaimCheck',
|
||||||
|
scope: `${claims.length} claim(s) against ${described} described element(s)`,
|
||||||
|
// Claims are the unit of assessment. A system that describes plenty of
|
||||||
|
// cryptography and claims nothing has still had every claim it makes
|
||||||
|
// checked — vacuously — and the count says so.
|
||||||
|
examined: claims.length,
|
||||||
|
findings: [...claims.map(c => findingFor(c, inv)), ...unclaimedFindings(inv)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a claim check for a terminal, CI log or evidence package. */
|
||||||
|
export function formatClaims(inv: CryptoInventory, opts: FormatOptions = {}): string {
|
||||||
|
return renderReport(checkClaims(inv), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CUSTODY_DISQUALIFIERS, PASSWORD_KDFS, BROKEN }
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
import { checkClaims, formatClaims, type CryptoInventory } from '../src/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape this library is built around: a notes feature that really is
|
||||||
|
* encrypted with a sound cipher, and is NOT end-to-end, because the server
|
||||||
|
* holds the key.
|
||||||
|
*/
|
||||||
|
const notes: CryptoInventory = {
|
||||||
|
claims: ['end-to-end-encrypted', 'encrypted-at-rest'],
|
||||||
|
keys: [{
|
||||||
|
keyId: 'NOTES_KEY',
|
||||||
|
heldBy: ['server', 'operator'],
|
||||||
|
location: 'config/app.env',
|
||||||
|
protects: 'note bodies',
|
||||||
|
}],
|
||||||
|
ciphers: [{
|
||||||
|
algorithm: 'AES',
|
||||||
|
mode: 'GCM',
|
||||||
|
keyBits: 256,
|
||||||
|
purpose: 'at-rest',
|
||||||
|
where: 'src/notes/store.ts',
|
||||||
|
line: 88,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('custody is the check that matters', () => {
|
||||||
|
it('sound cipher, false claim', () => {
|
||||||
|
// Every cryptographic choice here is correct. The claim is still untrue,
|
||||||
|
// and no amount of checking the algorithm would have caught it.
|
||||||
|
const r = checkClaims(notes)
|
||||||
|
const e2e = r.findings.find(f => f.id === 'end-to-end-encrypted')!
|
||||||
|
const atRest = r.findings.find(f => f.id === 'encrypted-at-rest')!
|
||||||
|
|
||||||
|
expect(e2e.determination).toBe('fail')
|
||||||
|
expect(atRest.determination).toBe('pass') // the encryption really is fine
|
||||||
|
expect(overall(r)).toBe('fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names who holds the key, not just that the claim failed', () => {
|
||||||
|
const text = formatClaims(notes, { evidence: 'full' })
|
||||||
|
expect(text).toContain('config/app.env')
|
||||||
|
expect(text).toContain('server, operator')
|
||||||
|
expect(text).toContain('this is encryption at rest, not end-to-end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores cipher strength entirely for a custody claim', () => {
|
||||||
|
// AES-256-GCM does not make an operator-held key end-to-end.
|
||||||
|
const stronger: CryptoInventory = {
|
||||||
|
...notes,
|
||||||
|
ciphers: [{ ...notes.ciphers![0]!, keyBits: 512 }],
|
||||||
|
}
|
||||||
|
expect(checkClaims(stronger).findings[0]!.determination).toBe('fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes a custody claim when only the client holds the key', () => {
|
||||||
|
const r = checkClaims({
|
||||||
|
claims: ['end-to-end-encrypted'],
|
||||||
|
keys: [{ keyId: 'k', heldBy: ['client'], location: 'browser/keystore.ts', protects: 'messages' }],
|
||||||
|
})
|
||||||
|
expect(overall(r)).toBe('pass')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an HSM-held key as compatible with the claim', () => {
|
||||||
|
// A key that never leaves an HSM is not readable by the operator, which
|
||||||
|
// is the property the claim is about.
|
||||||
|
const r = checkClaims({
|
||||||
|
claims: ['zero-knowledge'],
|
||||||
|
keys: [{ keyId: 'k', heldBy: ['hsm'], location: 'kms://arn', protects: 'records' }],
|
||||||
|
})
|
||||||
|
expect(overall(r)).toBe('pass')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a third party as disqualifying', () => {
|
||||||
|
// "End-to-end with a vendor in the middle" is the marketing sense of the
|
||||||
|
// phrase, not the technical one.
|
||||||
|
const r = checkClaims({
|
||||||
|
claims: ['end-to-end-encrypted'],
|
||||||
|
keys: [{ keyId: 'k', heldBy: ['client', 'third-party'], location: 'vendor.md', protects: 'x' }],
|
||||||
|
})
|
||||||
|
expect(overall(r)).toBe('fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an undescribed custody as not-assessed, never a pass', () => {
|
||||||
|
const r = checkClaims({ claims: ['end-to-end-encrypted'] })
|
||||||
|
expect(r.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(overall(r)).toBe('not-assessed')
|
||||||
|
expect(formatClaims({ claims: ['end-to-end-encrypted'] })).not.toContain('✓')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('primitives', () => {
|
||||||
|
const at = (algorithm: string, mode?: string): CryptoInventory => ({
|
||||||
|
claims: ['encrypted-at-rest'],
|
||||||
|
ciphers: [{ algorithm, mode, purpose: 'at-rest', where: 'lib/crypto.ts', line: 4 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails a broken algorithm', () => {
|
||||||
|
expect(checkClaims(at('3DES')).findings[0]!.determination).toBe('fail')
|
||||||
|
expect(checkClaims(at('RC4')).findings[0]!.detail).toContain('RFC 7465')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails ECB regardless of the cipher', () => {
|
||||||
|
const f = checkClaims(at('AES', 'ECB')).findings[0]!
|
||||||
|
expect(f.determination).toBe('fail')
|
||||||
|
expect(f.detail).toContain('identical blocks encrypt identically')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts AES-GCM', () => {
|
||||||
|
expect(checkClaims(at('AES', 'GCM')).findings[0]!.determination).toBe('pass')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a broken primitive even when nothing was claimed about it', () => {
|
||||||
|
// The absence of a marketing sentence is not a reason to leave MD5 in a
|
||||||
|
// signature path.
|
||||||
|
const r = checkClaims({
|
||||||
|
claims: [],
|
||||||
|
ciphers: [{ algorithm: 'MD5', purpose: 'signature', where: 'lib/sign.ts', line: 12 }],
|
||||||
|
})
|
||||||
|
expect(r.findings).toHaveLength(1)
|
||||||
|
expect(r.findings[0]!.severity).toBe('critical')
|
||||||
|
expect(r.findings[0]!.detail).toContain('collision-broken')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('passwords', () => {
|
||||||
|
const pw = (algorithm: string): CryptoInventory => ({
|
||||||
|
claims: ['passwords-hashed'],
|
||||||
|
ciphers: [{ algorithm, purpose: 'password', where: 'server/api/auth.ts', line: 30 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails a fast hash — right for integrity, wrong for a password', () => {
|
||||||
|
const f = checkClaims(pw('SHA-256')).findings[0]!
|
||||||
|
expect(f.determination).toBe('fail')
|
||||||
|
expect(f.detail).toContain('catastrophic for passwords')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails a reversible cipher, which is not hashing at all', () => {
|
||||||
|
expect(checkClaims(pw('AES')).findings[0]!.determination).toBe('fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a real KDF', () => {
|
||||||
|
for (const kdf of ['bcrypt', 'argon2id', 'scrypt', 'PBKDF2']) {
|
||||||
|
expect(checkClaims(pw(kdf)).findings[0]!.determination).toBe('pass')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transport', () => {
|
||||||
|
it('fails a TLS floor below 1.2', () => {
|
||||||
|
const f = checkClaims({
|
||||||
|
claims: ['encrypted-in-transit'],
|
||||||
|
transport: [{ minTlsVersion: '1.0', where: 'config/tls.yml' }],
|
||||||
|
}).findings[0]!
|
||||||
|
expect(f.determination).toBe('fail')
|
||||||
|
expect(f.detail).toContain('RFC 8996')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a 1.2 floor and a 1.3 floor', () => {
|
||||||
|
for (const v of ['1.2', '1.3']) {
|
||||||
|
expect(checkClaims({
|
||||||
|
claims: ['encrypted-in-transit'],
|
||||||
|
transport: [{ minTlsVersion: v, where: 'config/tls.yml' }],
|
||||||
|
}).findings[0]!.determination).toBe('pass')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an unpinned floor as not-assessed, not a pass', () => {
|
||||||
|
// Without a floor the answer is the runtime default, which is not a
|
||||||
|
// property of this system and can change under it.
|
||||||
|
const f = checkClaims({
|
||||||
|
claims: ['encrypted-in-transit'],
|
||||||
|
transport: [{ where: 'config/tls.yml' }],
|
||||||
|
}).findings[0]!
|
||||||
|
expect(f.determination).toBe('not-assessed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails forward secrecy on a non-ephemeral suite', () => {
|
||||||
|
const f = checkClaims({
|
||||||
|
claims: ['forward-secrecy'],
|
||||||
|
transport: [{ where: 'config/tls.yml', cipherSuites: ['TLS_RSA_WITH_AES_128_CBC_SHA'] }],
|
||||||
|
}).findings[0]!
|
||||||
|
expect(f.determination).toBe('fail')
|
||||||
|
expect(f.detail).toContain('retroactively decrypts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts ephemeral suites', () => {
|
||||||
|
expect(checkClaims({
|
||||||
|
claims: ['forward-secrecy'],
|
||||||
|
transport: [{ where: 't.yml', cipherSuites: ['TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256'] }],
|
||||||
|
}).findings[0]!.determination).toBe('pass')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conformance with the evidence-record standard', () => {
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
expect(validateReport(checkClaims(notes))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an empty inventory is not-assessed, never clean', () => {
|
||||||
|
const r = checkClaims({ claims: [] })
|
||||||
|
expect(overall(r)).toBe('not-assessed')
|
||||||
|
expect(formatClaims({ claims: [] })).not.toContain('✓')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist-cjs",
|
||||||
|
"declaration": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||||
|
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||||
|
"strict": true, "skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user