zeropoint-node 1.0.4 → 1.0.5
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 +97 -0
- package/CITATION.cff +1 -1
- package/README.md +1 -1
- package/package.json +5 -3
- package/src/crypto/ml-kem-768-acvp.json +492 -0
- package/src/crypto/ml-kem.test.ts +140 -0
- package/src/crypto/ml-kem.ts +554 -0
- package/src/quantum/error-correction.ts +69 -16
- package/src/quantum/hybrid.ts +2 -2
- package/src/quantum/simulator.ts +19 -0
- package/src/quantum/tomography.ts +4 -4
- package/src/verification/lean-bridge.test.ts +109 -228
- package/src/verification/lean-bridge.ts +401 -153
- package/src/crypto/kyber-real.test.ts +0 -198
- package/src/crypto/kyber-real.ts +0 -489
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
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.
|
|
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.
|
|
10
|
-
*/
|
|
11
|
-
|
|
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
|
-
}
|
|
80
|
-
|
|
81
|
-
function testKeyGeneration(): void {
|
|
82
|
-
console.log('Test: Kyber-768 key pair generation...')
|
|
83
|
-
|
|
84
|
-
const { publicKey, secretKey } = generateKeyPair()
|
|
85
|
-
|
|
86
|
-
if (publicKey.length !== 1184) {
|
|
87
|
-
throw new Error('Public key size mismatch: expected 1184, got ' + publicKey.length)
|
|
88
|
-
}
|
|
89
|
-
if (secretKey.length !== 2400) {
|
|
90
|
-
throw new Error('Secret key size mismatch: expected 2400, got ' + secretKey.length)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
console.log(' ✓ Public key: ' + publicKey.length + ' bytes')
|
|
94
|
-
console.log(' ✓ Secret key: ' + secretKey.length + ' bytes')
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function testEncapsulationDecapsulation(): void {
|
|
98
|
-
console.log('Test: Kyber-768 encapsulation & decapsulation...')
|
|
99
|
-
|
|
100
|
-
const { publicKey, secretKey } = generateKeyPair()
|
|
101
|
-
const { ciphertext, sharedSecret: ss1 } = encapsulate(publicKey)
|
|
102
|
-
const ss2 = decapsulate(secretKey, ciphertext)
|
|
103
|
-
|
|
104
|
-
if (ciphertext.length !== 1088) {
|
|
105
|
-
throw new Error('Ciphertext size mismatch: expected 1088, got ' + ciphertext.length)
|
|
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)
|
|
109
|
-
|
|
110
|
-
if (!ss1.equals(ss2)) {
|
|
111
|
-
console.log(' ✗ Encapsulated SS: ' + ss1.toString('hex').slice(0, 32) + '...')
|
|
112
|
-
console.log(' ✗ Decapsulated SS: ' + ss2.toString('hex').slice(0, 32) + '...')
|
|
113
|
-
throw new Error('Encapsulation/decapsulation mismatch: shared secrets do not match')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
console.log(' ✓ Ciphertext: ' + ciphertext.length + ' bytes')
|
|
117
|
-
console.log(' ✓ Shared secrets match: ' + ss1.toString('hex').slice(0, 16) + '...')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function testMultipleRounds(): void {
|
|
121
|
-
console.log('Test: Multiple encapsulation/decapsulation rounds...')
|
|
122
|
-
|
|
123
|
-
const { publicKey, secretKey } = generateKeyPair()
|
|
124
|
-
const secrets: Buffer[] = []
|
|
125
|
-
|
|
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++) {
|
|
129
|
-
const { ciphertext, sharedSecret } = encapsulate(publicKey)
|
|
130
|
-
const recovered = decapsulate(secretKey, ciphertext)
|
|
131
|
-
|
|
132
|
-
if (!sharedSecret.equals(recovered)) {
|
|
133
|
-
throw new Error('Round ' + (i + 1) + ': shared secrets do not match')
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
secrets.push(sharedSecret)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const ciphertexts = new Set<string>()
|
|
140
|
-
for (let i = 0; i < 5; i++) {
|
|
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')
|
|
145
|
-
}
|
|
146
|
-
|
|
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')
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
console.log(' ✓ unrelated key yields a different secret')
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function testErrorHandling(): void {
|
|
166
|
-
console.log('Test: Error handling...')
|
|
167
|
-
|
|
168
|
-
let caught = false
|
|
169
|
-
try {
|
|
170
|
-
encapsulate(Buffer.alloc(100))
|
|
171
|
-
} catch {
|
|
172
|
-
caught = true
|
|
173
|
-
}
|
|
174
|
-
if (!caught) throw new Error('Should reject invalid public key size')
|
|
175
|
-
|
|
176
|
-
console.log(' ✓ Invalid public key size rejected')
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function runTests(): void {
|
|
180
|
-
console.log('🔐 Kyber-768 KEM round-trip tests\n')
|
|
181
|
-
|
|
182
|
-
try {
|
|
183
|
-
testSerializationRoundTrip()
|
|
184
|
-
testMessageDecodeBand()
|
|
185
|
-
testKeyGeneration()
|
|
186
|
-
testEncapsulationDecapsulation()
|
|
187
|
-
testMultipleRounds()
|
|
188
|
-
testWrongKeyDoesNotRecover()
|
|
189
|
-
testErrorHandling()
|
|
190
|
-
|
|
191
|
-
console.log('\n✅ Round trip and structure verified — NOT FIPS 203 conformant')
|
|
192
|
-
} catch (error) {
|
|
193
|
-
console.error('\n❌ Test failed: ' + (error instanceof Error ? error.message : String(error)))
|
|
194
|
-
process.exit(1)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
runTests()
|
package/src/crypto/kyber-real.ts
DELETED
|
@@ -1,489 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kyber-768 shaped module-LWE KEM — CORRECT ROUND TRIP, NOT FIPS 203.
|
|
3
|
-
*
|
|
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.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import { randomBytes, createHash } from 'node:crypto'
|
|
34
|
-
import { abs, round, floor, sqrt } from '../0/algebra.ts'
|
|
35
|
-
|
|
36
|
-
// ============================================================================
|
|
37
|
-
// KYBER-768 PARAMETERS (NIST FIPS 203)
|
|
38
|
-
// ============================================================================
|
|
39
|
-
|
|
40
|
-
const KYBER_N = 256 // Polynomial degree
|
|
41
|
-
const KYBER_Q = 3329 // Prime modulus
|
|
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.
|
|
45
|
-
const KYBER_ETA1 = 2 // Noise parameter for key generation
|
|
46
|
-
const KYBER_ETA2 = 2 // Noise parameter for encapsulation
|
|
47
|
-
const KYBER_DU = 10 // Compression parameter for u
|
|
48
|
-
const KYBER_DV = 4 // Compression parameter for v
|
|
49
|
-
const KYBER_PUBLIC_KEY_SIZE = 1184 // (k * 384 + 32) = (3 * 384 + 32)
|
|
50
|
-
const KYBER_SECRET_KEY_SIZE = 2400 // (k * 384 + 128 + 64)
|
|
51
|
-
const KYBER_CIPHERTEXT_SIZE = 1088 // (du * k * 32 + dv * 32)
|
|
52
|
-
const KYBER_SHARED_SECRET_SIZE = 32
|
|
53
|
-
|
|
54
|
-
// ============================================================================
|
|
55
|
-
// POLYNOMIAL ARITHMETIC (mod Q)
|
|
56
|
-
// ============================================================================
|
|
57
|
-
|
|
58
|
-
export type Polynomial = Uint16Array<ArrayBuffer> // 256 coefficients mod 3329
|
|
59
|
-
|
|
60
|
-
// Create polynomial from bytes using CBD sampling
|
|
61
|
-
export function polyFromBytes(seed: Buffer, nonce: number): Polynomial {
|
|
62
|
-
const poly = new Uint16Array(KYBER_N)
|
|
63
|
-
|
|
64
|
-
// Expand seed to 64 bytes (need 512 bits for 256 coefficients with eta=2)
|
|
65
|
-
const shake = createHash('sha256')
|
|
66
|
-
shake.update(seed)
|
|
67
|
-
shake.update(Buffer.from([nonce]))
|
|
68
|
-
let bytes = shake.digest()
|
|
69
|
-
|
|
70
|
-
// For 256 coefficients with 2 bits each, we need 64 bytes
|
|
71
|
-
if (bytes.length < 64) {
|
|
72
|
-
// Expand by hashing again with incremented nonce
|
|
73
|
-
const shake2 = createHash('sha256')
|
|
74
|
-
shake2.update(seed)
|
|
75
|
-
shake2.update(Buffer.from([nonce + 256]))
|
|
76
|
-
bytes = Buffer.concat([bytes, shake2.digest()])
|
|
77
|
-
}
|
|
78
|
-
|
|
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.
|
|
81
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
82
|
-
const byte_idx = floor((i * 2) / 8)
|
|
83
|
-
const bit_offset = (i * 2) % 8
|
|
84
|
-
|
|
85
|
-
const a = (bytes[byte_idx] >> bit_offset) & 1
|
|
86
|
-
const b = (bytes[byte_idx] >> (bit_offset + 1)) & 1
|
|
87
|
-
poly[i] = (a - b + KYBER_Q) % KYBER_Q
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return poly
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Polynomial addition mod Q
|
|
94
|
-
export function polyAdd(a: Polynomial, b: Polynomial): Polynomial {
|
|
95
|
-
const result = new Uint16Array(KYBER_N)
|
|
96
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
97
|
-
result[i] = (a[i] + b[i]) % KYBER_Q
|
|
98
|
-
}
|
|
99
|
-
return result
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Polynomial multiplication: a * b in Z_Q[x] / (x^256 + 1)
|
|
103
|
-
// Using schoolbook multiplication (simpler, correct, not optimized)
|
|
104
|
-
export function polyMultiply(a: Polynomial, b: Polynomial): Polynomial {
|
|
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]
|
|
108
|
-
|
|
109
|
-
const c = new Uint16Array(KYBER_N)
|
|
110
|
-
|
|
111
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
112
|
-
let acc = 0n
|
|
113
|
-
|
|
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])
|
|
117
|
-
}
|
|
118
|
-
|
|
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
|
-
}
|
|
123
|
-
|
|
124
|
-
c[i] = Number(((acc % BigInt(KYBER_Q)) + BigInt(KYBER_Q)) % BigInt(KYBER_Q))
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return c
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ============================================================================
|
|
131
|
-
// KYBER-768 KEY GENERATION
|
|
132
|
-
// ============================================================================
|
|
133
|
-
|
|
134
|
-
export interface KyberKeyPair {
|
|
135
|
-
readonly publicKey: Buffer
|
|
136
|
-
readonly secretKey: Buffer
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function generateKeyPair(): KyberKeyPair {
|
|
140
|
-
const d = randomBytes(32) // Seed for pseudorandom generation
|
|
141
|
-
const z = randomBytes(32) // Decapsulation seed
|
|
142
|
-
|
|
143
|
-
// Generate matrix A and secret vectors s, e
|
|
144
|
-
const A: Polynomial[][] = []
|
|
145
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
146
|
-
A[i] = []
|
|
147
|
-
for (let j = 0; j < KYBER_K; j++) {
|
|
148
|
-
A[i]![j] = polyFromBytes(d, i * KYBER_K + j)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const seed_se = randomBytes(64)
|
|
153
|
-
const s: Polynomial[] = []
|
|
154
|
-
const e: Polynomial[] = []
|
|
155
|
-
|
|
156
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
157
|
-
s[i] = polyFromBytes(seed_se.slice(0, 32), i)
|
|
158
|
-
e[i] = polyFromBytes(seed_se.slice(32), i)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Compute t = A * s + e
|
|
162
|
-
const t: Polynomial[] = []
|
|
163
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
164
|
-
let ti = new Uint16Array(KYBER_N)
|
|
165
|
-
for (let j = 0; j < KYBER_K; j++) {
|
|
166
|
-
const prod = polyMultiply(A[i]![j]!, s[j]!)
|
|
167
|
-
ti = polyAdd(ti, prod)
|
|
168
|
-
}
|
|
169
|
-
t[i] = polyAdd(ti, e[i]!)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Encode public key: t || seed
|
|
173
|
-
const publicKey = Buffer.concat([
|
|
174
|
-
Buffer.from(polynomialsToBytes(t)),
|
|
175
|
-
d,
|
|
176
|
-
])
|
|
177
|
-
|
|
178
|
-
// Encode secret key: s || e || d || z || pk_hash (NIST FIPS 203)
|
|
179
|
-
const secretKey = Buffer.concat([
|
|
180
|
-
Buffer.from(polynomialsToBytes(s)),
|
|
181
|
-
Buffer.from(polynomialsToBytes(e)),
|
|
182
|
-
d,
|
|
183
|
-
z,
|
|
184
|
-
createHash('sha256').update(publicKey).digest(),
|
|
185
|
-
])
|
|
186
|
-
|
|
187
|
-
return { publicKey, secretKey }
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ============================================================================
|
|
191
|
-
// KYBER-768 ENCAPSULATION
|
|
192
|
-
// ============================================================================
|
|
193
|
-
|
|
194
|
-
export interface Encapsulation {
|
|
195
|
-
readonly ciphertext: Buffer
|
|
196
|
-
readonly sharedSecret: Buffer
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function encapsulate(publicKey: Buffer): Encapsulation {
|
|
200
|
-
if (publicKey.length !== KYBER_PUBLIC_KEY_SIZE) {
|
|
201
|
-
throw new Error(`Invalid public key size: ${publicKey.length}`)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Extract t from public key
|
|
205
|
-
const t_bytes = publicKey.slice(0, KYBER_PUBLIC_KEY_SIZE - 32)
|
|
206
|
-
const seed = publicKey.slice(KYBER_PUBLIC_KEY_SIZE - 32)
|
|
207
|
-
const t = bytesToPolynomials(t_bytes, KYBER_K)
|
|
208
|
-
|
|
209
|
-
// Sample random message
|
|
210
|
-
const m = randomBytes(32)
|
|
211
|
-
|
|
212
|
-
// Encode message to polynomial
|
|
213
|
-
const r_seed = createHash('sha256')
|
|
214
|
-
.update(m)
|
|
215
|
-
.update(createHash('sha256').update(publicKey).digest())
|
|
216
|
-
.digest()
|
|
217
|
-
|
|
218
|
-
const r: Polynomial[] = []
|
|
219
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
220
|
-
r[i] = polyFromBytes(r_seed, i)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Compute u = A^T * r + e1 and v = t^T * r + e2 + msg
|
|
224
|
-
const u: Polynomial[] = []
|
|
225
|
-
const A: Polynomial[][] = []
|
|
226
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
227
|
-
A[i] = []
|
|
228
|
-
for (let j = 0; j < KYBER_K; j++) {
|
|
229
|
-
A[i]![j] = polyFromBytes(seed, i * KYBER_K + j)
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
234
|
-
let ui = new Uint16Array(KYBER_N)
|
|
235
|
-
for (let j = 0; j < KYBER_K; j++) {
|
|
236
|
-
const prod = polyMultiply(A[j]![i]!, r[j]!)
|
|
237
|
-
ui = polyAdd(ui, prod)
|
|
238
|
-
}
|
|
239
|
-
u[i] = ui
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
let v = new Uint16Array(KYBER_N)
|
|
243
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
244
|
-
const prod = polyMultiply(t[i]!, r[i]!)
|
|
245
|
-
v = polyAdd(v, prod)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Add message to v
|
|
249
|
-
const msg_poly = messageToPoly(m)
|
|
250
|
-
v = polyAdd(v, msg_poly)
|
|
251
|
-
|
|
252
|
-
// Compress u and v
|
|
253
|
-
const c1 = compressPolynomials(u, KYBER_DU)
|
|
254
|
-
const c2 = compressPolynomial(v, KYBER_DV)
|
|
255
|
-
|
|
256
|
-
const ciphertext = Buffer.concat([c1, c2])
|
|
257
|
-
|
|
258
|
-
// Derive shared secret
|
|
259
|
-
const sharedSecret = createHash('sha256')
|
|
260
|
-
.update(m)
|
|
261
|
-
.update(ciphertext)
|
|
262
|
-
.digest()
|
|
263
|
-
|
|
264
|
-
return { ciphertext, sharedSecret }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ============================================================================
|
|
268
|
-
// KYBER-768 DECAPSULATION
|
|
269
|
-
// ============================================================================
|
|
270
|
-
|
|
271
|
-
export function decapsulate(secretKey: Buffer, ciphertext: Buffer): Buffer {
|
|
272
|
-
if (secretKey.length !== KYBER_SECRET_KEY_SIZE) {
|
|
273
|
-
throw new Error(`Invalid secret key size: ${secretKey.length}`)
|
|
274
|
-
}
|
|
275
|
-
if (ciphertext.length !== KYBER_CIPHERTEXT_SIZE) {
|
|
276
|
-
throw new Error(`Invalid ciphertext size: ${ciphertext.length}`)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Extract s from secret key (first k*384 bytes)
|
|
280
|
-
const s_bytes = secretKey.slice(0, KYBER_K * 384)
|
|
281
|
-
const s = bytesToPolynomials(s_bytes, KYBER_K)
|
|
282
|
-
|
|
283
|
-
// Note: e, d, z, and pk_hash follow s in the secret key but aren't needed for basic decapsulation
|
|
284
|
-
// z would be used for implicit rejection (FO transform variant)
|
|
285
|
-
|
|
286
|
-
// Decompress u and v from ciphertext
|
|
287
|
-
const c1_size = KYBER_DU * KYBER_K * 32
|
|
288
|
-
const u = decompressPolynomials(ciphertext.slice(0, c1_size), KYBER_K, KYBER_DU)
|
|
289
|
-
const v = decompressPolynomial(ciphertext.slice(c1_size), KYBER_DV)
|
|
290
|
-
|
|
291
|
-
// Recover message: m' = v - s^T * u
|
|
292
|
-
let m_prime = new Uint16Array(v)
|
|
293
|
-
for (let i = 0; i < KYBER_K; i++) {
|
|
294
|
-
const prod = polyMultiply(s[i]!, u[i]!)
|
|
295
|
-
m_prime = polySubtract(m_prime, prod)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const m = polyToMessage(m_prime)
|
|
299
|
-
|
|
300
|
-
// Derive shared secret
|
|
301
|
-
const sharedSecret = createHash('sha256')
|
|
302
|
-
.update(m)
|
|
303
|
-
.update(ciphertext)
|
|
304
|
-
.digest()
|
|
305
|
-
|
|
306
|
-
return sharedSecret
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ============================================================================
|
|
310
|
-
// HELPER FUNCTIONS
|
|
311
|
-
// ============================================================================
|
|
312
|
-
|
|
313
|
-
function polynomialsToBytes(polys: Polynomial[]): Uint8Array {
|
|
314
|
-
const bytes = new Uint8Array(polys.length * 384)
|
|
315
|
-
for (let i = 0; i < polys.length; i++) {
|
|
316
|
-
const poly_bytes = polynomialToBytes(polys[i]!)
|
|
317
|
-
bytes.set(poly_bytes, i * 384)
|
|
318
|
-
}
|
|
319
|
-
return bytes
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
export function polynomialToBytes(poly: Polynomial): Uint8Array {
|
|
323
|
-
const bytes = new Uint8Array(384)
|
|
324
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
325
|
-
const idx = floor((i * 12) / 8)
|
|
326
|
-
const shift = ((i * 12) % 8)
|
|
327
|
-
const val = poly[i] & ((1 << 12) - 1) // 12-bit encoding
|
|
328
|
-
bytes[idx] = (bytes[idx] | (val << shift)) & 0xff
|
|
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
|
|
336
|
-
}
|
|
337
|
-
return bytes
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function bytesToPolynomials(bytes: Buffer, count: number): Polynomial[] {
|
|
341
|
-
const polys: Polynomial[] = []
|
|
342
|
-
for (let i = 0; i < count; i++) {
|
|
343
|
-
polys[i] = bytesToPolynomial(bytes.slice(i * 384, (i + 1) * 384))
|
|
344
|
-
}
|
|
345
|
-
return polys
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function bytesToPolynomial(bytes: Buffer): Polynomial {
|
|
349
|
-
const poly = new Uint16Array(KYBER_N)
|
|
350
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
351
|
-
const idx = floor((i * 12) / 8)
|
|
352
|
-
const shift = (i * 12) % 8
|
|
353
|
-
poly[i] = ((bytes[idx] >> shift) | (bytes[idx + 1] << (8 - shift))) & ((1 << 12) - 1)
|
|
354
|
-
}
|
|
355
|
-
return poly
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
export function messageToPoly(msg: Buffer): Polynomial {
|
|
359
|
-
const poly = new Uint16Array(KYBER_N)
|
|
360
|
-
for (let i = 0; i < 32; i++) {
|
|
361
|
-
const byte = msg[i]!
|
|
362
|
-
for (let j = 0; j < 8; j++) {
|
|
363
|
-
if ((byte >> j) & 1) {
|
|
364
|
-
poly[i * 8 + j] = floor(KYBER_Q / 2)
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
return poly
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
export function polyToMessage(poly: Polynomial): Buffer {
|
|
372
|
-
const msg = Buffer.alloc(32)
|
|
373
|
-
for (let i = 0; i < 32; i++) {
|
|
374
|
-
for (let j = 0; j < 8; j++) {
|
|
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) {
|
|
383
|
-
msg[i] = msg[i]! | (1 << j)
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return msg
|
|
388
|
-
}
|
|
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
|
-
|
|
415
|
-
function compressPolynomials(polys: Polynomial[], d: number): Buffer {
|
|
416
|
-
const bytes = Buffer.alloc(polys.length * KYBER_N * d / 8)
|
|
417
|
-
let bit_idx = 0
|
|
418
|
-
for (const poly of polys) {
|
|
419
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
420
|
-
const compressed = compressCoefficient(poly[i]!, d)
|
|
421
|
-
for (let j = 0; j < d; j++) {
|
|
422
|
-
if ((compressed >> j) & 1) {
|
|
423
|
-
bytes[floor(bit_idx / 8)] |= 1 << (bit_idx % 8)
|
|
424
|
-
}
|
|
425
|
-
bit_idx++
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return bytes
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function compressPolynomial(poly: Polynomial, d: number): Buffer {
|
|
433
|
-
const bytes = Buffer.alloc(KYBER_N * d / 8)
|
|
434
|
-
let bit_idx = 0
|
|
435
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
436
|
-
const compressed = compressCoefficient(poly[i]!, d)
|
|
437
|
-
for (let j = 0; j < d; j++) {
|
|
438
|
-
if ((compressed >> j) & 1) {
|
|
439
|
-
bytes[floor(bit_idx / 8)] |= 1 << (bit_idx % 8)
|
|
440
|
-
}
|
|
441
|
-
bit_idx++
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return bytes
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function decompressPolynomials(bytes: Buffer, count: number, d: number): Polynomial[] {
|
|
448
|
-
const polys: Polynomial[] = []
|
|
449
|
-
let bit_idx = 0
|
|
450
|
-
for (let p = 0; p < count; p++) {
|
|
451
|
-
const poly = new Uint16Array(KYBER_N)
|
|
452
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
453
|
-
let compressed = 0
|
|
454
|
-
for (let j = 0; j < d; j++) {
|
|
455
|
-
if ((bytes[floor(bit_idx / 8)] >> (bit_idx % 8)) & 1) {
|
|
456
|
-
compressed |= 1 << j
|
|
457
|
-
}
|
|
458
|
-
bit_idx++
|
|
459
|
-
}
|
|
460
|
-
poly[i] = decompressCoefficient(compressed, d)
|
|
461
|
-
}
|
|
462
|
-
polys[p] = poly
|
|
463
|
-
}
|
|
464
|
-
return polys
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function decompressPolynomial(bytes: Buffer, d: number): Polynomial {
|
|
468
|
-
const poly = new Uint16Array(KYBER_N)
|
|
469
|
-
let bit_idx = 0
|
|
470
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
471
|
-
let compressed = 0
|
|
472
|
-
for (let j = 0; j < d; j++) {
|
|
473
|
-
if ((bytes[floor(bit_idx / 8)] >> (bit_idx % 8)) & 1) {
|
|
474
|
-
compressed |= 1 << j
|
|
475
|
-
}
|
|
476
|
-
bit_idx++
|
|
477
|
-
}
|
|
478
|
-
poly[i] = decompressCoefficient(compressed, d)
|
|
479
|
-
}
|
|
480
|
-
return poly
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function polySubtract(a: Polynomial, b: Polynomial): Polynomial {
|
|
484
|
-
const result = new Uint16Array(KYBER_N)
|
|
485
|
-
for (let i = 0; i < KYBER_N; i++) {
|
|
486
|
-
result[i] = (a[i] - b[i] + KYBER_Q) % KYBER_Q
|
|
487
|
-
}
|
|
488
|
-
return result
|
|
489
|
-
}
|