diff --git a/lib/hk-util.js b/lib/hk-util.js index cb4c9c7d7..5de5568d3 100644 --- a/lib/hk-util.js +++ b/lib/hk-util.js @@ -1032,7 +1032,7 @@ HK.onChannelMessage = function (Env, Server, channel, msgStruct, cb) { })); }).nThen(function (w) { // if there's no validateKey present skip to the next block - if (!(metadata && metadata.validateKey)) { return; } + if (!(metadata && metadata.validateKey && metadata.dsaPublic)) { return; } // trim the checkpoint indicator off the message if it's present let signedMsg = (isCp) ? msgStruct[4].replace(CHECKPOINT_PATTERN, '') : msgStruct[4]; @@ -1045,7 +1045,7 @@ HK.onChannelMessage = function (Env, Server, channel, msgStruct, cb) { */ var proceed = w(); Env.queueValidation(channel.id, function (next) { - Env.validateMessage(signedMsg, metadata.validateKey, function (err) { + Env.validateMessage(signedMsg, metadata.validateKey, metadata.dsaPublic, function (err) { // always go on to the next item in the queue regardless of the outcome next(); @@ -1056,7 +1056,8 @@ HK.onChannelMessage = function (Env, Server, channel, msgStruct, cb) { // we log this case, but not others for some reason Log.info("HK_SIGNED_MESSAGE_REJECTED", { channel: channel.id, - validateKey: metadata.validayKey, + validateKey: metadata.validateKey, + dsaPublic: metadata.dsaPublic, message: signedMsg, }); } diff --git a/lib/workers/db-worker.js b/lib/workers/db-worker.js index e9aa15e20..3712c5c1b 100644 --- a/lib/workers/db-worker.js +++ b/lib/workers/db-worker.js @@ -18,6 +18,7 @@ const Nacl = require('tweetnacl/nacl-fast'); const Eviction = require("../eviction"); const CPCrypto = require('../crypto'); const plugins = require("../plugin-manager"); +const ml_dsa = require('@noble/post-quantum/ml-dsa'); const Env = { Log: {}, @@ -783,8 +784,52 @@ COMMANDS.INLINE = function (data, cb) { } catch (e) { return void cb("E_BADKEY"); } - // validate the message - //const validated = Nacl.sign.open(signedMsg, validateKey); + + // Hybrid signature detection: [classical sig (64 bytes)] + [PQC sig length (4 bytes)] + [PQC sig] + [message] + if (signedMsg.length > 64 + 4) { + try { + // Parse hybrid signature + const classicalSig = signedMsg.slice(0, 64); + const pqSigLen = new DataView(signedMsg.buffer, signedMsg.byteOffset + 64, 4).getUint32(0, false); + const pqSig = signedMsg.slice(64 + 4, 64 + 4 + pqSigLen); + const message = signedMsg.slice(64 + 4 + pqSigLen); + + // Classical verification + const classicalVerified = Nacl.sign.detached.verify(message, classicalSig, validateKey); + + // PQC verification: get PQC public key from metadata if available + let pqcVerified = false; + let pqcPublicKey; + if (data.dsaPublic) { + try { + pqcPublicKey = Util.decodeBase64(data.dsaPublic); + } catch (e) { + return void cb("E_BAD_PQ_KEY"); + } + } else { + // If not provided, fail (could be extended to fetch from metadata) + return void cb("E_NO_PQ_KEY"); + } + try { + pqcVerified = ml_dsa.ml_dsa44.internal.verify( + pqcPublicKey, + message, + pqSig + ); + } catch (e) { + pqcVerified = false; + } + + if (!classicalVerified || !pqcVerified) { + return void cb("FAILED"); + } + return cb(); + } catch (e) { + return void cb("E_HYBRID_SIG_PARSE"); + } + } + + // Fallback: classical only const validated = Env.crypto.open(signedMsg, validateKey); if (!validated) { return void cb("FAILED"); diff --git a/lib/workers/index.js b/lib/workers/index.js index 81fba424f..1acdc665c 100644 --- a/lib/workers/index.js +++ b/lib/workers/index.js @@ -555,12 +555,14 @@ Workers.initialize = function (Env, config, _cb) { }; // Synchronous crypto functions - Env.validateMessage = function (signedMsg, key, cb) { - sendCommand({ - msg: signedMsg, - key: key, + Env.validateMessage = function (opts, cb) { + const cmd = { + msg: opts.msg, + key: opts.key, command: 'INLINE', - }, cb); + }; + if (opts.dsaPublic) cmd.dsaPublic = opts.dsaPublic; + sendCommand(cmd, cb); }; Env.checkSignature = function (signedMsg, signature, publicKey, cb) { diff --git a/src/common/outer/login-block.js b/src/common/outer/login-block.js index e730e27cc..2b0d01a90 100644 --- a/src/common/outer/login-block.js +++ b/src/common/outer/login-block.js @@ -74,7 +74,7 @@ const factory = (Util, ApiConfig = {}, ServerCommand, Nacl, Crypto) => { var sign = keys.sign; var pqSignPair = keys.pqSignPair; - // Basic format with classical keys + // Basic format with classical and pqc keys var result = { edPrivate: Util.encodeBase64(sign.secretKey), edPublic: Util.encodeBase64(sign.publicKey), @@ -101,10 +101,12 @@ const factory = (Util, ApiConfig = {}, ServerCommand, Nacl, Crypto) => { }; // (uint8Array block) => payload object + // src/common/outer/login-block.js Block.decrypt = function (u8_content, keys) { // version is currently ignored since there is only one - var nonce = u8_content.subarray(1, 1 + Crypto.CryptoAgility.secretboxNonceLength()); - var box = u8_content.subarray(1 + Crypto.CryptoAgility.secretboxNonceLength()); + var nonceLength = Crypto.CryptoAgility.secretboxNonceLength(); + var nonce = u8_content.subarray(1, 1 + nonceLength); + var box = u8_content.subarray(1 + nonceLength); var plaintext = Crypto.CryptoAgility.secretboxOpen(box, nonce, keys.symmetric); try { diff --git a/src/worker/async-store.js b/src/worker/async-store.js index bba47af3d..12aa13f68 100644 --- a/src/worker/async-store.js +++ b/src/worker/async-store.js @@ -586,7 +586,9 @@ const factory = (Sortify, UserObject, ProxyManager, color: Store.getUserColor(), notifications: Util.find(proxy, ['mailboxes', 'notifications', 'channel']), curvePublic: proxy.curvePublic, + kemPublic: proxy.kemPublic, edPublic: proxy.edPublic, + dsaPublic: proxy.dsaPublic, netfluxId: store?.network?.webChannels?.[0]?.myID, badge: Util.find(proxy, ['profile', 'badge']) }, @@ -595,6 +597,8 @@ const factory = (Sortify, UserObject, ProxyManager, clientId: clientId, edPublic: proxy.edPublic, edPrivate: proxy.edPrivate, + dsaPublic: proxy.dsaPublic, + dsaPrivate: proxy.dsaPrivate, friends: proxy.friends || {}, settings: proxy.settings || NEW_USER_SETTINGS, thumbnails: disableThumbnails === false, diff --git a/src/worker/components/invitation.js b/src/worker/components/invitation.js index e490ae9dd..515ce5def 100644 --- a/src/worker/components/invitation.js +++ b/src/worker/components/invitation.js @@ -12,7 +12,7 @@ var factory = function (Util, Cred, Nacl, Crypto) { Invite.generateKeys = function () { var ed = Crypto.CryptoAgility.signKeyPair(); var curve = Crypto.CryptoAgility.curveKeyPair(); - var kem = Crypto.PQC.ml_dsa.ml_kem512().keygen(); + var kem = Crypto.PQC.ml_kem.ml_kem512.keygen(); var dsa = Crypto.PQC.ml_dsa.ml_dsa44.keygen(); return { edPublic: encode64(ed.publicKey), diff --git a/src/worker/components/messaging.js b/src/worker/components/messaging.js index 53cd5d470..7a14378e4 100644 --- a/src/worker/components/messaging.js +++ b/src/worker/components/messaging.js @@ -5,15 +5,35 @@ const factory = (Crypto, Hash, Util, Constants, Realtime) => { var Msg = {}; + /* + * CRYPTOGRAPHIC IDENTITY NOTE: + * + * Throughout this module and the entire CryptPad application, we continue to use + * curve25519 public keys (curvePublic) as the primary identifier for users despite + * having post-quantum cryptography (PQC) keys (kemPublic) available. + * + * 1. Legacy compatibility: A complete migration to PQC for identification would require + * changing all existing user relationships and channels + * + * 2. System integration: The curvePublic key is deeply integrated into friend relationships, + * channel management, history keeper, and other core components + * + * 3. Risk management: Partially migrating identity systems can lead to inconsistencies + * across the application, potentially creating security vulnerabilities + * + * A future coordinated migration will be necessary to fully replace curve25519 with PQC, + * which will require careful planning to ensure all components are updated simultaneously. + */ + var createData = Msg.createData = function (proxy, hash) { var data = { channel: hash || Hash.createChannelId(), displayName: proxy['cryptpad.username'], profile: proxy.profile && proxy.profile.view, edPublic: proxy.edPublic, - curvePublic: proxy.curvePublic, + curvePublic: proxy.curvePublic, // Still used as primary ID throughout the system dsaPublic: proxy.dsaPublic, - kemPublic: proxy.kemPublic, + kemPublic: proxy.kemPublic, // PQC key available but not used as primary ID yet notifications: Util.find(proxy, ['mailboxes', 'notifications', 'channel']), avatar: proxy.profile && proxy.profile.avatar, badge: proxy.profile && proxy.profile.badge, @@ -23,6 +43,8 @@ const factory = (Crypto, Hash, Util, Constants, Realtime) => { return data; }; + // Friend lookup still uses curvePublic as the key - changing this would require + // migrating all existing friendship relationships and updating all related code var getFriend = Msg.getFriend = function (proxy, pubkey) { if (!pubkey) { return; } if (pubkey === proxy.curvePublic) { diff --git a/src/worker/components/roster.js b/src/worker/components/roster.js index 198b37f3c..85c3f226e 100644 --- a/src/worker/components/roster.js +++ b/src/worker/components/roster.js @@ -730,10 +730,6 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) { var ciphertext = crypto.encrypt(Sortify(msg)); var id = getMessageId(ciphertext); - - //console.log("Sending with id [%s]", id, msg); - //console.log(); - response.expect(id, function (err, state) { if (err) { return void cb(err); } cb(void 0, state, id); @@ -879,8 +875,19 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) { w.abort(); return void cb("NO_VALIDATE_KEY"); } + if (!config.keys.teamKemPublic && metadata.kemPublic) { + config.keys.teamKemPublic = metadata.kemPublic; + } + if (!config.keys.teamDsaPublic && metadata.dsaPublic) { + config.keys.teamDsaPublic = metadata.dsaPublic; + } + if (!config.keys.teamKemPublic && !config.keys.teamDsaPublic) { + w.abort(); + return void cb("NO_PQC_KEYS"); + } try { + // Crypto.Team.createEncryptor now supports PQC keys crypto = Crypto.Team.createEncryptor(config.keys); } catch (err) { w.abort(); @@ -903,6 +910,7 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) { crypto: crypto, validateKey: config.keys.teamEdPublic, + dsaValidateKey: config.keys.teamDsaPublic, // PQC validation key for ML-DSA owners: config.owners, diff --git a/src/worker/modules/mailbox.js b/src/worker/modules/mailbox.js index 16b388975..817753ba0 100644 --- a/src/worker/modules/mailbox.js +++ b/src/worker/modules/mailbox.js @@ -132,7 +132,10 @@ proxy.mailboxes = { } var text = JSON.stringify(obj); - var ciphertext = crypto.encrypt(text, user.curvePublic); + var ciphertext = crypto.encrypt(text, user.curvePublic, user.kemPublic); + + // const bytes = Crypto.CryptoAgility.decodeBase64(ciphertext); + // if (bytes[0] !== 1) throw new Error("PQC not used for encryption"); // If we've sent this message to one of our teams' mailbox, we may want to "dismiss" it // automatically diff --git a/src/worker/modules/messenger.js b/src/worker/modules/messenger.js index ad55143a7..f1dba7fb1 100644 --- a/src/worker/modules/messenger.js +++ b/src/worker/modules/messenger.js @@ -13,6 +13,26 @@ const factory = (Crypto, Hash, Util, Realtime, Messaging, Messages = data.Messages; }; + /* + * CRYPTOGRAPHIC IDENTITY NOTE: + * + * Throughout this module and the entire CryptPad application, we continue to use + * curve25519 public keys (curvePublic) as the primary identifier for users despite + * having post-quantum cryptography (PQC) keys (kemPublic) available. + * + * 1. Legacy compatibility: A complete migration to PQC for identification would require + * changing all existing user relationships and channels + * + * 2. System integration: The curvePublic key is deeply integrated into friend relationships, + * channel management, history keeper, and other core components + * + * 3. Risk management: Partially migrating identity systems can lead to inconsistencies + * across the application, potentially creating security vulnerabilities + * + * A future coordinated migration will be necessary to fully replace curve25519 with PQC, + * which will require careful planning to ensure all components are updated simultaneously. + */ + var Types = { message: 'MSG', unfriend: 'UNFRIEND', diff --git a/src/worker/modules/team.js b/src/worker/modules/team.js index b1a23487e..61ac70b37 100644 --- a/src/worker/modules/team.js +++ b/src/worker/modules/team.js @@ -448,7 +448,9 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, // Roster keys var myKeys = { curvePublic: ctx.store.proxy.curvePublic, - curvePrivate: ctx.store.proxy.curvePrivate + curvePrivate: ctx.store.proxy.curvePrivate, + kemPublic: ctx.store.proxy.kemPublic, + kemPrivate: ctx.store.proxy.kemPrivate, }; var rosterData = keys.roster || {}; var rosterKeys = rosterData.edit ? Crypto.Team.deriveMemberKeys(rosterData.edit, myKeys) @@ -470,6 +472,11 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, if (k && !rosterKeys.teamEdPublic) { rosterKeys.teamEdPublic = k; } + // Also get DSA public key from cache if available + var dsaPublic = obj && obj.dsaPublic; + if (dsaPublic && !rosterKeys.teamDsaPublic) { + rosterKeys.teamDsaPublic = dsaPublic; + } if (!c) { waitFor.abort(); cb({error: 'NOCACHE'}); @@ -653,14 +660,18 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, var hash = Hash.createRandomHash('team', password); var secret = Hash.getSecrets('team', hash, password); var roHash = Hash.getViewHashFromKeys(secret); - var keyPair = Crypto.CryptoAgility.signKeyPair(); // keyPair.secretKey , keyPair.publicKey + var keyPair = Crypto.CryptoAgility.signKeyPair(); // ed25519 + var curvePair = Crypto.CryptoAgility.curveKeyPair(); // Curve25519 - var curvePair = Crypto.CryptoAgility.curveKeyPair(); // curvePair.secretKey, curvePair.publicKey + var kemPair = Crypto.PQC.ml_kem.ml_kem512.keygen(); + var dsaPair = Crypto.PQC.ml_dsa.ml_dsa44.keygen(); var rosterSeed = Crypto.Team.createSeed(); var rosterKeys = Crypto.Team.deriveMemberKeys(rosterSeed, { curvePublic: ctx.store.proxy.curvePublic, - curvePrivate: ctx.store.proxy.curvePrivate + curvePrivate: ctx.store.proxy.curvePrivate, + kemPublic: ctx.store.proxy.kemPublic, + kemPrivate: ctx.store.proxy.kemPrivate, }); var roster; @@ -680,7 +691,6 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, owners: [ctx.store.proxy.edPublic] }; nThen(function (waitFor) { - // Initialize the roster Roster.create({ network: ctx.store.network || ctx.store.networkPromise, channel: rosterKeys.channel, //sharedConfig.rosterChannel, @@ -693,7 +703,6 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, }, waitFor(function (err, _roster) { if (err) { waitFor.abort(); - console.error(err); return void cb({error: 'ROSTER_ERROR'}); } roster = _roster; @@ -707,7 +716,6 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, })); })); - // Add yourself as owner of the chat channel var crypto = Crypto.createEncryptor(chatSecret.keys); var chatCfg = { network: ctx.store.network, @@ -750,19 +758,22 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, var proxy = lm.proxy; proxy.version = 2; // No migration needed proxy.on('ready', function () { - // Store keys in our drive var keys = { mailbox: { channel: Hash.createChannelId(), viewed: [], keys: { curvePrivate: Util.encodeBase64(curvePair.secretKey), - curvePublic: Util.encodeBase64(curvePair.publicKey) + curvePublic: Util.encodeBase64(curvePair.publicKey), + kemPrivate: Util.encodeBase64(kemPair.secretKey), + kemPublic: Util.encodeBase64(kemPair.publicKey), } }, drive: { edPrivate: Util.encodeBase64(keyPair.secretKey), - edPublic: Util.encodeBase64(keyPair.publicKey) + edPublic: Util.encodeBase64(keyPair.publicKey), + dsaPrivate: Util.encodeBase64(dsaPair.secretKey), + dsaPublic: Util.encodeBase64(dsaPair.publicKey), }, chat: { edit: chatHashes.editHash, @@ -783,7 +794,6 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, roHash: roHash, password: password, keys: keys, - //members: membersHashes.editHash, metadata: { name: data.name } @@ -802,13 +812,11 @@ const factory = (Util, Hash, Constants, Realtime, ProxyManager, cb(); }); }).on('error', function (info) { - if (info && typeof (info.loaded) !== "undefined" && !info.loaded) { + if (info && typeof (info.loaded) !== "undefined" && !info.loaded) { cb({error:'ECONNECT'}); } - if (info && info.error) { - if (info.error === "EDELETED") { - closeTeam(ctx, id); - } + if (info && info.error === "EDELETED") { + closeTeam(ctx, id); } }); }); diff --git a/www/common/inner/invitation.js b/www/common/inner/invitation.js index c92022417..f7850580d 100644 --- a/www/common/inner/invitation.js +++ b/www/common/inner/invitation.js @@ -53,7 +53,7 @@ var factory = function (Util, Nacl, Scrypt, Crypto) { '/components/tweetnacl/nacl-fast.min.js', '/components/scrypt-async/scrypt-async.min.js', '/components/chainpad-crypto/crypto.js' - ], function (Util) { + ], function (Util, nacl, Scrypt, Crypto) { return factory(Util, window.nacl, window.scrypt, Crypto); }); } diff --git a/www/form/command-handler.js b/www/form/command-handler.js index 122742de2..41e23387f 100644 --- a/www/form/command-handler.js +++ b/www/form/command-handler.js @@ -114,6 +114,7 @@ define([ var res = Utils.Crypto.Mailbox.openOwnSecretLetter(messages[0].msg, { validateKey: data.validateKey, ephemeral_private: Util.decodeBase64(answer.curvePrivate), + ephemeral_kem_private: Util.decodeBase64(answer.kemPrivate), my_private: Util.decodeBase64(finalKeys.curvePrivate), their_public: Util.decodeBase64(data.publicKey) }); diff --git a/www/report/main.js b/www/report/main.js index 2a0205f5a..1d01e9aba 100644 --- a/www/report/main.js +++ b/www/report/main.js @@ -326,6 +326,7 @@ define([ channel: d.channel, crypto: crypto, validateKey: rosterKeys.teamEdPublic, + dsaValidateKey: rosterKeys.teamDsaPublic, // Add PQC validation key for ML-DSA Cache: Cache, noChainPad: true, onCacheReady: onCacheReady, diff --git a/www/teams/inner.js b/www/teams/inner.js index a716a118d..8da8b5d42 100644 --- a/www/teams/inner.js +++ b/www/teams/inner.js @@ -355,6 +355,7 @@ define([ toolbar: APP.toolbar, APP: driveAPP, edPublic: APP.teamEdPublic, + dsaPublic: APP.teamDsaPublic, editKey: teamData.secondaryKey }); APP.drive = drive;