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 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.3.
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.3
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.3`** · owner `ceccec` · git tag `v1.0.3`
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",
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
- * Real Kyber-768 Test: Actual NIST Implementation
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
- * Tests polynomial arithmetic, NTT, key generation, encapsulation, decapsulation.
5
- * NOT simulation - real cryptographic operations.
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 { generateKeyPair, encapsulate, decapsulate } from './kyber-real.ts'
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(`Public key size mismatch: expected 1184, got ${publicKey.length}`)
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(`Secret key size mismatch: expected 2400, got ${secretKey.length}`)
90
+ throw new Error('Secret key size mismatch: expected 2400, got ' + secretKey.length)
20
91
  }
21
92
 
22
- console.log(` ✓ Public key: ${publicKey.length} bytes`)
23
- console.log(` ✓ Secret key: ${secretKey.length} bytes`)
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(`Ciphertext size mismatch: expected 1088, got ${ciphertext.length}`)
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(` ✓ Ciphertext: ${ciphertext.length} bytes`)
51
- console.log(`Encapsulated SS: ${ss1.toString('hex').slice(0, 16)}...`)
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
- for (let i = 0; i < 5; i++) {
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(`Round ${i + 1}: shared secrets do not match`)
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
- const { ciphertext } = encapsulate(publicKey)
77
- ciphertexts.add(ciphertext.toString('hex'))
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
- if (ciphertexts.size < 3) {
81
- console.warn(' Warning: encapsulations not sufficiently random')
82
- } else {
83
- console.log(` ✓ Randomness verified: ${ciphertexts.size}/5 unique ciphertexts`)
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(`${secrets.length} rounds: all shared secrets recovered`)
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(invalid_pk)
98
- } catch (e) {
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
- async function runTests(): Promise<void> {
110
- console.log('🔐 Kyber-768 Real Implementation Tests\n')
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✅ All Kyber-768 tests passed! (NIST FIPS 203 compliant)')
191
+ console.log('\n✅ Round trip and structure verified — NOT FIPS 203 conformant')
119
192
  } catch (error) {
120
- console.error(`\n❌ Test failed: ${error instanceof Error ? error.message : String(error)}`)
193
+ console.error('\n❌ Test failed: ' + (error instanceof Error ? error.message : String(error)))
121
194
  process.exit(1)
122
195
  }
123
196
  }
@@ -1,8 +1,33 @@
1
1
  /**
2
- * Kyber-768: Real NIST FIPS 203 Implementation
2
+ * Kyber-768 shaped module-LWE KEM — CORRECT ROUND TRIP, NOT FIPS 203.
3
3
  *
4
- * Proper polynomial arithmetic, NTT, noise sampling.
5
- * NOT a toy version - actual cryptographic implementation.
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 = 1 // Noise parameter for encapsulation
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: sample from {-KYBER_ETA1, ..., +KYBER_ETA1}
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 via NTT (Number Theoretic Transform)
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
- const aNTT = ntt(a)
77
- const bNTT = ntt(b)
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
- // Inverse NTT
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
- result[i] = (result[i] * inv) % KYBER_Q
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
- let result = 1
141
- base = base % mod
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
- // Modular inverse via extended Euclidean algorithm
155
- function modInverse(a: number, m: number): number {
156
- let [old_r, r] = [a, m]
157
- let [old_s, s] = [1, 0]
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
- while (r !== 0) {
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 (old_s + m) % m
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
- if (shift > 0) {
368
- bytes[idx + 1] = (bytes[idx + 1] | (val >> (8 - shift))) & 0xff
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
- if (poly[i * 8 + j]! > threshold) {
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 = floor((poly[i] * ((1 << d) - 1)) / KYBER_Q)
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 = floor((poly[i] * ((1 << d) - 1)) / KYBER_Q)
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] = floor((compressed * KYBER_Q) / ((1 << d) - 1))
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] = floor((compressed * KYBER_Q) / ((1 << d) - 1))
478
+ poly[i] = decompressCoefficient(compressed, d)
482
479
  }
483
480
  return poly
484
481
  }