First public release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 14:49:00 -04:00
co-authored by Claude Opus 5.5
commit 14ba140354
14 changed files with 2390 additions and 0 deletions
+12
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.DS_Store
.env*
!.env.example
.npmrc
dist-cjs/
+6
View File
@@ -0,0 +1,6 @@
stages: [test]
test:
stage: test
image: node:22-alpine
script: [npm ci, npm run build, npm test]
+17
View File
@@ -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.
+22
View File
@@ -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
```
+21
View File
@@ -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.
+96
View File
@@ -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).
+6
View File
@@ -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.
+1503
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -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
View File
@@ -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 }
+206
View File
@@ -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('✓')
})
})
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist-cjs",
"declaration": false
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
"declaration": true, "outDir": "dist", "rootDir": "src",
"strict": true, "skipLibCheck": true
},
"include": ["src/**/*.ts"]
}