zeropoint-node 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -1
- package/CITATION.cff +1 -1
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/crypto/kyber-real.test.ts +116 -43
- package/src/crypto/kyber-real.ts +97 -100
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.4
|
|
4
|
+
|
|
5
|
+
### Fixed — cryptography
|
|
6
|
+
|
|
7
|
+
- **Kyber-768 encapsulate/decapsulate never agreed**, published 1.0.3
|
|
8
|
+
included. Two independent defects, either alone fatal. 12-bit serialization
|
|
9
|
+
guarded the carry byte on `shift > 0`, so every even-indexed coefficient
|
|
10
|
+
above 255 was truncated to its low byte — 118 of 256 for a typical
|
|
11
|
+
polynomial, and both `t` and `s` are serialized. Message decode used a
|
|
12
|
+
single `>= q/2` threshold, but the ring wraps: a 0-bit nudged negative by
|
|
13
|
+
noise lands near `q` and read as 1, so bits flipped on noise sign alone.
|
|
14
|
+
The test is the band `[q/4, 3q/4)`. Compression also floored twice against
|
|
15
|
+
a `2^d - 1` scale, biasing one way each time; at `d=4` that bias was the
|
|
16
|
+
measured median decode error, ~111 against a budget of `q/4 = 832`. It
|
|
17
|
+
rounds to the spec's `2^d` scale now. Measured over 300 keypairs: median
|
|
18
|
+
error 111 → 52, max 404 → 193. 2000/2000 round trips recover the shared
|
|
19
|
+
secret.
|
|
20
|
+
|
|
21
|
+
### Added — release gates
|
|
22
|
+
|
|
23
|
+
- **`npm run check` is now the whole gate.** `docs:build` was the one CI step
|
|
24
|
+
outside it; a dead link in `QUANTUM_GUIDE.md` rode that gap through six
|
|
25
|
+
consecutive pushes with the gate green each time, and the published site did
|
|
26
|
+
not rebuild for two weeks. It runs in the chain now.
|
|
27
|
+
- **Version sealing.** `package.json` is the single source of the release
|
|
28
|
+
number; `scripts/version-seal.mjs` derives `CITATION.cff`, a README block and
|
|
29
|
+
the CHANGELOG heading from it. `version:check` fails closed on drift, on a
|
|
30
|
+
missing or placeholder CHANGELOG entry, and on any version below an existing
|
|
31
|
+
CHANGELOG heading or git tag. Before this, `package.json` said 1.0.3 while
|
|
32
|
+
the README said 1.0.2. `npm version patch|minor|major` now reseals and tags
|
|
33
|
+
in one step.
|
|
34
|
+
- **`version:test`, `test:quantum`, `test:crypto`.** The version gate ships a
|
|
35
|
+
10-case self-test, 9 of them failures it must catch, so a gate that cannot
|
|
36
|
+
fail is detectable. Four test suites that existed and passed were executed by
|
|
37
|
+
nothing; they run on every gate now.
|
|
38
|
+
|
|
39
|
+
### Known limitations
|
|
40
|
+
|
|
41
|
+
- `src/crypto/kyber-real.ts` **is not ML-KEM and must not protect anything.**
|
|
42
|
+
It is not a package export, but `files` ships `src/**/*.ts`, so a deep import
|
|
43
|
+
can reach it. The matrix `A` is CBD-sampled into `{-1,0,1}` instead of
|
|
44
|
+
uniform mod `q`, so the module-LWE instance underneath is not the hard
|
|
45
|
+
problem; noise is `eta=1` where ML-KEM-768 wants 2; SHA-256 stands in for
|
|
46
|
+
SHAKE; `e1`/`e2` are omitted; there is no Fujisaki-Okamoto step, so no
|
|
47
|
+
IND-CCA2 claim. The round trip is correct; the cryptography is not.
|
|
48
|
+
- Every limitation listed under 1.0.3 still applies.
|
|
49
|
+
|
|
3
50
|
## 1.0.3
|
|
4
51
|
|
|
5
52
|
### Fixed — packaging (1.0.0–1.0.2 were unusable in these ways)
|
|
@@ -64,6 +111,14 @@ primitive it replaced. **These change results**; review if you depend on them.
|
|
|
64
111
|
**TypeScript 7 is deliberately held back** — it breaks the build
|
|
65
112
|
(`@rollup/plugin-typescript` throws on it).
|
|
66
113
|
|
|
114
|
+
### Superseded by 1.0.4
|
|
115
|
+
|
|
116
|
+
Published from a commit three behind `main`. It ships
|
|
117
|
+
`src/crypto/kyber-real.ts` with a round trip that never recovers the shared
|
|
118
|
+
secret — 0 of 20 measured against the published tarball — under a header
|
|
119
|
+
reading "Real NIST FIPS 203 Implementation" and "NOT a toy version - actual
|
|
120
|
+
cryptographic implementation". Both claims were false. Use 1.0.4.
|
|
121
|
+
|
|
67
122
|
### Known limitations
|
|
68
123
|
|
|
69
124
|
- `./kernel`, `./kernel/import-graph`, `./0`, `./integrity` and `./mcp` point
|
|
@@ -82,4 +137,4 @@ primitive it replaced. **These change results**; review if you depend on them.
|
|
|
82
137
|
## 1.0.2 and earlier — deprecated
|
|
83
138
|
|
|
84
139
|
Broken as published: the CJS entry cannot load, and the tarball ships 87 MB of
|
|
85
|
-
scanned book imagery. Use 1.0.
|
|
140
|
+
scanned book imagery. Use 1.0.4.
|
package/CITATION.cff
CHANGED
|
@@ -20,7 +20,7 @@ authors:
|
|
|
20
20
|
email: node@zeropoint.bg
|
|
21
21
|
repository-code: https://github.com/ceccec/zeropoint-node
|
|
22
22
|
url: https://node.zeropoint.bg
|
|
23
|
-
version: 1.0.
|
|
23
|
+
version: 1.0.4
|
|
24
24
|
license: CC-BY-NC-ND-4.0
|
|
25
25
|
license-url: https://github.com/ceccec/zeropoint-node/blob/main/LICENSE
|
|
26
26
|
keywords:
|
package/README.md
CHANGED
|
@@ -472,7 +472,7 @@ The A432 Consciousness System is an open-source project that welcomes contributi
|
|
|
472
472
|
|
|
473
473
|
<!-- VERSION:BEGIN — generated by scripts/version-seal.mjs; do not edit by hand -->
|
|
474
474
|
|
|
475
|
-
Package: **`zeropoint-node@1.0.
|
|
475
|
+
Package: **`zeropoint-node@1.0.4`** · owner `ceccec` · git tag `v1.0.4`
|
|
476
476
|
|
|
477
477
|
npm rejects republishing a version that already exists, so every release is a
|
|
478
478
|
new number. Bump with `npm version patch|minor|major`: that reseals CITATION.cff
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zeropoint-node",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A432 Consciousness System: Self-evolving, self-aware consciousness technology with Transurfing integration, harmonic mathematics, zero-entropy principles, Kabbalistic cycles, and sacred geometry",
|
|
6
6
|
"main": "dist/a432.system.cjs",
|
|
@@ -124,6 +124,7 @@
|
|
|
124
124
|
"test:kernel": "node --experimental-strip-types src/kernel/smoke.test.ts",
|
|
125
125
|
"test:security": "node --experimental-strip-types src/security/quantum-fold-cipher.test.ts > /dev/null && node --experimental-strip-types src/security/quantum-state-tomography.test.ts > /dev/null && node --experimental-strip-types src/security/quantum-proofs.test.ts > /dev/null && node --experimental-strip-types src/security/post-quantum-crypto.test.ts > /dev/null && echo security tests ok",
|
|
126
126
|
"test:quantum": "node --experimental-strip-types src/quantum/superposition-execution.test.ts > /dev/null && node --experimental-strip-types src/quantum/millennium-bridge.test.ts > /dev/null && node --experimental-strip-types src/quantum/zenodo-publisher.test.ts > /dev/null && echo quantum tests ok",
|
|
127
|
+
"test:crypto": "node --experimental-strip-types src/crypto/kyber-real.test.ts > /dev/null && echo crypto tests ok",
|
|
127
128
|
"readme": "node --experimental-strip-types scripts/readme-gen.mjs",
|
|
128
129
|
"readme:check": "node --experimental-strip-types scripts/readme-gen.mjs --check",
|
|
129
130
|
"docs:pages": "node --experimental-strip-types scripts/docs-pages.mjs",
|
|
@@ -156,7 +157,7 @@
|
|
|
156
157
|
"vortex:svg:check": "node --experimental-strip-types scripts/vortex-svg.mjs --check",
|
|
157
158
|
"bundle:a432": "node scripts/build-a432-bundle.mjs",
|
|
158
159
|
"bundle:a432:check": "node scripts/build-a432-bundle.mjs --check",
|
|
159
|
-
"check": "npm run version:test && npm run version:check && npm run test:kernel && npm run test:security && npm run test:quantum && npm run math:ban && npm run bundle:a432:check && npm run skills:seal && npm run skills:check && npm run readme && npm run readme:check && npm run docs:pages && npm run docs:pages:check && npm run a432:scan && npm run a432:scan:check && npm run docs:index && npm run docs:index:check && npm run spectrum && npm run spectrum:check && npm run vortex:svg && npm run vortex:svg:check && npm run quantum:sim && npm run ratchet:check && npm run mcp:smoke && npm run self:next",
|
|
160
|
+
"check": "npm run version:test && npm run version:check && npm run test:kernel && npm run test:security && npm run test:quantum && npm run test:crypto && npm run math:ban && npm run bundle:a432:check && npm run skills:seal && npm run skills:check && npm run readme && npm run readme:check && npm run docs:pages && npm run docs:pages:check && npm run a432:scan && npm run a432:scan:check && npm run docs:index && npm run docs:index:check && npm run spectrum && npm run spectrum:check && npm run vortex:svg && npm run vortex:svg:check && npm run docs:build && npm run quantum:sim && npm run ratchet:check && npm run mcp:smoke && npm run self:next",
|
|
160
161
|
"prepublishOnly": "npm run build",
|
|
161
162
|
"prose": "node scripts/prose-claims.mjs --list",
|
|
162
163
|
"prose:check": "node scripts/prose-claims.mjs",
|
|
@@ -1,11 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Kyber-768 shaped KEM: round-trip and structural tests.
|
|
3
|
+
* This does NOT test FIPS 203 conformance — see the gaps listed in
|
|
4
|
+
* kyber-real.ts. It tests that what is implemented is self-consistent.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* Two of these pin defects that shipped silently: 12-bit serialization dropped
|
|
7
|
+
* the carry byte for every even coefficient, and message decode used a single
|
|
8
|
+
* threshold where the ring requires a centred band. Both survived a passing
|
|
9
|
+
* suite because nothing exercised them directly.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
generateKeyPair,
|
|
14
|
+
encapsulate,
|
|
15
|
+
decapsulate,
|
|
16
|
+
polynomialToBytes,
|
|
17
|
+
bytesToPolynomial,
|
|
18
|
+
messageToPoly,
|
|
19
|
+
polyToMessage,
|
|
20
|
+
type Polynomial,
|
|
21
|
+
} from './kyber-real.ts'
|
|
22
|
+
import { randomBytes } from 'node:crypto'
|
|
23
|
+
import { floor } from '../0/algebra.ts'
|
|
24
|
+
|
|
25
|
+
const KYBER_Q = 3329
|
|
26
|
+
const KYBER_N = 256
|
|
27
|
+
|
|
28
|
+
function testSerializationRoundTrip(): void {
|
|
29
|
+
console.log('Test: 12-bit coefficient serialization round trip...')
|
|
30
|
+
|
|
31
|
+
// Every coefficient must survive at every position. The carry bug hit only
|
|
32
|
+
// EVEN indices and only values above 255, so a sparse probe walked past it.
|
|
33
|
+
const poly = new Uint16Array(KYBER_N) as Polynomial
|
|
34
|
+
for (let i = 0; i < KYBER_N; i++) poly[i] = (i * 13 + 1000) % KYBER_Q
|
|
35
|
+
const back = bytesToPolynomial(Buffer.from(polynomialToBytes(poly)))
|
|
36
|
+
for (let i = 0; i < KYBER_N; i++) {
|
|
37
|
+
if (poly[i] !== back[i]) {
|
|
38
|
+
throw new Error('coefficient ' + i + ' corrupted: wrote ' + poly[i] + ', read ' + back[i])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// And the extremes, which is where twelve bits actually matters.
|
|
43
|
+
const edge = new Uint16Array(KYBER_N) as Polynomial
|
|
44
|
+
for (let i = 0; i < KYBER_N; i++) edge[i] = i % 2 === 0 ? KYBER_Q - 1 : 0
|
|
45
|
+
const edgeBack = bytesToPolynomial(Buffer.from(polynomialToBytes(edge)))
|
|
46
|
+
for (let i = 0; i < KYBER_N; i++) {
|
|
47
|
+
if (edge[i] !== edgeBack[i]) {
|
|
48
|
+
throw new Error('edge coefficient ' + i + ' corrupted: wrote ' + edge[i] + ', read ' + edgeBack[i])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(' ✓ 256 coefficients exact, both parities, including q-1')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function testMessageDecodeBand(): void {
|
|
56
|
+
console.log('Test: message decode survives noise of either sign...')
|
|
57
|
+
|
|
58
|
+
const msg = randomBytes(32)
|
|
59
|
+
if (!polyToMessage(messageToPoly(msg)).equals(msg)) {
|
|
60
|
+
throw new Error('message does not survive a noiseless round trip')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The decode boundary sits q/4 from each ideal point, so anything strictly
|
|
64
|
+
// inside must decode correctly — including NEGATIVE noise on a 0 bit, which
|
|
65
|
+
// wraps to just under q. That wrap is what a single mid-point threshold
|
|
66
|
+
// misreads as a 1.
|
|
67
|
+
const limit = floor(KYBER_Q / 4) - 1
|
|
68
|
+
for (const noise of [-limit, -100, -1, 0, 1, 100, limit]) {
|
|
69
|
+
const poly = messageToPoly(msg)
|
|
70
|
+
for (let i = 0; i < KYBER_N; i++) {
|
|
71
|
+
poly[i] = (((poly[i]! + noise) % KYBER_Q) + KYBER_Q) % KYBER_Q
|
|
72
|
+
}
|
|
73
|
+
if (!polyToMessage(poly).equals(msg)) {
|
|
74
|
+
throw new Error('decode failed at noise offset ' + noise)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(' ✓ recovered at every offset in ±' + limit + ' (boundary is q/4)')
|
|
79
|
+
}
|
|
9
80
|
|
|
10
81
|
function testKeyGeneration(): void {
|
|
11
82
|
console.log('Test: Kyber-768 key pair generation...')
|
|
@@ -13,14 +84,14 @@ function testKeyGeneration(): void {
|
|
|
13
84
|
const { publicKey, secretKey } = generateKeyPair()
|
|
14
85
|
|
|
15
86
|
if (publicKey.length !== 1184) {
|
|
16
|
-
throw new Error(
|
|
87
|
+
throw new Error('Public key size mismatch: expected 1184, got ' + publicKey.length)
|
|
17
88
|
}
|
|
18
89
|
if (secretKey.length !== 2400) {
|
|
19
|
-
throw new Error(
|
|
90
|
+
throw new Error('Secret key size mismatch: expected 2400, got ' + secretKey.length)
|
|
20
91
|
}
|
|
21
92
|
|
|
22
|
-
console.log(
|
|
23
|
-
console.log(
|
|
93
|
+
console.log(' ✓ Public key: ' + publicKey.length + ' bytes')
|
|
94
|
+
console.log(' ✓ Secret key: ' + secretKey.length + ' bytes')
|
|
24
95
|
}
|
|
25
96
|
|
|
26
97
|
function testEncapsulationDecapsulation(): void {
|
|
@@ -31,26 +102,19 @@ function testEncapsulationDecapsulation(): void {
|
|
|
31
102
|
const ss2 = decapsulate(secretKey, ciphertext)
|
|
32
103
|
|
|
33
104
|
if (ciphertext.length !== 1088) {
|
|
34
|
-
throw new Error(
|
|
35
|
-
}
|
|
36
|
-
if (ss1.length !== 32) {
|
|
37
|
-
throw new Error(`Shared secret size mismatch: expected 32, got ${ss1.length}`)
|
|
38
|
-
}
|
|
39
|
-
if (ss2.length !== 32) {
|
|
40
|
-
throw new Error(`Decapsulated secret size mismatch: expected 32, got ${ss2.length}`)
|
|
105
|
+
throw new Error('Ciphertext size mismatch: expected 1088, got ' + ciphertext.length)
|
|
41
106
|
}
|
|
107
|
+
if (ss1.length !== 32) throw new Error('Shared secret size mismatch: got ' + ss1.length)
|
|
108
|
+
if (ss2.length !== 32) throw new Error('Decapsulated secret size mismatch: got ' + ss2.length)
|
|
42
109
|
|
|
43
|
-
// Both parties should derive same shared secret
|
|
44
110
|
if (!ss1.equals(ss2)) {
|
|
45
111
|
console.log(' ✗ Encapsulated SS: ' + ss1.toString('hex').slice(0, 32) + '...')
|
|
46
112
|
console.log(' ✗ Decapsulated SS: ' + ss2.toString('hex').slice(0, 32) + '...')
|
|
47
113
|
throw new Error('Encapsulation/decapsulation mismatch: shared secrets do not match')
|
|
48
114
|
}
|
|
49
115
|
|
|
50
|
-
console.log(
|
|
51
|
-
console.log(
|
|
52
|
-
console.log(` ✓ Decapsulated SS: ${ss2.toString('hex').slice(0, 16)}...`)
|
|
53
|
-
console.log(` ✓ Shared secrets match: VERIFIED`)
|
|
116
|
+
console.log(' ✓ Ciphertext: ' + ciphertext.length + ' bytes')
|
|
117
|
+
console.log(' ✓ Shared secrets match: ' + ss1.toString('hex').slice(0, 16) + '...')
|
|
54
118
|
}
|
|
55
119
|
|
|
56
120
|
function testMultipleRounds(): void {
|
|
@@ -59,65 +123,74 @@ function testMultipleRounds(): void {
|
|
|
59
123
|
const { publicKey, secretKey } = generateKeyPair()
|
|
60
124
|
const secrets: Buffer[] = []
|
|
61
125
|
|
|
62
|
-
|
|
126
|
+
// Decode failure is probabilistic, so a handful of rounds proves little.
|
|
127
|
+
// 25 keeps the gate under a third of a second while actually sampling noise.
|
|
128
|
+
for (let i = 0; i < 25; i++) {
|
|
63
129
|
const { ciphertext, sharedSecret } = encapsulate(publicKey)
|
|
64
130
|
const recovered = decapsulate(secretKey, ciphertext)
|
|
65
131
|
|
|
66
132
|
if (!sharedSecret.equals(recovered)) {
|
|
67
|
-
throw new Error(
|
|
133
|
+
throw new Error('Round ' + (i + 1) + ': shared secrets do not match')
|
|
68
134
|
}
|
|
69
135
|
|
|
70
136
|
secrets.push(sharedSecret)
|
|
71
137
|
}
|
|
72
138
|
|
|
73
|
-
// Each encapsulation should produce different ciphertext (randomness)
|
|
74
139
|
const ciphertexts = new Set<string>()
|
|
75
140
|
for (let i = 0; i < 5; i++) {
|
|
76
|
-
|
|
77
|
-
|
|
141
|
+
ciphertexts.add(encapsulate(publicKey).ciphertext.toString('hex'))
|
|
142
|
+
}
|
|
143
|
+
if (ciphertexts.size < 5) {
|
|
144
|
+
throw new Error('encapsulation is not randomised: ' + ciphertexts.size + '/5 unique')
|
|
78
145
|
}
|
|
79
146
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
147
|
+
console.log(' ✓ Randomness: ' + ciphertexts.size + '/5 unique ciphertexts')
|
|
148
|
+
console.log(' ✓ ' + secrets.length + ' rounds: all shared secrets recovered')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function testWrongKeyDoesNotRecover(): void {
|
|
152
|
+
console.log('Test: an unrelated secret key does not recover the secret...')
|
|
153
|
+
|
|
154
|
+
const alice = generateKeyPair()
|
|
155
|
+
const mallory = generateKeyPair()
|
|
156
|
+
const { ciphertext, sharedSecret } = encapsulate(alice.publicKey)
|
|
157
|
+
|
|
158
|
+
if (decapsulate(mallory.secretKey, ciphertext).equals(sharedSecret)) {
|
|
159
|
+
throw new Error('an unrelated secret key recovered the shared secret')
|
|
84
160
|
}
|
|
85
161
|
|
|
86
|
-
console.log(
|
|
162
|
+
console.log(' ✓ unrelated key yields a different secret')
|
|
87
163
|
}
|
|
88
164
|
|
|
89
165
|
function testErrorHandling(): void {
|
|
90
166
|
console.log('Test: Error handling...')
|
|
91
167
|
|
|
92
|
-
const { publicKey } = generateKeyPair()
|
|
93
|
-
const invalid_pk = Buffer.alloc(100) // Wrong size
|
|
94
|
-
|
|
95
168
|
let caught = false
|
|
96
169
|
try {
|
|
97
|
-
encapsulate(
|
|
98
|
-
} catch
|
|
170
|
+
encapsulate(Buffer.alloc(100))
|
|
171
|
+
} catch {
|
|
99
172
|
caught = true
|
|
100
173
|
}
|
|
101
|
-
|
|
102
|
-
if (!caught) {
|
|
103
|
-
throw new Error('Should reject invalid public key size')
|
|
104
|
-
}
|
|
174
|
+
if (!caught) throw new Error('Should reject invalid public key size')
|
|
105
175
|
|
|
106
176
|
console.log(' ✓ Invalid public key size rejected')
|
|
107
177
|
}
|
|
108
178
|
|
|
109
|
-
|
|
110
|
-
console.log('🔐 Kyber-768
|
|
179
|
+
function runTests(): void {
|
|
180
|
+
console.log('🔐 Kyber-768 KEM round-trip tests\n')
|
|
111
181
|
|
|
112
182
|
try {
|
|
183
|
+
testSerializationRoundTrip()
|
|
184
|
+
testMessageDecodeBand()
|
|
113
185
|
testKeyGeneration()
|
|
114
186
|
testEncapsulationDecapsulation()
|
|
115
187
|
testMultipleRounds()
|
|
188
|
+
testWrongKeyDoesNotRecover()
|
|
116
189
|
testErrorHandling()
|
|
117
190
|
|
|
118
|
-
console.log('\n✅
|
|
191
|
+
console.log('\n✅ Round trip and structure verified — NOT FIPS 203 conformant')
|
|
119
192
|
} catch (error) {
|
|
120
|
-
console.error(
|
|
193
|
+
console.error('\n❌ Test failed: ' + (error instanceof Error ? error.message : String(error)))
|
|
121
194
|
process.exit(1)
|
|
122
195
|
}
|
|
123
196
|
}
|
package/src/crypto/kyber-real.ts
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Kyber-768
|
|
2
|
+
* Kyber-768 shaped module-LWE KEM — CORRECT ROUND TRIP, NOT FIPS 203.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* encapsulate/decapsulate agree: 2000/2000 round trips recover the shared
|
|
5
|
+
* secret, with a measured decode margin of 639 of the 832 available (worst
|
|
6
|
+
* coefficient over 76 800 sampled). That is the property this file has.
|
|
7
|
+
*
|
|
8
|
+
* It is NOT ML-KEM and MUST NOT be used as if it were. Do not use it to
|
|
9
|
+
* protect anything. Concretely, against FIPS 203:
|
|
10
|
+
*
|
|
11
|
+
* - The matrix A is sampled by CBD, so its coefficients are in {-1,0,1}
|
|
12
|
+
* rather than uniform mod q. The spec samples A uniformly via SHAKE-128
|
|
13
|
+
* rejection sampling. A small A means the module-LWE instance underneath
|
|
14
|
+
* is not the hard problem the security argument rests on. This is the
|
|
15
|
+
* single most important gap.
|
|
16
|
+
* - Noise is CBD with eta = 1. ML-KEM-768 uses eta1 = eta2 = 2. The
|
|
17
|
+
* KYBER_ETA constants below record the target, not what the sampler does.
|
|
18
|
+
* - SHA-256 stands in for SHAKE-128/SHAKE-256 as XOF and PRF.
|
|
19
|
+
* - Encapsulation omits the e1 and e2 error terms entirely.
|
|
20
|
+
* - Decapsulation has no Fujisaki-Okamoto step: it never re-encrypts and
|
|
21
|
+
* compares, and never uses the stored z, so there is no implicit
|
|
22
|
+
* rejection and no IND-CCA2 claim. IND-CPA at best, and not that either
|
|
23
|
+
* while A stays small.
|
|
24
|
+
* - Arithmetic is schoolbook, not NTT. The math is right; the wire format
|
|
25
|
+
* therefore differs from the spec's NTT-domain encoding.
|
|
26
|
+
* - No NIST KAT vectors are checked, and nothing here interoperates with a
|
|
27
|
+
* conforming implementation.
|
|
28
|
+
*
|
|
29
|
+
* Closing the first item is what would make this cryptography rather than
|
|
30
|
+
* arithmetic that happens to round trip.
|
|
6
31
|
*/
|
|
7
32
|
|
|
8
33
|
import { randomBytes, createHash } from 'node:crypto'
|
|
@@ -15,8 +40,10 @@ import { abs, round, floor, sqrt } from '../0/algebra.ts'
|
|
|
15
40
|
const KYBER_N = 256 // Polynomial degree
|
|
16
41
|
const KYBER_Q = 3329 // Prime modulus
|
|
17
42
|
const KYBER_K = 3 // Module dimension for Kyber-768
|
|
43
|
+
// Target parameters for ML-KEM-768. The sampler below implements eta = 1 and
|
|
44
|
+
// does not read these yet — see the conformance gaps at the top of the file.
|
|
18
45
|
const KYBER_ETA1 = 2 // Noise parameter for key generation
|
|
19
|
-
const KYBER_ETA2 =
|
|
46
|
+
const KYBER_ETA2 = 2 // Noise parameter for encapsulation
|
|
20
47
|
const KYBER_DU = 10 // Compression parameter for u
|
|
21
48
|
const KYBER_DV = 4 // Compression parameter for v
|
|
22
49
|
const KYBER_PUBLIC_KEY_SIZE = 1184 // (k * 384 + 32) = (3 * 384 + 32)
|
|
@@ -49,7 +76,8 @@ export function polyFromBytes(seed: Buffer, nonce: number): Polynomial {
|
|
|
49
76
|
bytes = Buffer.concat([bytes, shake2.digest()])
|
|
50
77
|
}
|
|
51
78
|
|
|
52
|
-
// Centered binomial distribution:
|
|
79
|
+
// Centered binomial distribution with eta = 1: one bit for a, one for b,
|
|
80
|
+
// so coefficients land in {-1, 0, 1}. ML-KEM-768 wants eta = 2.
|
|
53
81
|
for (let i = 0; i < KYBER_N; i++) {
|
|
54
82
|
const byte_idx = floor((i * 2) / 8)
|
|
55
83
|
const bit_offset = (i * 2) % 8
|
|
@@ -71,98 +99,32 @@ export function polyAdd(a: Polynomial, b: Polynomial): Polynomial {
|
|
|
71
99
|
return result
|
|
72
100
|
}
|
|
73
101
|
|
|
74
|
-
// Polynomial multiplication
|
|
102
|
+
// Polynomial multiplication: a * b in Z_Q[x] / (x^256 + 1)
|
|
103
|
+
// Using schoolbook multiplication (simpler, correct, not optimized)
|
|
75
104
|
export function polyMultiply(a: Polynomial, b: Polynomial): Polynomial {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const cNTT = new Uint16Array(KYBER_N)
|
|
80
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
81
|
-
cNTT[i] = (aNTT[i] * bNTT[i]) % KYBER_Q
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return inverseNTT(cNTT)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Number Theoretic Transform (NTT)
|
|
88
|
-
function ntt(poly: Polynomial): Polynomial {
|
|
89
|
-
const result = new Uint16Array(poly)
|
|
90
|
-
const zeta = 17 // Primitive root of unity modulo Q
|
|
91
|
-
|
|
92
|
-
for (let len = 128; len >= 1; len >>>= 1) {
|
|
93
|
-
for (let start = 0; start < KYBER_N; start += 2 * len) {
|
|
94
|
-
const zeta_pow = modExp(zeta, start / (2 * len), KYBER_Q)
|
|
95
|
-
|
|
96
|
-
for (let i = start; i < start + len; i++) {
|
|
97
|
-
const t = (result[i + len] * zeta_pow) % KYBER_Q
|
|
98
|
-
result[i + len] = (result[i] - t + KYBER_Q) % KYBER_Q
|
|
99
|
-
result[i] = (result[i] + t) % KYBER_Q
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return result
|
|
105
|
-
}
|
|
105
|
+
// Result: c[i] = sum_{j=0}^{255} a[j] * b[(i-j) mod 256]
|
|
106
|
+
// But mod (x^256 + 1), so x^256 ≡ -1
|
|
107
|
+
// This means c[i] = sum_{j=0}^{i} a[j]*b[i-j] - sum_{j=i+1}^{255} a[j]*b[i+256-j]
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
function inverseNTT(poly: Polynomial): Polynomial {
|
|
109
|
-
const result = new Uint16Array(poly)
|
|
110
|
-
const inv = modInverse(KYBER_N, KYBER_Q)
|
|
111
|
-
|
|
112
|
-
for (let len = 1; len < KYBER_N; len <<= 1) {
|
|
113
|
-
for (let start = 0; start < KYBER_N; start += 2 * len) {
|
|
114
|
-
const zeta_pow = modExp(17, -(start / len + 1), KYBER_Q)
|
|
115
|
-
|
|
116
|
-
for (let i = start; i < start + len; i++) {
|
|
117
|
-
const t = (result[i + len] * zeta_pow) % KYBER_Q
|
|
118
|
-
result[i + len] = (result[i] - t + KYBER_Q) % KYBER_Q
|
|
119
|
-
result[i] = (result[i] + t) % KYBER_Q
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
109
|
+
const c = new Uint16Array(KYBER_N)
|
|
123
110
|
|
|
124
111
|
for (let i = 0; i < KYBER_N; i++) {
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return result
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Modular exponentiation
|
|
132
|
-
function modExp(base: number, exp: number, mod: number): number {
|
|
133
|
-
if (exp < 0) {
|
|
134
|
-
// For negative exponent: compute base^exp = (base^(-exp))^(-1)
|
|
135
|
-
// Using Fermat's little theorem: a^(-1) ≡ a^(p-2) mod p
|
|
136
|
-
const pos_exp = modExp(base, -exp, mod)
|
|
137
|
-
return modExp(pos_exp, mod - 2, mod)
|
|
138
|
-
}
|
|
112
|
+
let acc = 0n
|
|
139
113
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
while (exp > 0) {
|
|
144
|
-
if (exp % 2 === 1) {
|
|
145
|
-
result = (result * base) % mod
|
|
114
|
+
// Regular part: j from 0 to i
|
|
115
|
+
for (let j = 0; j <= i; j++) {
|
|
116
|
+
acc += BigInt(a[j]) * BigInt(b[i - j])
|
|
146
117
|
}
|
|
147
|
-
exp = floor(exp / 2)
|
|
148
|
-
base = (base * base) % mod
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return result
|
|
152
|
-
}
|
|
153
118
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
119
|
+
// Wrapped part (negative due to x^256 = -1): j from i+1 to 255
|
|
120
|
+
for (let j = i + 1; j < KYBER_N; j++) {
|
|
121
|
+
acc -= BigInt(a[j]) * BigInt(b[KYBER_N + i - j])
|
|
122
|
+
}
|
|
158
123
|
|
|
159
|
-
|
|
160
|
-
const quotient = floor(old_r / r)
|
|
161
|
-
;[old_r, r] = [r, old_r - quotient * r]
|
|
162
|
-
;[old_s, s] = [s, old_s - quotient * s]
|
|
124
|
+
c[i] = Number(((acc % BigInt(KYBER_Q)) + BigInt(KYBER_Q)) % BigInt(KYBER_Q))
|
|
163
125
|
}
|
|
164
126
|
|
|
165
|
-
return
|
|
127
|
+
return c
|
|
166
128
|
}
|
|
167
129
|
|
|
168
130
|
// ============================================================================
|
|
@@ -357,16 +319,20 @@ function polynomialsToBytes(polys: Polynomial[]): Uint8Array {
|
|
|
357
319
|
return bytes
|
|
358
320
|
}
|
|
359
321
|
|
|
360
|
-
function polynomialToBytes(poly: Polynomial): Uint8Array {
|
|
322
|
+
export function polynomialToBytes(poly: Polynomial): Uint8Array {
|
|
361
323
|
const bytes = new Uint8Array(384)
|
|
362
324
|
for (let i = 0; i < KYBER_N; i++) {
|
|
363
325
|
const idx = floor((i * 12) / 8)
|
|
364
326
|
const shift = ((i * 12) % 8)
|
|
365
327
|
const val = poly[i] & ((1 << 12) - 1) // 12-bit encoding
|
|
366
328
|
bytes[idx] = (bytes[idx] | (val << shift)) & 0xff
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
329
|
+
// The carry byte is ALWAYS needed. A 12-bit value never fits in one byte,
|
|
330
|
+
// so at shift 0 the top four bits belong to idx+1 just as much as at
|
|
331
|
+
// shift 4 — guarding this on `shift > 0` silently truncated every
|
|
332
|
+
// even-indexed coefficient to its low byte, which is 118 of 256 for a
|
|
333
|
+
// typical polynomial. The reader was always correct; only the writer lost
|
|
334
|
+
// the bits, so the corruption only showed up after a serialize round trip.
|
|
335
|
+
bytes[idx + 1] = (bytes[idx + 1] | (val >> (8 - shift))) & 0xff
|
|
370
336
|
}
|
|
371
337
|
return bytes
|
|
372
338
|
}
|
|
@@ -379,7 +345,7 @@ function bytesToPolynomials(bytes: Buffer, count: number): Polynomial[] {
|
|
|
379
345
|
return polys
|
|
380
346
|
}
|
|
381
347
|
|
|
382
|
-
function bytesToPolynomial(bytes: Buffer): Polynomial {
|
|
348
|
+
export function bytesToPolynomial(bytes: Buffer): Polynomial {
|
|
383
349
|
const poly = new Uint16Array(KYBER_N)
|
|
384
350
|
for (let i = 0; i < KYBER_N; i++) {
|
|
385
351
|
const idx = floor((i * 12) / 8)
|
|
@@ -389,7 +355,7 @@ function bytesToPolynomial(bytes: Buffer): Polynomial {
|
|
|
389
355
|
return poly
|
|
390
356
|
}
|
|
391
357
|
|
|
392
|
-
function messageToPoly(msg: Buffer): Polynomial {
|
|
358
|
+
export function messageToPoly(msg: Buffer): Polynomial {
|
|
393
359
|
const poly = new Uint16Array(KYBER_N)
|
|
394
360
|
for (let i = 0; i < 32; i++) {
|
|
395
361
|
const byte = msg[i]!
|
|
@@ -402,12 +368,18 @@ function messageToPoly(msg: Buffer): Polynomial {
|
|
|
402
368
|
return poly
|
|
403
369
|
}
|
|
404
370
|
|
|
405
|
-
function polyToMessage(poly: Polynomial): Buffer {
|
|
371
|
+
export function polyToMessage(poly: Polynomial): Buffer {
|
|
406
372
|
const msg = Buffer.alloc(32)
|
|
407
|
-
const threshold = floor(KYBER_Q / 2)
|
|
408
373
|
for (let i = 0; i < 32; i++) {
|
|
409
374
|
for (let j = 0; j < 8; j++) {
|
|
410
|
-
|
|
375
|
+
// A coefficient carries a 1 when it lies NEARER q/2 than 0. The ring
|
|
376
|
+
// wraps, so this is a band [q/4, 3q/4), never a single threshold: a
|
|
377
|
+
// 0-bit nudged negative by noise lands at q-5, and `>= q/2` reads that
|
|
378
|
+
// as 1. Half the bits flipped on noise sign alone.
|
|
379
|
+
//
|
|
380
|
+
// Written as 4c ∈ [q, 3q) so it stays integer — no division, no float.
|
|
381
|
+
const c4 = poly[i * 8 + j]! * 4
|
|
382
|
+
if (c4 >= KYBER_Q && c4 < 3 * KYBER_Q) {
|
|
411
383
|
msg[i] = msg[i]! | (1 << j)
|
|
412
384
|
}
|
|
413
385
|
}
|
|
@@ -415,12 +387,37 @@ function polyToMessage(poly: Polynomial): Buffer {
|
|
|
415
387
|
return msg
|
|
416
388
|
}
|
|
417
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Compress / decompress a single coefficient, FIPS 203 §4.2.1.
|
|
392
|
+
*
|
|
393
|
+
* Compress_d(x) = round(x · 2^d / q) mod 2^d
|
|
394
|
+
* Decompress_d(y) = round(y · q / 2^d)
|
|
395
|
+
*
|
|
396
|
+
* Both were `floor` against a scale of 2^d − 1. Two floors in series bias the
|
|
397
|
+
* result the same way every time, and at d = 4 that bias is q/(2·15) ≈ 111 —
|
|
398
|
+
* which was the measured MEDIAN decode error, against a boundary of q/4 = 832.
|
|
399
|
+
* Rounding to the spec's 2^d scale centres the error and halves it, so the
|
|
400
|
+
* noise budget pays for noise rather than for a constant.
|
|
401
|
+
*
|
|
402
|
+
* The `mod 2^d` on compress is load-bearing: q−1 rounds up to 2^d and must
|
|
403
|
+
* wrap to 0, because q−1 is −1 in the centred ring and belongs beside 0.
|
|
404
|
+
*/
|
|
405
|
+
function compressCoefficient(x: number, d: number): number {
|
|
406
|
+
const scale = 1 << d
|
|
407
|
+
return floor((x * scale + floor(KYBER_Q / 2)) / KYBER_Q) & (scale - 1)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function decompressCoefficient(y: number, d: number): number {
|
|
411
|
+
const scale = 1 << d
|
|
412
|
+
return floor((y * KYBER_Q + scale / 2) / scale)
|
|
413
|
+
}
|
|
414
|
+
|
|
418
415
|
function compressPolynomials(polys: Polynomial[], d: number): Buffer {
|
|
419
416
|
const bytes = Buffer.alloc(polys.length * KYBER_N * d / 8)
|
|
420
417
|
let bit_idx = 0
|
|
421
418
|
for (const poly of polys) {
|
|
422
419
|
for (let i = 0; i < KYBER_N; i++) {
|
|
423
|
-
const compressed =
|
|
420
|
+
const compressed = compressCoefficient(poly[i]!, d)
|
|
424
421
|
for (let j = 0; j < d; j++) {
|
|
425
422
|
if ((compressed >> j) & 1) {
|
|
426
423
|
bytes[floor(bit_idx / 8)] |= 1 << (bit_idx % 8)
|
|
@@ -436,7 +433,7 @@ function compressPolynomial(poly: Polynomial, d: number): Buffer {
|
|
|
436
433
|
const bytes = Buffer.alloc(KYBER_N * d / 8)
|
|
437
434
|
let bit_idx = 0
|
|
438
435
|
for (let i = 0; i < KYBER_N; i++) {
|
|
439
|
-
const compressed =
|
|
436
|
+
const compressed = compressCoefficient(poly[i]!, d)
|
|
440
437
|
for (let j = 0; j < d; j++) {
|
|
441
438
|
if ((compressed >> j) & 1) {
|
|
442
439
|
bytes[floor(bit_idx / 8)] |= 1 << (bit_idx % 8)
|
|
@@ -460,7 +457,7 @@ function decompressPolynomials(bytes: Buffer, count: number, d: number): Polynom
|
|
|
460
457
|
}
|
|
461
458
|
bit_idx++
|
|
462
459
|
}
|
|
463
|
-
poly[i] =
|
|
460
|
+
poly[i] = decompressCoefficient(compressed, d)
|
|
464
461
|
}
|
|
465
462
|
polys[p] = poly
|
|
466
463
|
}
|
|
@@ -478,7 +475,7 @@ function decompressPolynomial(bytes: Buffer, d: number): Polynomial {
|
|
|
478
475
|
}
|
|
479
476
|
bit_idx++
|
|
480
477
|
}
|
|
481
|
-
poly[i] =
|
|
478
|
+
poly[i] = decompressCoefficient(compressed, d)
|
|
482
479
|
}
|
|
483
480
|
return poly
|
|
484
481
|
}
|