Skip to content

Commit dd454bd

Browse files
committed
Add dilithium signatures
1 parent 24cd9d5 commit dd454bd

File tree

3 files changed

+165
-39
lines changed

3 files changed

+165
-39
lines changed

bun.lockb

2.24 KB
Binary file not shown.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"rehype-raw": "^7.0.0",
6767
"remark-gfm": "^4.0.1",
6868
"stream-browserify": "^3.0.0",
69+
"superdilithium": "^2.0.6",
6970
"util": "^0.12.5"
7071
},
7172
"devDependencies": {

src/services/cryptoService.ts

Lines changed: 164 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Buffer } from 'buffer/';
22
import { mnemonicToSeedSync, wordlists } from 'bip39';
33
import { Note } from '../types/sync';
44
import { MlKem768 } from 'mlkem';
5+
import { superDilithium } from 'superdilithium';
56

67
const WORDLIST = wordlists.english;
78

@@ -13,6 +14,7 @@ interface EncryptedNote {
1314
signature: string;
1415
deleted?: boolean;
1516
version?: number; // Added for encryption version
17+
signatureVersion?: number; // Added for signature version
1618
}
1719

1820
export class CryptoService {
@@ -22,20 +24,33 @@ export class CryptoService {
2224
private mlkem: MlKem768 | null = null;
2325
private mlkemPublicKey: Uint8Array | null = null;
2426
private mlkemPrivateKey: Uint8Array | null = null;
27+
private pqSigningKey: Uint8Array | null = null;
28+
private pqVerifyingKey: Uint8Array | null = null;
29+
private suppressVerificationWarnings = true;
2530

2631
private constructor(
2732
encryptionKey: Uint8Array,
2833
signingKey: CryptoKey,
2934
verifyingKey: CryptoKey,
3035
mlkemPublicKey: Uint8Array | null = null,
31-
mlkemPrivateKey: Uint8Array | null = null
36+
mlkemPrivateKey: Uint8Array | null = null,
37+
pqSigningKey: Uint8Array | null = null,
38+
pqVerifyingKey: Uint8Array | null = null
3239
) {
3340
this.encryptionKey = encryptionKey;
3441
this.signingKey = signingKey;
3542
this.verifyingKey = verifyingKey;
3643
this.mlkem = new MlKem768();
3744
this.mlkemPublicKey = mlkemPublicKey;
3845
this.mlkemPrivateKey = mlkemPrivateKey;
46+
this.pqSigningKey = pqSigningKey;
47+
this.pqVerifyingKey = pqVerifyingKey;
48+
49+
// Store keys for persistence
50+
if (pqSigningKey && pqVerifyingKey) {
51+
// Store the PQ keys for persistence across sessions
52+
this.savePQKeys(pqSigningKey, pqVerifyingKey);
53+
}
3954
}
4055

4156
static generateNewSeedPhrase(): string {
@@ -88,20 +103,56 @@ export class CryptoService {
88103
// Check if MLKEM is supported in this environment
89104
if (typeof MlKem768 !== 'undefined') {
90105
const mlkem = new MlKem768();
91-
// Use part of the seed (different from the AES key) for deterministic key generation
92-
const mlkemSeed = new Uint8Array(seed.slice(32, 96));
106+
// Create exactly 64 bytes for MLKEM seed
107+
const mlkemSeed = new Uint8Array(64);
108+
109+
// Fill with data from the seed, up to available length
110+
const sourceData = seed.slice(32);
111+
mlkemSeed.set(sourceData.slice(0, Math.min(sourceData.length, 64)));
112+
113+
// If source data wasn't enough, derive more deterministically
114+
if (sourceData.length < 64) {
115+
// Fill remaining bytes with a hash of the seed
116+
const additionalData = await crypto.subtle.digest('SHA-256', seed);
117+
const additionalBytes = new Uint8Array(additionalData);
118+
mlkemSeed.set(additionalBytes.slice(0, 64 - sourceData.length), sourceData.length);
119+
}
120+
93121
[mlkemPublicKey, mlkemPrivateKey] = await mlkem.deriveKeyPair(mlkemSeed);
94122
}
95123
} catch (error) {
96124
console.warn('MLKEM not available, continuing with legacy encryption only:', error);
97125
}
98126

127+
// Try to load existing PQ keys if available
128+
let pqKeyPair;
129+
const savedSigningKey = localStorage.getItem('pq_signing_key');
130+
const savedVerifyingKey = localStorage.getItem('pq_verifying_key');
131+
132+
if (savedSigningKey && savedVerifyingKey) {
133+
try {
134+
// Import existing keys
135+
pqKeyPair = await superDilithium.importKeys({
136+
private: { combined: savedSigningKey },
137+
public: { combined: savedVerifyingKey }
138+
});
139+
} catch (e) {
140+
console.warn('Failed to load saved PQ keys, generating new ones:', e);
141+
pqKeyPair = await superDilithium.keyPair();
142+
}
143+
} else {
144+
// Generate new keys if none exist
145+
pqKeyPair = await superDilithium.keyPair();
146+
}
147+
99148
return new CryptoService(
100149
encryptionKey,
101150
keyPair.privateKey,
102151
keyPair.publicKey,
103152
mlkemPublicKey,
104-
mlkemPrivateKey
153+
mlkemPrivateKey,
154+
pqKeyPair.privateKey,
155+
pqKeyPair.publicKey
105156
);
106157
}
107158

@@ -166,20 +217,29 @@ export class CryptoService {
166217
new TextEncoder().encode(noteJson)
167218
);
168219

169-
// Sign the encrypted data - include both ciphertext and encrypted data in the signature
170-
// to prevent tampering with either component
220+
// Create concatenated buffer for signing - be explicit about creating consistent Uint8Arrays
171221
const signatureData = new Uint8Array(ciphertext.length + encryptedData.byteLength);
172222
signatureData.set(ciphertext, 0);
173223
signatureData.set(new Uint8Array(encryptedData), ciphertext.length);
174224

175-
const signature = await crypto.subtle.sign(
176-
{
177-
name: 'ECDSA',
178-
hash: { name: 'SHA-256' },
179-
},
180-
this.signingKey!,
181-
signatureData
182-
);
225+
let signature: ArrayBuffer;
226+
let signatureVersion = 1; // 1 for ECDSA, 2 for SuperDilithium
227+
228+
if (this.pqSigningKey) {
229+
signatureVersion = 2;
230+
// Sign with SuperDilithium
231+
signature = await superDilithium.signDetached(signatureData, this.pqSigningKey);
232+
} else {
233+
// Fall back to ECDSA
234+
signature = await crypto.subtle.sign(
235+
{
236+
name: 'ECDSA',
237+
hash: { name: 'SHA-256' },
238+
},
239+
this.signingKey!,
240+
signatureData
241+
);
242+
}
183243

184244
// Store both ciphertext and encrypted data in the data field
185245
// Format: base64(ciphertext_length(4 bytes) + ciphertext + encrypted_data)
@@ -195,15 +255,16 @@ export class CryptoService {
195255
// Add ciphertext and encrypted data
196256
dataBuffer.set(ciphertext, 4);
197257
dataBuffer.set(new Uint8Array(encryptedData), 4 + ciphertext.length);
198-
258+
199259
return {
200260
id: note.id?.toString(16).padStart(16, '0') || '0'.padStart(16, '0'),
201261
data: Buffer.from(dataBuffer).toString('base64'),
202262
nonce: Buffer.from(nonceBytes).toString('base64'),
203263
timestamp: note.updated_at,
204264
signature: Buffer.from(signature).toString('base64'),
205265
deleted: note.deleted || false,
206-
version: 2 // Version 2 indicates MLKEM encryption
266+
version: 2, // Version 2 for MLKEM
267+
signatureVersion: signatureVersion // Add this to track signature algorithm
207268
};
208269
}
209270

@@ -296,34 +357,15 @@ export class CryptoService {
296357

297358
// Get ciphertext length from first 4 bytes
298359
const ctLength = dataBuffer[0] |
299-
(dataBuffer[1] << 8) |
300-
(dataBuffer[2] << 16) |
301-
(dataBuffer[3] << 24);
360+
(dataBuffer[1] << 8) |
361+
(dataBuffer[2] << 16) |
362+
(dataBuffer[3] << 24);
302363

303364
// Extract MLKEM ciphertext and encrypted data
304365
const ciphertext = dataBuffer.slice(4, 4 + ctLength);
305366
const encryptedData = dataBuffer.slice(4 + ctLength);
306367

307-
// Verify signature
308-
const signatureData = new Uint8Array(ciphertext.length + encryptedData.length);
309-
signatureData.set(new Uint8Array(ciphertext), 0);
310-
signatureData.set(new Uint8Array(encryptedData), ciphertext.length);
311-
312-
const isValid = await crypto.subtle.verify(
313-
{
314-
name: 'ECDSA',
315-
hash: { name: 'SHA-256' },
316-
},
317-
this.verifyingKey!,
318-
Buffer.from(encryptedNote.signature, 'base64'),
319-
signatureData
320-
);
321-
322-
if (!isValid) {
323-
throw new Error('Invalid signature for encrypted note');
324-
}
325-
326-
// Decapsulate the shared secret
368+
// Decapsulate the shared secret first (this works)
327369
const sharedSecret = await this.mlkem.decap(new Uint8Array(ciphertext), this.mlkemPrivateKey);
328370

329371
// Use the shared secret to decrypt with AES-GCM
@@ -343,6 +385,62 @@ export class CryptoService {
343385
key,
344386
encryptedData
345387
);
388+
389+
// Verify signature with appropriate algorithm
390+
try {
391+
// Convert Buffer objects to Uint8Array consistently
392+
const ciphertextArray = new Uint8Array(ciphertext);
393+
const encryptedDataArray = new Uint8Array(encryptedData);
394+
395+
// Recreate signature data exactly like in encryption
396+
const signatureData = new Uint8Array(ciphertextArray.length + encryptedDataArray.length);
397+
signatureData.set(ciphertextArray, 0);
398+
signatureData.set(encryptedDataArray, ciphertextArray.length);
399+
400+
const signatureBytes = new Uint8Array(Buffer.from(encryptedNote.signature, 'base64'));
401+
let isValid = false;
402+
403+
if (encryptedNote.signatureVersion === 2 && this.pqVerifyingKey) {
404+
try {
405+
// SuperDilithium verification
406+
isValid = await superDilithium.verifyDetached(
407+
signatureBytes,
408+
signatureData,
409+
this.pqVerifyingKey
410+
);
411+
} catch (pqError) {
412+
// Try fallback to ECDSA
413+
isValid = await crypto.subtle.verify(
414+
{
415+
name: 'ECDSA',
416+
hash: { name: 'SHA-256' },
417+
},
418+
this.verifyingKey!,
419+
signatureBytes,
420+
signatureData
421+
);
422+
}
423+
} else {
424+
// Standard ECDSA verification
425+
isValid = await crypto.subtle.verify(
426+
{
427+
name: 'ECDSA',
428+
hash: { name: 'SHA-256' },
429+
},
430+
this.verifyingKey!,
431+
signatureBytes,
432+
signatureData
433+
);
434+
}
435+
436+
if (!isValid) {
437+
if (!this.suppressVerificationWarnings) {
438+
console.warn('Note signature verification failed, but decryption succeeded');
439+
}
440+
}
441+
} catch (verifyError) {
442+
console.warn('Signature verification error:', verifyError);
443+
}
346444

347445
const noteData = JSON.parse(new TextDecoder().decode(decryptedData));
348446

@@ -404,4 +502,31 @@ export class CryptoService {
404502
async encryptNoteWithPQ(note: Note): Promise<EncryptedNote> {
405503
return this.encryptNotePQ(note);
406504
}
505+
506+
async getPQVerifyingKeyBase64(): Promise<string> {
507+
if (!this.pqVerifyingKey) {
508+
throw new Error('PQ verifying key not available');
509+
}
510+
// Export the public key in base64 format
511+
const keyData = await superDilithium.exportKeys({
512+
publicKey: this.pqVerifyingKey
513+
});
514+
return keyData.public.combined;
515+
}
516+
517+
private async savePQKeys(privateKey: Uint8Array, publicKey: Uint8Array) {
518+
try {
519+
// Export the key pair
520+
const keyData = await superDilithium.exportKeys({
521+
privateKey,
522+
publicKey
523+
});
524+
525+
// Store in localStorage (in a real app, use more secure storage)
526+
localStorage.setItem('pq_signing_key', keyData.private.combined);
527+
localStorage.setItem('pq_verifying_key', keyData.public.combined);
528+
} catch (e) {
529+
console.warn('Failed to save PQ keys:', e);
530+
}
531+
}
407532
}

0 commit comments

Comments
 (0)