diff --git a/.github/dictionary.txt b/.github/dictionary.txt index 02aaa2027f..48465b6925 100644 --- a/.github/dictionary.txt +++ b/.github/dictionary.txt @@ -1,8 +1,11 @@ +Certicom +MLDSA +RSAES +SECG additionals backpressure blpop buildx -Certicom chacha connmanager dialback @@ -21,8 +24,6 @@ reprovides reproviding rlflx rpush -RSAES -SECG setbit stopstr supercop diff --git a/packages/connection-encrypter-plaintext/src/pb/proto.proto b/packages/connection-encrypter-plaintext/src/pb/proto.proto index 0711f9b793..13e077ab02 100644 --- a/packages/connection-encrypter-plaintext/src/pb/proto.proto +++ b/packages/connection-encrypter-plaintext/src/pb/proto.proto @@ -10,6 +10,7 @@ enum KeyType { Ed25519 = 1; secp256k1 = 2; ECDSA = 3; + MLDSA = 4; } message PublicKey { diff --git a/packages/connection-encrypter-plaintext/src/pb/proto.ts b/packages/connection-encrypter-plaintext/src/pb/proto.ts index 52a368f0a8..abe6bb7e91 100644 --- a/packages/connection-encrypter-plaintext/src/pb/proto.ts +++ b/packages/connection-encrypter-plaintext/src/pb/proto.ts @@ -123,14 +123,16 @@ export enum KeyType { RSA = 'RSA', Ed25519 = 'Ed25519', secp256k1 = 'secp256k1', - ECDSA = 'ECDSA' + ECDSA = 'ECDSA', + MLDSA = 'MLDSA' } enum __KeyTypeValues { RSA = 0, Ed25519 = 1, secp256k1 = 2, - ECDSA = 3 + ECDSA = 3, + MLDSA = 4 } export namespace KeyType { diff --git a/packages/connection-encrypter-tls/src/pb/index.proto b/packages/connection-encrypter-tls/src/pb/index.proto index 0068549c3c..f333cc4f65 100644 --- a/packages/connection-encrypter-tls/src/pb/index.proto +++ b/packages/connection-encrypter-tls/src/pb/index.proto @@ -5,6 +5,7 @@ enum KeyType { Ed25519 = 1; secp256k1 = 2; ECDSA = 3; + MLDSA = 4; } message PublicKey { diff --git a/packages/connection-encrypter-tls/src/pb/index.ts b/packages/connection-encrypter-tls/src/pb/index.ts index 41b809ddd7..37e470d9f7 100644 --- a/packages/connection-encrypter-tls/src/pb/index.ts +++ b/packages/connection-encrypter-tls/src/pb/index.ts @@ -6,14 +6,16 @@ export enum KeyType { RSA = 'RSA', Ed25519 = 'Ed25519', secp256k1 = 'secp256k1', - ECDSA = 'ECDSA' + ECDSA = 'ECDSA', + MLDSA = 'MLDSA' } enum __KeyTypeValues { RSA = 0, Ed25519 = 1, secp256k1 = 2, - ECDSA = 3 + ECDSA = 3, + MLDSA = 4 } export namespace KeyType { diff --git a/packages/connection-encrypter-tls/test/utils.spec.ts b/packages/connection-encrypter-tls/test/utils.spec.ts index 3b42f79564..16328e36a2 100644 --- a/packages/connection-encrypter-tls/test/utils.spec.ts +++ b/packages/connection-encrypter-tls/test/utils.spec.ts @@ -1,5 +1,7 @@ import { EventEmitter } from 'node:events' +import { generateKeyPair } from '@libp2p/crypto/keys' import { logger } from '@libp2p/logger' +import { peerIdFromPrivateKey } from '@libp2p/peer-id' import { streamPair } from '@libp2p/utils' import { Crypto } from '@peculiar/webcrypto' import * as x509 from '@peculiar/x509' @@ -7,13 +9,18 @@ import { expect } from 'aegir/chai' import { pEvent } from 'p-event' import { stubInterface } from 'sinon-ts' import { Uint8ArrayList } from 'uint8arraylist' -import { toMessageStream, toNodeDuplex, verifyPeerCertificate } from '../src/utils.js' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { generateCertificate, toMessageStream, toNodeDuplex, verifyPeerCertificate } from '../src/utils.js' import * as testVectors from './fixtures/test-vectors.js' const crypto = new Crypto() x509.cryptoProvider.set(crypto) describe('utils', () => { + before(function () { + this.timeout(60 * 1000) + }) + // unsupported key type it.skip('should verify correct ECDSA certificate', async () => { const peerId = await verifyPeerCertificate(testVectors.validECDSACertificate.cert) @@ -33,6 +40,29 @@ describe('utils', () => { expect(peerId.toString()).to.equal(testVectors.validSecp256k1Certificate.peerId.toString()) }) + it('should verify generated MLDSA certificate', async () => { + const privateKey = await generateKeyPair('MLDSA') + const expectedPeerId = peerIdFromPrivateKey(privateKey) + + const certificate = await generateCertificate(privateKey) + const certBytes = uint8ArrayFromString(certificate.cert.replace('-----BEGIN CERTIFICATE-----\n', '').replace('\n-----END CERTIFICATE-----', '').replace(/\n/g, ''), 'base64pad') + const peerId = await verifyPeerCertificate(certBytes) + + expect(peerId.toString()).to.equal(expectedPeerId.toString()) + }) + + it('should reject generated MLDSA certificate when expected peer id does not match', async () => { + const privateKey = await generateKeyPair('MLDSA') + const wrongPeerKey = await generateKeyPair('MLDSA') + const wrongPeerId = peerIdFromPrivateKey(wrongPeerKey) + + const certificate = await generateCertificate(privateKey) + const certBytes = uint8ArrayFromString(certificate.cert.replace('-----BEGIN CERTIFICATE-----\n', '').replace('\n-----END CERTIFICATE-----', '').replace(/\n/g, ''), 'base64pad') + + await expect(verifyPeerCertificate(certBytes, wrongPeerId, logger('libp2p'))).to.eventually.be.rejected + .with.property('name', 'UnexpectedPeerError') + }) + it('should reject certificate with a the wrong peer id in the extension', async () => { await expect(verifyPeerCertificate(testVectors.wrongPeerIdInExtension.cert, undefined, logger('libp2p'))).to.eventually.be.rejected .with.property('name', 'InvalidCryptoExchangeError') diff --git a/packages/crypto/benchmark/mldsa.cjs b/packages/crypto/benchmark/mldsa.cjs new file mode 100644 index 0000000000..a69439242f --- /dev/null +++ b/packages/crypto/benchmark/mldsa.cjs @@ -0,0 +1,135 @@ +/* eslint-disable no-console */ +const crypto = require('../dist/src/index.js') +const Benchmark = require('benchmark') + +const variants = ['MLDSA44', 'MLDSA65', 'MLDSA87'] + +function parseBackends () { + const raw = process.env.MLDSA_BENCH_BACKENDS ?? 'noble,node-subtle' + const backends = raw.split(',').map(s => s.trim()).filter(Boolean) + + return backends.length > 0 ? backends : ['noble', 'node-subtle'] +} + +async function runBackendBenchmarks (backend) { + const mldsa = await import('../dist/src/keys/mldsa/index.js') + const { setMLDSABackend, getMLDSABackend } = mldsa + + setMLDSABackend(backend) + + const keys = new Map() + const verifyFixtures = new Map() + const suite = new Benchmark.Suite(`mldsa (${backend})`) + + for (const variant of variants) { + const key = await crypto.keys.generateKeyPair('MLDSA', variant) + keys.set(variant, key) + + const data = crypto.randomBytes(256) + const sig = await key.sign(data) + verifyFixtures.set(variant, { + data, + sig + }) + } + + variants.forEach((variant) => { + suite.add(`generateKeyPair ${variant}`, async (d) => { + await crypto.keys.generateKeyPair('MLDSA', variant) + d.resolve() + }, { + defer: true + }) + }) + + variants.forEach((variant) => { + suite.add(`sign-only ${variant}`, async (d) => { + const key = keys.get(variant) + + if (key == null) { + throw new Error(`missing benchmark key for ${variant}`) + } + + const data = crypto.randomBytes(256) + const sig = await key.sign(data) + + if (!(sig instanceof Uint8Array) || sig.byteLength === 0) { + throw new Error(`failed to sign with ${variant}`) + } + + d.resolve() + }, { + defer: true + }) + }) + + variants.forEach((variant) => { + suite.add(`verify-only ${variant}`, async (d) => { + const key = keys.get(variant) + const fixture = verifyFixtures.get(variant) + + if (key == null || fixture == null) { + throw new Error(`missing benchmark fixtures for ${variant}`) + } + + const ok = await key.publicKey.verify(fixture.data, fixture.sig) + + if (!ok) { + throw new Error(`failed to verify ${variant} signature`) + } + + d.resolve() + }, { + defer: true + }) + }) + + variants.forEach((variant) => { + suite.add(`sign/verify ${variant}`, async (d) => { + const key = keys.get(variant) + + if (key == null) { + throw new Error(`missing benchmark key for ${variant}`) + } + + const data = crypto.randomBytes(256) + const sig = await key.sign(data) + const ok = await key.publicKey.verify(data, sig) + + if (!ok) { + throw new Error(`failed to verify ${variant} signature`) + } + + d.resolve() + }, { + defer: true + }) + }) + + console.log(`\n=== MLDSA backend: ${backend} (effective: ${getMLDSABackend()}) ===`) + + await new Promise((resolve) => { + suite + .on('cycle', (event) => console.log(String(event.target))) + .on('error', (event) => { + console.error('benchmark error:', event.target?.name, event.target?.error) + }) + .on('complete', function () { + resolve() + }) + .run({ async: true }) + }) +} + +async function run () { + const backends = parseBackends() + + for (const backend of backends) { + await runBackendBenchmarks(backend) + } +} + +run().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 5e3352ffba..cfc0c7be99 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -82,12 +82,16 @@ "test:webkit": "aegir test -t browser -- --browser webkit", "test:node": "aegir test -t node --cov", "test:electron-main": "aegir test -t electron-main", - "generate": "protons ./src/keys/keys.proto" + "generate": "protons ./src/keys/keys.proto", + "bench:mldsa": "node ./benchmark/mldsa.cjs", + "bench:mldsa:noble": "MLDSA_BENCH_BACKENDS=noble node ./benchmark/mldsa.cjs", + "bench:mldsa:subtle": "MLDSA_BENCH_BACKENDS=node-subtle node ./benchmark/mldsa.cjs" }, "dependencies": { "@libp2p/interface": "^3.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@noble/post-quantum": "^0.6.0", "multiformats": "^13.4.0", "protons-runtime": "^6.0.1", "uint8arraylist": "^2.4.8", @@ -105,6 +109,7 @@ "./dist/src/hmac/index.js": "./dist/src/hmac/index.browser.js", "./dist/src/keys/ecdh/index.js": "./dist/src/keys/ecdh/index.browser.js", "./dist/src/keys/ed25519/index.js": "./dist/src/keys/ed25519/index.browser.js", + "./dist/src/keys/mldsa/index.js": "./dist/src/keys/mldsa/index.browser.js", "./dist/src/keys/rsa/index.js": "./dist/src/keys/rsa/index.browser.js", "./dist/src/keys/secp256k1/index.js": "./dist/src/keys/secp256k1/index.browser.js", "./dist/src/webcrypto/webcrypto.js": "./dist/src/webcrypto/webcrypto.browser.js" diff --git a/packages/crypto/src/keys/index.ts b/packages/crypto/src/keys/index.ts index af54de387b..4df1e647ae 100644 --- a/packages/crypto/src/keys/index.ts +++ b/packages/crypto/src/keys/index.ts @@ -3,7 +3,7 @@ * * ## Supported Key Types * - * Currently the `'RSA'`, `'ed25519'`, and `secp256k1` types are supported, although ed25519 and secp256k1 keys support only signing and verification of messages. + * Currently the `'RSA'`, `'ed25519'`, `secp256k1` and `MLDSA` types are supported, although ed25519, secp256k1 and MLDSA keys support only signing and verification of messages. * * For encryption / decryption support, RSA keys should be used. */ @@ -15,13 +15,15 @@ import { generateECDSAKeyPair, pkiMessageToECDSAPrivateKey, pkiMessageToECDSAPub import { privateKeyLength as ed25519PrivateKeyLength, publicKeyLength as ed25519PublicKeyLength } from './ed25519/index.js' import { generateEd25519KeyPair, generateEd25519KeyPairFromSeed, unmarshalEd25519PrivateKey, unmarshalEd25519PublicKey } from './ed25519/utils.js' import * as pb from './keys.js' +import { defaultMLDSAVariant, isMLDSAVariant } from './mldsa/index.js' +import { generateMLDSAKeyPair, marshalMLDSAPrivateKey, marshalMLDSAPublicKey, unmarshalMLDSAPrivateKey, unmarshalMLDSAPublicKey } from './mldsa/utils.js' import { decodeDer } from './rsa/der.js' import { RSAES_PKCS1_V1_5_OID } from './rsa/index.js' import { pkcs1ToRSAPrivateKey, pkixToRSAPublicKey, generateRSAKeyPair, pkcs1MessageToRSAPrivateKey, pkixMessageToRSAPublicKey, jwkToRSAPrivateKey } from './rsa/utils.js' import { privateKeyLength as secp256k1PrivateKeyLength, publicKeyLength as secp256k1PublicKeyLength } from './secp256k1/index.js' import { generateSecp256k1KeyPair, unmarshalSecp256k1PrivateKey, unmarshalSecp256k1PublicKey } from './secp256k1/utils.js' import type { Curve } from './ecdsa/index.js' -import type { PrivateKey, PublicKey, KeyType, RSAPrivateKey, Secp256k1PrivateKey, Ed25519PrivateKey, Secp256k1PublicKey, Ed25519PublicKey, ECDSAPrivateKey, ECDSAPublicKey } from '@libp2p/interface' +import type { PrivateKey, PublicKey, KeyType, RSAPrivateKey, Secp256k1PrivateKey, Ed25519PrivateKey, Secp256k1PublicKey, Ed25519PublicKey, ECDSAPrivateKey, ECDSAPublicKey, MLDSAPrivateKey, MLDSAVariant } from '@libp2p/interface' import type { MultihashDigest } from 'multiformats' import type { Digest } from 'multiformats/hashes/digest' @@ -30,6 +32,10 @@ export type { Curve } from './ecdh/index.js' export type { ECDHKey, EnhancedKey, EnhancedKeyPair, ECDHKeyPair } from './interface.js' export { keyStretcher } from './key-stretcher.js' +export interface MLDSAKeyGenerationOptions { + variant?: MLDSAVariant +} + /** * Generates a keypair of the given type and bitsize */ @@ -37,8 +43,9 @@ export async function generateKeyPair (type: 'Ed25519'): Promise export async function generateKeyPair (type: 'ECDSA', curve?: Curve): Promise export async function generateKeyPair (type: 'RSA', bits?: number): Promise +export async function generateKeyPair (type: 'MLDSA', variant?: MLDSAVariant | MLDSAKeyGenerationOptions): Promise export async function generateKeyPair (type: KeyType, bits?: number): Promise -export async function generateKeyPair (type: KeyType, bits?: number | string): Promise { +export async function generateKeyPair (type: KeyType, bits?: number | string | MLDSAKeyGenerationOptions): Promise { if (type === 'Ed25519') { return generateEd25519KeyPair() } @@ -55,6 +62,10 @@ export async function generateKeyPair (type: KeyType, bits?: number | string): P return generateECDSAKeyPair(toCurve(bits)) } + if (type === 'MLDSA') { + return generateMLDSAKeyPair(toMLDSAVariant(bits)) + } + throw new UnsupportedKeyTypeError() } @@ -96,6 +107,8 @@ export function publicKeyFromProtobuf (buf: Uint8Array, digest?: Digest<18, numb return unmarshalSecp256k1PublicKey(data) case pb.KeyType.ECDSA: return unmarshalECDSAPublicKey(data) + case pb.KeyType.MLDSA: + return unmarshalMLDSAPublicKey(data) default: throw new UnsupportedKeyTypeError() } @@ -105,6 +118,10 @@ export function publicKeyFromProtobuf (buf: Uint8Array, digest?: Digest<18, numb * Creates a public key from the raw key bytes */ export function publicKeyFromRaw (buf: Uint8Array): PublicKey { + try { + return unmarshalMLDSAPublicKey(buf) + } catch {} + if (buf.byteLength === ed25519PublicKeyLength) { return unmarshalEd25519PublicKey(buf) } else if (buf.byteLength === secp256k1PublicKeyLength) { @@ -152,6 +169,13 @@ export function publicKeyFromMultihash (digest: MultihashDigest<0x0>): Ed25519Pu * Converts a public key object into a protobuf serialized public key */ export function publicKeyToProtobuf (key: PublicKey): Uint8Array { + if (key.type === 'MLDSA') { + return pb.PublicKey.encode({ + Type: pb.KeyType[key.type], + Data: marshalMLDSAPublicKey(key.variant, key.raw) + }) + } + return pb.PublicKey.encode({ Type: pb.KeyType[key.type], Data: key.raw @@ -161,7 +185,7 @@ export function publicKeyToProtobuf (key: PublicKey): Uint8Array { /** * Converts a protobuf serialized private key into its representative object */ -export function privateKeyFromProtobuf (buf: Uint8Array): Ed25519PrivateKey | Secp256k1PrivateKey | RSAPrivateKey | ECDSAPrivateKey { +export function privateKeyFromProtobuf (buf: Uint8Array): Ed25519PrivateKey | Secp256k1PrivateKey | RSAPrivateKey | ECDSAPrivateKey | MLDSAPrivateKey { const decoded = pb.PrivateKey.decode(buf) const data = decoded.Data ?? new Uint8Array() @@ -174,6 +198,8 @@ export function privateKeyFromProtobuf (buf: Uint8Array): Ed25519PrivateKey | Se return unmarshalSecp256k1PrivateKey(data) case pb.KeyType.ECDSA: return unmarshalECDSAPrivateKey(data) + case pb.KeyType.MLDSA: + return unmarshalMLDSAPrivateKey(data) default: throw new UnsupportedKeyTypeError() } @@ -185,6 +211,10 @@ export function privateKeyFromProtobuf (buf: Uint8Array): Ed25519PrivateKey | Se * differentiate between Ed25519 and secp256k1 keys as they are the same length. */ export function privateKeyFromRaw (buf: Uint8Array): PrivateKey { + try { + return unmarshalMLDSAPrivateKey(buf) + } catch {} + if (buf.byteLength === ed25519PrivateKeyLength) { return unmarshalEd25519PrivateKey(buf) } else if (buf.byteLength === secp256k1PrivateKeyLength) { @@ -209,6 +239,13 @@ export function privateKeyFromRaw (buf: Uint8Array): PrivateKey { * Converts a private key object into a protobuf serialized private key */ export function privateKeyToProtobuf (key: PrivateKey): Uint8Array { + if (key.type === 'MLDSA') { + return pb.PrivateKey.encode({ + Type: pb.KeyType[key.type], + Data: marshalMLDSAPrivateKey(key.variant, key.raw) + }) + } + return pb.PrivateKey.encode({ Type: pb.KeyType[key.type], Data: key.raw @@ -239,6 +276,20 @@ function toCurve (curve: any): Curve { throw new InvalidParametersError('Unsupported curve, should be P-256, P-384 or P-521') } +function toMLDSAVariant (variantOrOptions: any): MLDSAVariant { + const variant = variantOrOptions?.variant ?? variantOrOptions + + if (variant == null) { + return defaultMLDSAVariant + } + + if (isMLDSAVariant(variant)) { + return variant + } + + throw new InvalidParametersError('Unsupported ML-DSA variant, should be MLDSA44, MLDSA65 or MLDSA87') +} + /** * Convert a libp2p RSA or ECDSA private key to a WebCrypto CryptoKeyPair */ diff --git a/packages/crypto/src/keys/keys.proto b/packages/crypto/src/keys/keys.proto index d3ff6cf269..bcce37900a 100644 --- a/packages/crypto/src/keys/keys.proto +++ b/packages/crypto/src/keys/keys.proto @@ -5,6 +5,7 @@ enum KeyType { Ed25519 = 1; secp256k1 = 2; ECDSA = 3; + MLDSA = 4; } message PublicKey { // the proto2 version of this field is "required" which means it will have diff --git a/packages/crypto/src/keys/keys.ts b/packages/crypto/src/keys/keys.ts index 7f8b5a5182..af0f27697a 100644 --- a/packages/crypto/src/keys/keys.ts +++ b/packages/crypto/src/keys/keys.ts @@ -6,14 +6,16 @@ export enum KeyType { RSA = 'RSA', Ed25519 = 'Ed25519', secp256k1 = 'secp256k1', - ECDSA = 'ECDSA' + ECDSA = 'ECDSA', + MLDSA = 'MLDSA' } enum __KeyTypeValues { RSA = 0, Ed25519 = 1, secp256k1 = 2, - ECDSA = 3 + ECDSA = 3, + MLDSA = 4 } export namespace KeyType { diff --git a/packages/crypto/src/keys/mldsa/index.browser.ts b/packages/crypto/src/keys/mldsa/index.browser.ts new file mode 100644 index 0000000000..ad81e5a510 --- /dev/null +++ b/packages/crypto/src/keys/mldsa/index.browser.ts @@ -0,0 +1,197 @@ +import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js' // eslint-disable-line camelcase +import { randomBytes as pqRandomBytes } from '@noble/post-quantum/utils.js' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { SigningError, VerificationError } from '../../errors.js' +import webcrypto from '../../webcrypto/index.js' +import type { Uint8ArrayKeyPair } from '../interface.js' +import type { MLDSAVariant, AbortOptions } from '@libp2p/interface' +import type { Uint8ArrayList } from 'uint8arraylist' + +type WebCryptoAlgorithm = 'ML-DSA-44' | 'ML-DSA-65' | 'ML-DSA-87' +export type MLDSABackend = 'auto' | 'noble' | 'node-subtle' + +const variants = { + MLDSA44: ml_dsa44, // eslint-disable-line camelcase + MLDSA65: ml_dsa65, // eslint-disable-line camelcase + MLDSA87: ml_dsa87 // eslint-disable-line camelcase +} as const + +let backendPreference: MLDSABackend = 'auto' +const webCryptoSupport = new Map>() + +export const mldsaVariants = Object.keys(variants) as MLDSAVariant[] +export const defaultMLDSAVariant: MLDSAVariant = 'MLDSA65' + +export function setMLDSABackend (backend: MLDSABackend): void { + if (backend !== 'auto' && backend !== 'noble' && backend !== 'node-subtle') { + throw new Error(`Unsupported ML-DSA backend "${backend}"`) + } + + backendPreference = backend +} + +export function getMLDSABackend (): MLDSABackend { + return backendPreference +} + +export function isMLDSAVariant (variant: string): variant is MLDSAVariant { + return variant === 'MLDSA44' || variant === 'MLDSA65' || variant === 'MLDSA87' +} + +export function getMLDSA (variant: MLDSAVariant): (typeof variants)[MLDSAVariant] { + return variants[variant] +} + +export function getMLDSAPublicKeyLength (variant: MLDSAVariant): number { + return getMLDSA(variant).lengths.publicKey ?? 0 +} + +export function getMLDSAPrivateKeyLength (variant: MLDSAVariant): number { + return getMLDSASeedLength(variant) +} + +export function getMLDSASeedLength (variant: MLDSAVariant): number { + return getMLDSA(variant).lengths.seed ?? 0 +} + +export function getMLDSASignatureLength (variant: MLDSAVariant): number { + return getMLDSA(variant).lengths.signature ?? 0 +} + +export function generateKey (variant: MLDSAVariant): Uint8ArrayKeyPair { + const seed = pqRandomBytes(getMLDSASeedLength(variant)) + const { publicKey } = getMLDSA(variant).keygen(seed) + + return { + privateKey: seed, + publicKey + } +} + +export function getPublicKeyFromPrivateKey (variant: MLDSAVariant, key: Uint8Array): Uint8Array { + return getMLDSA(variant).keygen(key).publicKey +} + +export function hashAndSign (variant: MLDSAVariant, key: Uint8Array, msg: Uint8Array | Uint8ArrayList, options?: AbortOptions, publicKey?: Uint8Array): Uint8Array | Promise { + options?.signal?.throwIfAborted() + + const data = msg instanceof Uint8Array ? msg : msg.subarray() + + if (shouldUseWebCryptoMLDSA()) { + return hashAndSignWebCrypto(variant, key, publicKey, data) + .catch(() => { + const normalizedKey = getMLDSA(variant).keygen(key).secretKey + return getMLDSA(variant).sign(data, normalizedKey) + }) + } + + try { + const normalizedKey = getMLDSA(variant).keygen(key).secretKey + + return getMLDSA(variant).sign(data, normalizedKey) + } catch (err) { + throw new SigningError(String(err)) + } +} + +export function hashAndVerify (variant: MLDSAVariant, key: Uint8Array, sig: Uint8Array, msg: Uint8Array | Uint8ArrayList, options?: AbortOptions): boolean | Promise { + options?.signal?.throwIfAborted() + + const data = msg instanceof Uint8Array ? msg : msg.subarray() + + if (shouldUseWebCryptoMLDSA()) { + return hashAndVerifyWebCrypto(variant, key, sig, data) + .catch(() => getMLDSA(variant).verify(sig, data, key)) + } + + try { + return getMLDSA(variant).verify(sig, data, key) + } catch (err) { + throw new VerificationError(String(err)) + } +} + +function shouldUseWebCryptoMLDSA (): boolean { + return backendPreference !== 'noble' +} + +async function hashAndSignWebCrypto (variant: MLDSAVariant, seed: Uint8Array, publicKey: Uint8Array | undefined, msg: Uint8Array): Promise { + const supported = await isWebCryptoMLDSASupported(variant) + + if (!supported) { + throw new SigningError('webcrypto ML-DSA variant not supported') + } + + const pk = publicKey ?? getMLDSA(variant).keygen(seed).publicKey + const subtle = webcrypto.get().subtle + const key = await subtle.importKey('jwk', { + kty: 'AKP', + alg: toWebCryptoAlgorithm(variant), + priv: uint8ArrayToString(seed, 'base64url'), + pub: uint8ArrayToString(pk, 'base64url'), + ext: false, + key_ops: ['sign'] + } as any, { + name: toWebCryptoAlgorithm(variant) + }, false, ['sign']) + + const sig = await subtle.sign({ name: toWebCryptoAlgorithm(variant) }, key, msg) + return new Uint8Array(sig, 0, sig.byteLength) +} + +async function hashAndVerifyWebCrypto (variant: MLDSAVariant, publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array): Promise { + const supported = await isWebCryptoMLDSASupported(variant) + + if (!supported) { + throw new VerificationError('webcrypto ML-DSA variant not supported') + } + + const subtle = webcrypto.get().subtle + const key = await subtle.importKey('jwk', { + kty: 'AKP', + alg: toWebCryptoAlgorithm(variant), + pub: uint8ArrayToString(publicKey, 'base64url'), + ext: false, + key_ops: ['verify'] + } as any, { + name: toWebCryptoAlgorithm(variant) + }, false, ['verify']) + + return subtle.verify({ name: toWebCryptoAlgorithm(variant) }, key, sig, msg) +} + +async function isWebCryptoMLDSASupported (variant: MLDSAVariant): Promise { + let supportPromise = webCryptoSupport.get(variant) + + if (supportPromise == null) { + supportPromise = (async () => { + try { + const subtle = webcrypto.get().subtle + const keyPair = await subtle.generateKey({ + name: toWebCryptoAlgorithm(variant) + }, false, ['sign', 'verify']) as CryptoKeyPair + + return keyPair.privateKey != null && keyPair.publicKey != null + } catch { + return false + } + })() + + webCryptoSupport.set(variant, supportPromise) + } + + return supportPromise +} + +function toWebCryptoAlgorithm (variant: MLDSAVariant): WebCryptoAlgorithm { + switch (variant) { + case 'MLDSA44': + return 'ML-DSA-44' + case 'MLDSA65': + return 'ML-DSA-65' + case 'MLDSA87': + return 'ML-DSA-87' + default: + throw new Error('Unsupported ML-DSA variant') + } +} diff --git a/packages/crypto/src/keys/mldsa/index.ts b/packages/crypto/src/keys/mldsa/index.ts new file mode 100644 index 0000000000..d20112fd39 --- /dev/null +++ b/packages/crypto/src/keys/mldsa/index.ts @@ -0,0 +1,219 @@ +import crypto from 'crypto' +import { InvalidParametersError } from '@libp2p/interface' +import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js' // eslint-disable-line camelcase +import { randomBytes as pqRandomBytes } from '@noble/post-quantum/utils.js' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { SigningError, VerificationError } from '../../errors.js' +import type { Uint8ArrayKeyPair } from '../interface.js' +import type { MLDSAVariant, AbortOptions } from '@libp2p/interface' +import type { Uint8ArrayList } from 'uint8arraylist' + +type NodeAlgorithm = 'ML-DSA-44' | 'ML-DSA-65' | 'ML-DSA-87' +export type MLDSABackend = 'auto' | 'noble' | 'node-subtle' + +const variants = { + MLDSA44: ml_dsa44, // eslint-disable-line camelcase + MLDSA65: ml_dsa65, // eslint-disable-line camelcase + MLDSA87: ml_dsa87 // eslint-disable-line camelcase +} as const + +let backendPreference: MLDSABackend = 'auto' +const subtleSupport = new Map>() + +export const mldsaVariants = Object.keys(variants) as MLDSAVariant[] +export const defaultMLDSAVariant: MLDSAVariant = 'MLDSA65' + +export function setMLDSABackend (backend: MLDSABackend): void { + if (backend !== 'auto' && backend !== 'noble' && backend !== 'node-subtle') { + throw new InvalidParametersError(`Unsupported ML-DSA backend "${backend}"`) + } + + backendPreference = backend +} + +export function getMLDSABackend (): MLDSABackend { + return backendPreference +} + +export function isMLDSAVariant (variant: string): variant is MLDSAVariant { + return variant === 'MLDSA44' || variant === 'MLDSA65' || variant === 'MLDSA87' +} + +export function getMLDSA (variant: MLDSAVariant): (typeof variants)[MLDSAVariant] { + return variants[variant] +} + +export function getMLDSAPublicKeyLength (variant: MLDSAVariant): number { + return getMLDSA(variant).lengths.publicKey ?? 0 +} + +export function getMLDSAPrivateKeyLength (variant: MLDSAVariant): number { + return getMLDSASeedLength(variant) +} + +export function getMLDSASeedLength (variant: MLDSAVariant): number { + return getMLDSA(variant).lengths.seed ?? 0 +} + +export function getMLDSASignatureLength (variant: MLDSAVariant): number { + return getMLDSA(variant).lengths.signature ?? 0 +} + +export function generateKey (variant: MLDSAVariant): Uint8ArrayKeyPair { + const seed = pqRandomBytes(getMLDSASeedLength(variant)) + const { publicKey } = getMLDSA(variant).keygen(seed) + + return { + privateKey: seed, + publicKey + } +} + +export function getPublicKeyFromPrivateKey (variant: MLDSAVariant, key: Uint8Array): Uint8Array { + return getMLDSA(variant).keygen(key).publicKey +} + +export function hashAndSign (variant: MLDSAVariant, key: Uint8Array, msg: Uint8Array | Uint8ArrayList, options?: AbortOptions, publicKey?: Uint8Array): Uint8Array | Promise { + options?.signal?.throwIfAborted() + + const data = msg instanceof Uint8Array ? msg : msg.subarray() + + if (shouldUseNodeSubtle() && publicKey != null) { + return hashAndSignNodeSubtle(variant, key, publicKey, data) + .catch(() => { + const normalizedKey = getMLDSA(variant).keygen(key).secretKey + return getMLDSA(variant).sign(data, normalizedKey) + }) + } + + try { + const normalizedKey = getMLDSA(variant).keygen(key).secretKey + return getMLDSA(variant).sign(data, normalizedKey) + } catch (err) { + throw new SigningError(String(err)) + } +} + +export function hashAndVerify (variant: MLDSAVariant, key: Uint8Array, sig: Uint8Array, msg: Uint8Array | Uint8ArrayList, options?: AbortOptions): boolean | Promise { + options?.signal?.throwIfAborted() + + const data = msg instanceof Uint8Array ? msg : msg.subarray() + + if (shouldUseNodeSubtle()) { + return hashAndVerifyNodeSubtle(variant, key, sig, data) + .catch(() => getMLDSA(variant).verify(sig, data, key)) + } + + try { + return getMLDSA(variant).verify(sig, data, key) + } catch (err) { + throw new VerificationError(String(err)) + } +} + +function shouldUseNodeSubtle (): boolean { + if (backendPreference === 'noble') { + return false + } + + if (backendPreference === 'node-subtle') { + return true + } + + return crypto.webcrypto?.subtle != null +} + +async function hashAndSignNodeSubtle (variant: MLDSAVariant, seed: Uint8Array, publicKey: Uint8Array, msg: Uint8Array): Promise { + const subtle = crypto.webcrypto?.subtle + + if (subtle == null) { + throw new SigningError('node-subtle backend unavailable') + } + + const supported = await isNodeSubtleMLDSASupported(variant) + + if (!supported) { + throw new SigningError('node-subtle ML-DSA variant not supported') + } + + const key = await subtle.importKey('jwk', { + kty: 'AKP', + alg: toNodeAlgorithm(variant), + priv: uint8ArrayToString(seed, 'base64url'), + pub: uint8ArrayToString(publicKey, 'base64url'), + ext: false, + key_ops: ['sign'] + } as any, { + name: toNodeAlgorithm(variant) + }, false, ['sign']) + + const sig = await subtle.sign({ name: toNodeAlgorithm(variant) }, key, msg) + return new Uint8Array(sig, 0, sig.byteLength) +} + +async function hashAndVerifyNodeSubtle (variant: MLDSAVariant, publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array): Promise { + const subtle = crypto.webcrypto?.subtle + + if (subtle == null) { + throw new VerificationError('node-subtle backend unavailable') + } + + const supported = await isNodeSubtleMLDSASupported(variant) + + if (!supported) { + throw new VerificationError('node-subtle ML-DSA variant not supported') + } + + const key = await subtle.importKey('jwk', { + kty: 'AKP', + alg: toNodeAlgorithm(variant), + pub: uint8ArrayToString(publicKey, 'base64url'), + ext: false, + key_ops: ['verify'] + } as any, { + name: toNodeAlgorithm(variant) + }, false, ['verify']) + + return subtle.verify({ name: toNodeAlgorithm(variant) }, key, sig, msg) +} + +async function isNodeSubtleMLDSASupported (variant: MLDSAVariant): Promise { + let supportPromise = subtleSupport.get(variant) + + if (supportPromise == null) { + supportPromise = (async () => { + try { + const subtle = crypto.webcrypto?.subtle + + if (subtle == null) { + return false + } + + const keyPair = await subtle.generateKey({ + name: toNodeAlgorithm(variant) + }, false, ['sign', 'verify']) as CryptoKeyPair + + return keyPair.privateKey != null && keyPair.publicKey != null + } catch { + return false + } + })() + + subtleSupport.set(variant, supportPromise) + } + + return supportPromise +} + +function toNodeAlgorithm (variant: MLDSAVariant): NodeAlgorithm { + switch (variant) { + case 'MLDSA44': + return 'ML-DSA-44' + case 'MLDSA65': + return 'ML-DSA-65' + case 'MLDSA87': + return 'ML-DSA-87' + default: + throw new Error('Unsupported ML-DSA variant') + } +} diff --git a/packages/crypto/src/keys/mldsa/mldsa.ts b/packages/crypto/src/keys/mldsa/mldsa.ts new file mode 100644 index 0000000000..1b02ce0ad0 --- /dev/null +++ b/packages/crypto/src/keys/mldsa/mldsa.ts @@ -0,0 +1,74 @@ +import { base58btc } from 'multiformats/bases/base58' +import { CID } from 'multiformats/cid' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { hashAndSign, hashAndVerify } from './index.js' +import type { MLDSAPublicKey as MLDSAPublicKeyInterface, MLDSAPrivateKey as MLDSAPrivateKeyInterface, MLDSAVariant, AbortOptions } from '@libp2p/interface' +import type { Digest } from 'multiformats/hashes/digest' +import type { Uint8ArrayList } from 'uint8arraylist' + +export class MLDSAPublicKey implements MLDSAPublicKeyInterface { + public readonly type = 'MLDSA' + public readonly variant: MLDSAVariant + public readonly raw: Uint8Array + private readonly digest: Digest<0x12, number> + private string?: string + + constructor (variant: MLDSAVariant, key: Uint8Array, digest: Digest<0x12, number>) { + this.variant = variant + this.raw = key + this.digest = digest + } + + toMultihash (): Digest<0x12, number> { + return this.digest + } + + toCID (): CID { + return CID.createV1(114, this.toMultihash()) + } + + toString (): string { + if (this.string == null) { + this.string = base58btc.encode(this.toMultihash().bytes).substring(1) + } + + return this.string + } + + equals (key?: any): boolean { + if (key == null || !(key.raw instanceof Uint8Array) || key.variant !== this.variant) { + return false + } + + return uint8ArrayEquals(this.raw, key.raw) + } + + verify (data: Uint8Array | Uint8ArrayList, sig: Uint8Array, options?: AbortOptions): boolean | Promise { + return hashAndVerify(this.variant, this.raw, sig, data, options) + } +} + +export class MLDSAPrivateKey implements MLDSAPrivateKeyInterface { + public readonly type = 'MLDSA' + public readonly variant: MLDSAVariant + public readonly raw: Uint8Array + public readonly publicKey: MLDSAPublicKey + + constructor (variant: MLDSAVariant, key: Uint8Array, publicKey: MLDSAPublicKey) { + this.variant = variant + this.raw = key + this.publicKey = publicKey + } + + equals (key?: any): boolean { + if (key == null || !(key.raw instanceof Uint8Array) || key.variant !== this.variant) { + return false + } + + return uint8ArrayEquals(this.raw, key.raw) + } + + sign (message: Uint8Array | Uint8ArrayList, options?: AbortOptions): Uint8Array | Promise { + return hashAndSign(this.variant, this.raw, message, options, this.publicKey.raw) + } +} diff --git a/packages/crypto/src/keys/mldsa/utils.ts b/packages/crypto/src/keys/mldsa/utils.ts new file mode 100644 index 0000000000..2b5f8d8020 --- /dev/null +++ b/packages/crypto/src/keys/mldsa/utils.ts @@ -0,0 +1,146 @@ +import { InvalidParametersError, UnsupportedKeyTypeError } from '@libp2p/interface' +import { sha256 } from '@noble/hashes/sha2.js' +import { create as createDigest } from 'multiformats/hashes/digest' +import * as pb from '../keys.js' +import { MLDSAPrivateKey as MLDSAPrivateKeyClass, MLDSAPublicKey as MLDSAPublicKeyClass } from './mldsa.js' +import * as crypto from './index.js' +import type { MLDSAPrivateKey, MLDSAPublicKey, MLDSAVariant } from '@libp2p/interface' +import type { Digest } from 'multiformats/hashes/digest' + +const SHA2_256_CODE = 0x12 +const MLDSA44_PREFIX = 1 +const MLDSA65_PREFIX = 2 +const MLDSA87_PREFIX = 3 + +export function variantToPrefix (variant: MLDSAVariant): number { + switch (variant) { + case 'MLDSA44': return MLDSA44_PREFIX + case 'MLDSA65': return MLDSA65_PREFIX + case 'MLDSA87': return MLDSA87_PREFIX + default: + throw new UnsupportedKeyTypeError('Unsupported ML-DSA variant') + } +} + +export function prefixToVariant (prefix: number): MLDSAVariant { + switch (prefix) { + case MLDSA44_PREFIX: return 'MLDSA44' + case MLDSA65_PREFIX: return 'MLDSA65' + case MLDSA87_PREFIX: return 'MLDSA87' + default: + throw new InvalidParametersError('Unknown ML-DSA variant prefix') + } +} + +export function marshalMLDSAPublicKey (variant: MLDSAVariant, publicKey: Uint8Array): Uint8Array { + const expectedLength = crypto.getMLDSAPublicKeyLength(variant) + + if (publicKey.byteLength !== expectedLength) { + throw new InvalidParametersError(`ML-DSA public key must be ${expectedLength} bytes, got ${publicKey.byteLength}`) + } + + const out = new Uint8Array(1 + publicKey.byteLength) + out[0] = variantToPrefix(variant) + out.set(publicKey, 1) + return out +} + +export function marshalMLDSAPrivateKey (variant: MLDSAVariant, privateKey: Uint8Array): Uint8Array { + const expectedLength = crypto.getMLDSAPrivateKeyLength(variant) + const seedLength = crypto.getMLDSASeedLength(variant) + + if (privateKey.byteLength !== expectedLength && privateKey.byteLength !== seedLength) { + throw new InvalidParametersError(`ML-DSA private key must be ${expectedLength} or ${seedLength} bytes, got ${privateKey.byteLength}`) + } + + const out = new Uint8Array(1 + privateKey.byteLength) + out[0] = variantToPrefix(variant) + out.set(privateKey, 1) + return out +} + +export function unmarshalMLDSAPublicKeyData (bytes: Uint8Array): { variant: MLDSAVariant, key: Uint8Array } { + if (!(bytes instanceof Uint8Array) || bytes.byteLength < 2) { + throw new InvalidParametersError('Invalid ML-DSA public key bytes') + } + + try { + const variant = prefixToVariant(bytes[0]) + const key = bytes.subarray(1) + const expectedLength = crypto.getMLDSAPublicKeyLength(variant) + + if (key.byteLength !== expectedLength) { + throw new InvalidParametersError(`ML-DSA public key must be ${expectedLength} bytes, got ${key.byteLength}`) + } + + return { variant, key } + } catch {} + + for (const variant of crypto.mldsaVariants) { + if (bytes.byteLength === crypto.getMLDSAPublicKeyLength(variant)) { + return { + variant, + key: bytes + } + } + } + + throw new InvalidParametersError('Invalid ML-DSA public key bytes') +} + +export function unmarshalMLDSAPrivateKeyData (bytes: Uint8Array): { variant: MLDSAVariant, key: Uint8Array } { + if (!(bytes instanceof Uint8Array) || bytes.byteLength < 2) { + throw new InvalidParametersError('Invalid ML-DSA private key bytes') + } + + try { + const variant = prefixToVariant(bytes[0]) + const key = bytes.subarray(1) + const expectedLength = crypto.getMLDSAPrivateKeyLength(variant) + const seedLength = crypto.getMLDSASeedLength(variant) + + if (key.byteLength !== expectedLength && key.byteLength !== seedLength) { + throw new InvalidParametersError(`ML-DSA private key must be ${expectedLength} or ${seedLength} bytes, got ${key.byteLength}`) + } + + return { variant, key } + } catch {} + + for (const variant of crypto.mldsaVariants) { + if (bytes.byteLength === crypto.getMLDSAPrivateKeyLength(variant)) { + return { + variant, + key: bytes + } + } + } + + throw new InvalidParametersError('Invalid ML-DSA private key bytes') +} + +export function createMLDSAPublicKeyDigest (variant: MLDSAVariant, publicKey: Uint8Array): Digest<0x12, number> { + const hash = sha256(pb.PublicKey.encode({ + Type: pb.KeyType.MLDSA, + Data: marshalMLDSAPublicKey(variant, publicKey) + })) + + return createDigest(SHA2_256_CODE, hash) +} + +export function unmarshalMLDSAPublicKey (bytes: Uint8Array): MLDSAPublicKey { + const { variant, key } = unmarshalMLDSAPublicKeyData(bytes) + return new MLDSAPublicKeyClass(variant, key, createMLDSAPublicKeyDigest(variant, key)) +} + +export function unmarshalMLDSAPrivateKey (bytes: Uint8Array): MLDSAPrivateKey { + const { variant, key } = unmarshalMLDSAPrivateKeyData(bytes) + const publicKey = crypto.getPublicKeyFromPrivateKey(variant, key) + const digest = createMLDSAPublicKeyDigest(variant, publicKey) + return new MLDSAPrivateKeyClass(variant, key, new MLDSAPublicKeyClass(variant, publicKey, digest)) +} + +export async function generateMLDSAKeyPair (variant: MLDSAVariant = crypto.defaultMLDSAVariant): Promise { + const keyPair = crypto.generateKey(variant) + const digest = createMLDSAPublicKeyDigest(variant, keyPair.publicKey) + return new MLDSAPrivateKeyClass(variant, keyPair.privateKey, new MLDSAPublicKeyClass(variant, keyPair.publicKey, digest)) +} diff --git a/packages/crypto/test/keys/mldsa.spec.ts b/packages/crypto/test/keys/mldsa.spec.ts new file mode 100644 index 0000000000..a37e94aa4c --- /dev/null +++ b/packages/crypto/test/keys/mldsa.spec.ts @@ -0,0 +1,168 @@ +/* eslint-env mocha */ +import { isPrivateKey, isPublicKey } from '@libp2p/interface' +import { expect } from 'aegir/chai' +import { randomBytes } from '../../src/index.js' +import { privateKeyFromProtobuf, privateKeyFromRaw, privateKeyToProtobuf, publicKeyFromProtobuf, publicKeyFromRaw, publicKeyToProtobuf, generateKeyPair } from '../../src/keys/index.js' +import { KeyType } from '../../src/keys/keys.js' +import { getMLDSABackend, getMLDSASignatureLength, setMLDSABackend } from '../../src/keys/mldsa/index.js' +import { unmarshalMLDSAPrivateKey, unmarshalMLDSAPublicKey } from '../../src/keys/mldsa/utils.js' +import webcrypto from '../../src/webcrypto/index.js' +import type { MLDSAPrivateKey } from '@libp2p/interface' + +describe('mldsa keys', function () { + this.timeout(60 * 1000) + + let key: MLDSAPrivateKey + + before(async () => { + key = await generateKeyPair('MLDSA') + }) + + afterEach(() => { + setMLDSABackend('auto') + }) + + it('generates a valid key', async () => { + expect(key).to.have.property('type', 'MLDSA') + expect(key).to.have.property('variant', 'MLDSA65') + expect(key.equals(key)).to.be.true() + }) + + it('generates variant-specific keys', async () => { + const key44 = await generateKeyPair('MLDSA', 'MLDSA44') + const key87 = await generateKeyPair('MLDSA', 'MLDSA87') + + expect(key44.variant).to.equal('MLDSA44') + expect(key87.variant).to.equal('MLDSA87') + }) + + it('generates variant-specific keys from options object', async () => { + const key44 = await generateKeyPair('MLDSA', { variant: 'MLDSA44' }) + const key87 = await generateKeyPair('MLDSA', { variant: 'MLDSA87' }) + + expect(key44.variant).to.equal('MLDSA44') + expect(key87.variant).to.equal('MLDSA87') + }) + + it('signs and verifies', async () => { + const data = randomBytes(256) + const sig = await key.sign(data) + + expect(sig).to.have.length(getMLDSASignatureLength(key.variant)) + expect(await key.publicKey.verify(data, sig)).to.be.true() + }) + + it('fails to verify for different data', async () => { + const data = randomBytes(256) + const sig = await key.sign(data) + + expect(await key.publicKey.verify(randomBytes(256), sig)).to.be.false() + }) + + it('encoding', () => { + const keyMarshal = key.raw + const key2 = unmarshalMLDSAPrivateKey(keyMarshal) + const keyMarshal2 = key2.raw + + expect(keyMarshal).to.equalBytes(keyMarshal2) + + const pkMarshal = key.publicKey.raw + const pk2 = unmarshalMLDSAPublicKey(pkMarshal) + const pkMarshal2 = pk2.raw + + expect(pkMarshal).to.equalBytes(pkMarshal2) + }) + + it('marshals and unmarshals protobuf private key', () => { + const encoded = privateKeyToProtobuf(key) + const decoded = privateKeyFromProtobuf(encoded) + + expect(decoded.type).to.equal('MLDSA') + expect(decoded.equals(key)).to.be.true() + expect(decoded.publicKey.equals(key.publicKey)).to.be.true() + }) + + it('marshals and unmarshals protobuf public key', () => { + const encoded = publicKeyToProtobuf(key.publicKey) + const decoded = publicKeyFromProtobuf(encoded) + + expect(decoded.type).to.equal('MLDSA') + expect(decoded.equals(key.publicKey)).to.be.true() + }) + + it('imports private key from raw', async () => { + const key = await generateKeyPair('MLDSA', 'MLDSA44') + const imported = privateKeyFromRaw(key.raw) + + expect(key.equals(imported)).to.be.true() + }) + + it('imports public key from raw', async () => { + const key = await generateKeyPair('MLDSA', 'MLDSA44') + const imported = publicKeyFromRaw(key.publicKey.raw) + + expect(key.publicKey.equals(imported)).to.be.true() + }) + + it('is PrivateKey', async () => { + const key = await generateKeyPair('MLDSA') + + expect(isPrivateKey(key)).to.be.true() + expect(isPublicKey(key)).to.be.false() + }) + + it('is PublicKey', async () => { + const key = await generateKeyPair('MLDSA') + + expect(isPrivateKey(key.publicKey)).to.be.false() + expect(isPublicKey(key.publicKey)).to.be.true() + }) + + it('uses protobuf key type enum', async () => { + const key = await generateKeyPair('MLDSA') + + expect(publicKeyFromProtobuf(publicKeyToProtobuf(key.publicKey)).type).to.equal('MLDSA') + expect(privateKeyFromProtobuf(privateKeyToProtobuf(key)).type).to.equal('MLDSA') + expect(KeyType.MLDSA).to.equal('MLDSA') + }) + + it('supports backend selection', async () => { + setMLDSABackend('noble') + expect(getMLDSABackend()).to.equal('noble') + + const data = randomBytes(128) + const sig = await key.sign(data) + expect(await key.publicKey.verify(data, sig)).to.equal(true) + + setMLDSABackend('auto') + expect(getMLDSABackend()).to.equal('auto') + + const sig2 = await key.sign(data) + expect(await key.publicKey.verify(data, sig2)).to.equal(true) + + setMLDSABackend('node-subtle') + expect(getMLDSABackend()).to.equal('node-subtle') + + const sig3 = await key.sign(data) + expect(await key.publicKey.verify(data, sig3)).to.equal(true) + }) + + it('reports webcrypto ML-DSA capability', async function () { + const subtle = webcrypto.get().subtle + const capabilities: Record = {} + + for (const name of ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']) { + try { + const keyPair = await subtle.generateKey({ name }, false, ['sign', 'verify']) as CryptoKeyPair + capabilities[name] = keyPair.privateKey != null && keyPair.publicKey != null + } catch { + capabilities[name] = false + } + } + + // eslint-disable-next-line no-console + console.log('ML-DSA subtle capability:', capabilities) + + expect(capabilities).to.have.property('ML-DSA-65') + }) +}) diff --git a/packages/interface-compliance-tests/src/peer-discovery/index.ts b/packages/interface-compliance-tests/src/peer-discovery/index.ts index 908891f1d4..0d907ea34a 100644 --- a/packages/interface-compliance-tests/src/peer-discovery/index.ts +++ b/packages/interface-compliance-tests/src/peer-discovery/index.ts @@ -46,7 +46,7 @@ export default (common: TestSetup): void => { expect(id).to.exist() expect(id) .to.have.property('type') - .that.is.oneOf(['RSA', 'Ed25519', 'secp256k1']) + .that.is.oneOf(['RSA', 'Ed25519', 'secp256k1', 'MLDSA', 'hashed']) expect(multiaddrs).to.exist() multiaddrs.forEach((m) => expect(isMultiaddr(m)).to.eql(true)) diff --git a/packages/interface/src/keys.ts b/packages/interface/src/keys.ts index 1967529f5d..96e11868fc 100644 --- a/packages/interface/src/keys.ts +++ b/packages/interface/src/keys.ts @@ -3,7 +3,9 @@ import type { CID } from 'multiformats/cid' import type { MultihashDigest } from 'multiformats/hashes/interface' import type { Uint8ArrayList } from 'uint8arraylist' -export type KeyType = 'RSA' | 'Ed25519' | 'secp256k1' | 'ECDSA' +export type KeyType = 'RSA' | 'Ed25519' | 'secp256k1' | 'ECDSA' | 'MLDSA' + +export type MLDSAVariant = 'MLDSA44' | 'MLDSA65' | 'MLDSA87' export interface RSAPublicKey { /** @@ -184,7 +186,54 @@ export interface ECDSAPublicKey { toString(): string } -export type PublicKey = RSAPublicKey | Ed25519PublicKey | Secp256k1PublicKey | ECDSAPublicKey +export interface MLDSAPublicKey { + /** + * The type of this key + */ + readonly type: 'MLDSA' + + /** + * The ML-DSA parameter set used for this key + */ + readonly variant: MLDSAVariant + + /** + * The raw public key bytes + */ + readonly raw: Uint8Array + + /** + * Returns `true` if the passed object matches this key + */ + equals(key?: any): boolean + + /** + * Returns this public key as a SHA2-256 hash containing the protobuf wrapped + * public key + */ + toMultihash(): MultihashDigest<0x12> + + /** + * Return this public key as a CID encoded with the `libp2p-key` codec + * + * The digest contains a SHA2-256 hash of the protobuf wrapped version of the + * public key. + */ + toCID(): CID + + /** + * Verify the passed data was signed by the private key corresponding to this + * public key + */ + verify(data: Uint8Array | Uint8ArrayList, sig: Uint8Array, options?: AbortOptions): boolean | Promise + + /** + * Returns this key as a multihash with base58btc encoding + */ + toString(): string +} + +export type PublicKey = RSAPublicKey | Ed25519PublicKey | Secp256k1PublicKey | ECDSAPublicKey | MLDSAPublicKey /** * Returns true if the passed argument has type overlap with the `PublicKey` @@ -195,7 +244,7 @@ export function isPublicKey (key?: any): key is PublicKey { return false } - return (key.type === 'RSA' || key.type === 'Ed25519' || key.type === 'secp256k1' || key.type === 'ECDSA') && + return (key.type === 'RSA' || key.type === 'Ed25519' || key.type === 'secp256k1' || key.type === 'ECDSA' || key.type === 'MLDSA') && key.raw instanceof Uint8Array && typeof key.equals === 'function' && typeof key.toMultihash === 'function' && @@ -328,7 +377,40 @@ export interface ECDSAPrivateKey { sign(data: Uint8Array | Uint8ArrayList, options?: AbortOptions): Uint8Array | Promise } -export type PrivateKey = RSAPrivateKey | Ed25519PrivateKey | Secp256k1PrivateKey | ECDSAPrivateKey +export interface MLDSAPrivateKey { + /** + * The type of this key + */ + readonly type: 'MLDSA' + + /** + * The ML-DSA parameter set used for this key + */ + readonly variant: MLDSAVariant + + /** + * The public key that corresponds to this private key + */ + readonly publicKey: MLDSAPublicKey + + /** + * The raw private key bytes + */ + readonly raw: Uint8Array + + /** + * Returns `true` if the passed object matches this key + */ + equals(key?: any): boolean + + /** + * Sign the passed data with this private key and return the signature for + * later verification + */ + sign(data: Uint8Array | Uint8ArrayList, options?: AbortOptions): Uint8Array | Promise +} + +export type PrivateKey = RSAPrivateKey | Ed25519PrivateKey | Secp256k1PrivateKey | ECDSAPrivateKey | MLDSAPrivateKey /** * Returns true if the passed argument has type overlap with the `PrivateKey` @@ -339,7 +421,7 @@ export function isPrivateKey (key?: any): key is PrivateKey { return false } - return (key.type === 'RSA' || key.type === 'Ed25519' || key.type === 'secp256k1' || key.type === 'ECDSA') && + return (key.type === 'RSA' || key.type === 'Ed25519' || key.type === 'secp256k1' || key.type === 'ECDSA' || key.type === 'MLDSA') && isPublicKey(key.publicKey) && key.raw instanceof Uint8Array && typeof key.equals === 'function' && diff --git a/packages/interface/src/peer-id.ts b/packages/interface/src/peer-id.ts index 2d2f533c1b..4dd5c8bef3 100644 --- a/packages/interface/src/peer-id.ts +++ b/packages/interface/src/peer-id.ts @@ -1,4 +1,4 @@ -import type { Ed25519PublicKey, KeyType, RSAPublicKey, Secp256k1PublicKey } from './keys.js' +import type { Ed25519PublicKey, KeyType, MLDSAPublicKey, RSAPublicKey, Secp256k1PublicKey } from './keys.js' import type { CID } from 'multiformats/cid' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -143,11 +143,73 @@ export interface URLPeerId { equals(other?: any): boolean } +export interface MLDSAPeerId { + readonly type: 'MLDSA' + + /** + * This may be undefined if the public key has not been discovered yet + */ + readonly publicKey?: MLDSAPublicKey + + /** + * Returns the multihash from `toMultihash()` as a base58btc encoded string + */ + toString(): string + + /** + * Returns a multihash, the digest of which is the SHA2-256 hash of the + * protobuf-encoded public key + */ + toMultihash(): MultihashDigest<0x12> + + /** + * Returns a CID with the libp2p key code and the same multihash as + * `toMultihash()` + */ + toCID(): CID + + /** + * Returns true if the passed argument is equivalent to this PeerId + */ + equals(other?: any): boolean +} + +export interface HashedPeerId { + readonly type: 'hashed' + + /** + * This may be undefined if the public key has not been discovered yet + */ + readonly publicKey?: RSAPublicKey | MLDSAPublicKey + + /** + * Returns the multihash from `toMultihash()` as a base58btc encoded string + */ + toString(): string + + /** + * Returns a multihash, the digest of which is the SHA2-256 hash of the + * protobuf-encoded public key + */ + toMultihash(): MultihashDigest<0x12> + + /** + * Returns a CID with the libp2p key code and the same multihash as + * `toMultihash()` + */ + toCID(): CID + + /** + * Returns true if the passed argument is equivalent to this PeerId + */ + equals(other?: any): boolean +} + /** * This is a union of all known PeerId types - use the `.type` field to * disambiguate them */ -export type PeerId = RSAPeerId | Ed25519PeerId | Secp256k1PeerId | URLPeerId +export type PeerId = RSAPeerId | Ed25519PeerId | Secp256k1PeerId | URLPeerId | MLDSAPeerId | HashedPeerId /** * All PeerId implementations must use this symbol as the name of a property diff --git a/packages/keychain/src/utils/export.ts b/packages/keychain/src/utils/export.ts index ad390808bc..7851858049 100644 --- a/packages/keychain/src/utils/export.ts +++ b/packages/keychain/src/utils/export.ts @@ -9,7 +9,7 @@ import * as asn1js from 'asn1js' import { base64 } from 'multiformats/bases/base64' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { ITERATIONS, KEY_SIZE, SALT_LENGTH } from './constants.js' -import type { ECDSAPrivateKey, Ed25519PrivateKey, PrivateKey, RSAPrivateKey, Secp256k1PrivateKey } from '@libp2p/interface' +import type { ECDSAPrivateKey, Ed25519PrivateKey, MLDSAPrivateKey, PrivateKey, RSAPrivateKey, Secp256k1PrivateKey } from '@libp2p/interface' import type { Multibase } from 'multiformats/bases/interface' /** @@ -47,6 +47,10 @@ export async function exportPrivateKey (key: PrivateKey, password: string, forma return exportECDSAPrivateKey(key, password, format) } + if (key.type === 'MLDSA') { + return exportMLDSAPrivateKey(key, password, format) + } + throw new UnsupportedKeyTypeError() } @@ -83,6 +87,17 @@ export async function exportECDSAPrivateKey (key: ECDSAPrivateKey, password: str } } +/** + * Exports the key into a password protected `format` + */ +export async function exportMLDSAPrivateKey (key: MLDSAPrivateKey, password: string, format: ExportFormat = 'libp2p-key'): Promise> { + if (format === 'libp2p-key') { + return exporter(privateKeyToProtobuf(key), password) + } else { + throw new InvalidParametersError(`export format '${format}' is not supported`) + } +} + /** * Exports the key as libp2p-key - a aes-gcm encrypted value with the key * derived from the password. diff --git a/packages/keychain/test/keychain.spec.ts b/packages/keychain/test/keychain.spec.ts index 0f20808f60..79586d9b40 100644 --- a/packages/keychain/test/keychain.spec.ts +++ b/packages/keychain/test/keychain.spec.ts @@ -224,6 +224,37 @@ describe('keychain', () => { }) }) + describe('MLDSA keys', () => { + const keyName = 'my custom MLDSA key' + + it('can be an MLDSA key', async () => { + const key = await generateKeyPair('MLDSA') + const keyInfo = await ks.importKey(keyName, key) + + expect(keyInfo).to.exist() + expect(keyInfo).to.have.property('name', keyName) + expect(keyInfo).to.have.property('id') + }) + + it('does not overwrite existing key', async () => { + const key = await generateKeyPair('MLDSA') + + await expect(ks.importKey(keyName, key)).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + + it('can export/import a key', async () => { + const keyName = 'a new MLDSA key' + const key = await generateKeyPair('MLDSA') + const keyInfo = await ks.importKey(keyName, key) + const exportedKey = await ks.exportKey(keyName) + // remove it so we can re-import it + await ks.removeKey(keyName) + const importedKey = await ks.importKey(keyName, exportedKey) + expect(importedKey.id).to.eql(keyInfo.id) + }) + }) + describe('query', () => { before(async () => { const key = await generateKeyPair('RSA') diff --git a/packages/keychain/test/utils/import-export.spec.ts b/packages/keychain/test/utils/import-export.spec.ts index a1a5d3a4e2..03464f84b5 100644 --- a/packages/keychain/test/utils/import-export.spec.ts +++ b/packages/keychain/test/utils/import-export.spec.ts @@ -160,4 +160,33 @@ vQ2NBF1B1/I4w5/LCbEDxrliX5fTe9osfkFZolLMsD6B9c2J1DvAJKaiMhc= expect.fail('should have thrown') }) }) + + describe('MLDSA', () => { + it('should export a password encrypted libp2p-key', async () => { + const key = await generateKeyPair('MLDSA') + const encryptedKey = await exportPrivateKey(key, 'my secret') + + // Import the key + const importedKey = await importPrivateKey(encryptedKey, 'my secret') + + expect(key.equals(importedKey)).to.equal(true) + }) + + it('should export a libp2p-key with no password to encrypt', async () => { + const key = await generateKeyPair('MLDSA') + const encryptedKey = await exportPrivateKey(key, '') + + // Import the key + const importedKey = await importPrivateKey(encryptedKey, '') + + expect(key.equals(importedKey)).to.equal(true) + }) + + it('should fail to import libp2p-key with wrong password', async () => { + const key = await generateKeyPair('MLDSA') + const encryptedKey = await exportPrivateKey(key, 'my secret', 'libp2p-key') + + await expect(importPrivateKey(encryptedKey, 'not my secret')).to.eventually.be.rejected() + }) + }) }) diff --git a/packages/libp2p/src/connection-manager/index.ts b/packages/libp2p/src/connection-manager/index.ts index e1c32da355..495f844a1e 100644 --- a/packages/libp2p/src/connection-manager/index.ts +++ b/packages/libp2p/src/connection-manager/index.ts @@ -469,8 +469,8 @@ export class DefaultConnectionManager implements ConnectionManager, Startable { this.connections.set(peerId, storedConns) - // only need to store RSA public keys, all other types are embedded in the peer id - if (peerId.publicKey != null && peerId.type === 'RSA') { + // only hashed peer ids need public keys stored, identity multihashes embed the key + if (peerId.publicKey != null && peerId.toMultihash().code === 0x12) { await this.peerStore.patch(peerId, { publicKey: peerId.publicKey }) diff --git a/packages/libp2p/test/core/get-public-key.spec.ts b/packages/libp2p/test/core/get-public-key.spec.ts index 3d6d90f24a..0270ee2547 100644 --- a/packages/libp2p/test/core/get-public-key.spec.ts +++ b/packages/libp2p/test/core/get-public-key.spec.ts @@ -2,6 +2,7 @@ import { generateKeyPair, publicKeyToProtobuf } from '@libp2p/crypto/keys' import { contentRoutingSymbol } from '@libp2p/interface' import { peerIdFromMultihash, peerIdFromPrivateKey } from '@libp2p/peer-id' import { expect } from 'aegir/chai' +import { MemoryDatastore } from 'datastore-core/memory' import { stubInterface } from 'sinon-ts' import { createLibp2p } from '../../src/index.js' import type { ContentRouting, ContentRoutingProvider, Libp2p } from '@libp2p/interface' @@ -72,4 +73,53 @@ describe('getPublicKey', () => { expect(otherPeer.publicKey?.equals(key)).to.be.true() expect(router.get.called).to.be.true('routing was not queried') }) + + it('should load an MLDSA public key from persisted peer store after restart', async () => { + await node.stop() + + const datastore = new MemoryDatastore() + const privateKey = await generateKeyPair('Ed25519') + const routerA = stubInterface() + routerA[contentRoutingSymbol] = routerA + + const nodeA = await createLibp2p({ + datastore, + privateKey, + services: { + router: () => routerA + } + }) + + const otherPeer = peerIdFromPrivateKey(await generateKeyPair('MLDSA')) + + if (otherPeer.publicKey == null) { + throw new Error('Public key was missing') + } + + await nodeA.peerStore.patch(otherPeer, { + publicKey: otherPeer.publicKey + }) + await nodeA.stop() + + const routerB = stubInterface() + routerB[contentRoutingSymbol] = routerB + + const nodeB = await createLibp2p({ + datastore, + privateKey, + services: { + router: () => routerB + } + }) + + const otherPeerWithoutPublicKey = peerIdFromMultihash(otherPeer.toMultihash()) + expect(otherPeerWithoutPublicKey).to.have.property('publicKey', undefined) + + const key = await nodeB.getPublicKey(otherPeerWithoutPublicKey) + + expect(otherPeer.publicKey.equals(key)).to.be.true() + expect(routerB.get.called).to.be.false('routing was queried instead of peer store') + + await nodeB.stop() + }) }) diff --git a/packages/peer-id/src/index.ts b/packages/peer-id/src/index.ts index ad8c06b717..74df065be3 100644 --- a/packages/peer-id/src/index.ts +++ b/packages/peer-id/src/index.ts @@ -22,8 +22,8 @@ import * as Digest from 'multiformats/hashes/digest' import { identity } from 'multiformats/hashes/identity' import { sha256 } from 'multiformats/hashes/sha2' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' -import { RSAPeerId as RSAPeerIdClass, Ed25519PeerId as Ed25519PeerIdClass, Secp256k1PeerId as Secp256k1PeerIdClass, URLPeerId as URLPeerIdClass } from './peer-id.js' -import type { Ed25519PeerId, RSAPeerId, URLPeerId, Secp256k1PeerId, PeerId, PublicKey, Ed25519PublicKey, Secp256k1PublicKey, RSAPublicKey, Ed25519PrivateKey, Secp256k1PrivateKey, RSAPrivateKey, PrivateKey } from '@libp2p/interface' +import { RSAPeerId as RSAPeerIdClass, Ed25519PeerId as Ed25519PeerIdClass, Secp256k1PeerId as Secp256k1PeerIdClass, URLPeerId as URLPeerIdClass, MLDSAPeerId as MLDSAPeerIdClass, HashedPeerId as HashedPeerIdClass } from './peer-id.js' +import type { Ed25519PeerId, RSAPeerId, URLPeerId, Secp256k1PeerId, MLDSAPeerId, HashedPeerId, PeerId, PublicKey, Ed25519PublicKey, Secp256k1PublicKey, RSAPublicKey, MLDSAPublicKey, Ed25519PrivateKey, Secp256k1PrivateKey, RSAPrivateKey, MLDSAPrivateKey, PrivateKey } from '@libp2p/interface' import type { MultibaseDecoder } from 'multiformats/cid' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -31,7 +31,7 @@ import type { MultihashDigest } from 'multiformats/hashes/interface' const LIBP2P_KEY_CODE = 0x72 const TRANSPORT_IPFS_GATEWAY_HTTP_CODE = 0x0920 -export function peerIdFromString (str: string, decoder?: MultibaseDecoder): Ed25519PeerId | Secp256k1PeerId | RSAPeerId | URLPeerId { +export function peerIdFromString (str: string, decoder?: MultibaseDecoder): Ed25519PeerId | Secp256k1PeerId | RSAPeerId | URLPeerId | MLDSAPeerId | HashedPeerId { let multihash: MultihashDigest if (str.charAt(0) === '1' || str.charAt(0) === 'Q') { @@ -55,6 +55,7 @@ export function peerIdFromString (str: string, decoder?: MultibaseDecoder): export function peerIdFromPublicKey (publicKey: Ed25519PublicKey): Ed25519PeerId export function peerIdFromPublicKey (publicKey: Secp256k1PublicKey): Secp256k1PeerId export function peerIdFromPublicKey (publicKey: RSAPublicKey): RSAPeerId +export function peerIdFromPublicKey (publicKey: MLDSAPublicKey): MLDSAPeerId export function peerIdFromPublicKey (publicKey: PublicKey): PeerId export function peerIdFromPublicKey (publicKey: PublicKey): PeerId { if (publicKey.type === 'Ed25519') { @@ -72,6 +73,11 @@ export function peerIdFromPublicKey (publicKey: PublicKey): PeerId { multihash: publicKey.toCID().multihash, publicKey }) + } else if (publicKey.type === 'MLDSA') { + return new MLDSAPeerIdClass({ + multihash: publicKey.toCID().multihash, + publicKey + }) } throw new UnsupportedKeyTypeError() @@ -80,6 +86,7 @@ export function peerIdFromPublicKey (publicKey: PublicKey): PeerId { export function peerIdFromPrivateKey (privateKey: Ed25519PrivateKey): Ed25519PeerId export function peerIdFromPrivateKey (privateKey: Secp256k1PrivateKey): Secp256k1PeerId export function peerIdFromPrivateKey (privateKey: RSAPrivateKey): RSAPeerId +export function peerIdFromPrivateKey (privateKey: MLDSAPrivateKey): MLDSAPeerId export function peerIdFromPrivateKey (privateKey: PrivateKey): PeerId export function peerIdFromPrivateKey (privateKey: PrivateKey): PeerId { return peerIdFromPublicKey(privateKey.publicKey) @@ -87,7 +94,7 @@ export function peerIdFromPrivateKey (privateKey: PrivateKey): PeerId { export function peerIdFromMultihash (multihash: MultihashDigest): PeerId { if (isSha256Multihash(multihash)) { - return new RSAPeerIdClass({ multihash }) + return new HashedPeerIdClass({ multihash }) } else if (isIdentityMultihash(multihash)) { try { const publicKey = publicKeyFromMultihash(multihash) @@ -108,7 +115,7 @@ export function peerIdFromMultihash (multihash: MultihashDigest): PeerId { throw new InvalidMultihashError('Supplied PeerID Multihash is invalid') } -export function peerIdFromCID (cid: CID): Ed25519PeerId | Secp256k1PeerId | RSAPeerId | URLPeerId { +export function peerIdFromCID (cid: CID): Ed25519PeerId | Secp256k1PeerId | RSAPeerId | URLPeerId | MLDSAPeerId | HashedPeerId { if (cid?.multihash == null || cid.version == null || (cid.version === 1 && (cid.code !== LIBP2P_KEY_CODE) && cid.code !== TRANSPORT_IPFS_GATEWAY_HTTP_CODE)) { throw new InvalidCIDError('Supplied PeerID CID is invalid') } diff --git a/packages/peer-id/src/peer-id.ts b/packages/peer-id/src/peer-id.ts index 748ed41f98..dd65cd1806 100644 --- a/packages/peer-id/src/peer-id.ts +++ b/packages/peer-id/src/peer-id.ts @@ -21,7 +21,7 @@ import { identity } from 'multiformats/hashes/identity' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' -import type { Ed25519PeerId as Ed25519PeerIdInterface, PeerIdType, RSAPeerId as RSAPeerIdInterface, URLPeerId as URLPeerIdInterface, Secp256k1PeerId as Secp256k1PeerIdInterface, PeerId, PublicKey, Ed25519PublicKey, Secp256k1PublicKey, RSAPublicKey } from '@libp2p/interface' +import type { Ed25519PeerId as Ed25519PeerIdInterface, PeerIdType, RSAPeerId as RSAPeerIdInterface, URLPeerId as URLPeerIdInterface, Secp256k1PeerId as Secp256k1PeerIdInterface, MLDSAPeerId as MLDSAPeerIdInterface, HashedPeerId as HashedPeerIdInterface, PeerId, PublicKey, Ed25519PublicKey, Secp256k1PublicKey, RSAPublicKey, MLDSAPublicKey } from '@libp2p/interface' import type { MultihashDigest } from 'multiformats/hashes/interface' const inspect = Symbol.for('nodejs.util.inspect.custom') @@ -49,6 +49,16 @@ interface Secp256k1PeerIdInit { publicKey: Secp256k1PublicKey } +interface MLDSAPeerIdInit { + multihash: MultihashDigest<0x12> + publicKey?: MLDSAPublicKey +} + +interface HashedPeerIdInit { + multihash: MultihashDigest<0x12> + publicKey?: RSAPublicKey | MLDSAPublicKey +} + class PeerIdImpl { public type: PeerIdType private readonly multihash: MultihashDigest @@ -163,6 +173,28 @@ export class Secp256k1PeerId extends PeerIdImpl<0x0> implements Secp256k1PeerIdI } } +export class MLDSAPeerId extends PeerIdImpl<0x12> implements MLDSAPeerIdInterface { + public readonly type = 'MLDSA' + public readonly publicKey?: MLDSAPublicKey + + constructor (init: MLDSAPeerIdInit) { + super({ ...init, type: 'MLDSA' }) + + this.publicKey = init.publicKey + } +} + +export class HashedPeerId extends PeerIdImpl<0x12> implements HashedPeerIdInterface { + public readonly type = 'hashed' + public readonly publicKey?: RSAPublicKey | MLDSAPublicKey + + constructor (init: HashedPeerIdInit) { + super({ ...init, type: 'hashed' }) + + this.publicKey = init.publicKey + } +} + // these values are from https://github.com/multiformats/multicodec/blob/master/table.csv const TRANSPORT_IPFS_GATEWAY_HTTP_CODE = 0x0920 diff --git a/packages/peer-id/test/index.spec.ts b/packages/peer-id/test/index.spec.ts index 343363aba9..bb56f6b8a9 100644 --- a/packages/peer-id/test/index.spec.ts +++ b/packages/peer-id/test/index.spec.ts @@ -18,13 +18,15 @@ const RAW_CODE = 0x55 const types: KeyType[] = [ 'Ed25519', 'secp256k1', - 'RSA' + 'RSA', + 'MLDSA' ] describe('PeerId', () => { types.forEach(type => { describe(`${type} keys`, () => { let peerId: PeerId + const parsedType = (type === 'RSA' || type === 'MLDSA') ? 'hashed' : type before(async () => { const key = await generateKeyPair(type) @@ -34,7 +36,7 @@ describe('PeerId', () => { it('should create a PeerId from a Multihash', async () => { const id = peerIdFromMultihash(peerId.toMultihash()) expect(id.equals(peerId)).to.be.true() - expect(id.type).to.equal(type) + expect(id.type).to.equal(parsedType) expect(id.toString()).to.equal(peerId.toString()) expect(id.toCID().toString()).to.equal(peerId.toCID().toString()) }) @@ -42,7 +44,7 @@ describe('PeerId', () => { it('should create a PeerId from a string', async () => { const id = peerIdFromString(peerId.toString()) expect(id.equals(peerId)).to.be.true() - expect(id.type).to.equal(type) + expect(id.type).to.equal(parsedType) expect(id.toString()).to.equal(peerId.toString()) expect(id.toCID().toString()).to.equal(peerId.toCID().toString()) }) @@ -53,21 +55,21 @@ describe('PeerId', () => { it('should parse a v1 CID with the libp2p-key codec', async () => { const id = peerIdFromCID(peerId.toCID()) - expect(id.type).to.equal(type) + expect(id.type).to.equal(parsedType) expect(id.toString()).to.equal(peerId.toString()) expect(id.toCID().toString()).to.equal(peerId.toCID().toString()) }) it('should return the correct peer id from cid encoded peer id in base36', async () => { const id = peerIdFromString(peerId.toCID().toString(base36)) - expect(id.type).to.equal(type) + expect(id.type).to.equal(parsedType) expect(id.toString()).to.equal(peerId.toString()) expect(id.toCID().toString()).to.equal(peerId.toCID().toString()) }) it('should return the correct peer id from cid encoded peer id in base32', async () => { const id = peerIdFromString(peerId.toCID().toString(base32)) - expect(id.type).to.equal(type) + expect(id.type).to.equal(parsedType) expect(id.toString()).to.equal(peerId.toString()) expect(id.toCID().toString()).to.equal(peerId.toCID().toString()) }) @@ -159,4 +161,28 @@ describe('PeerId', () => { expect(id.toString()).to.equal(cid.toString()) }) }) + + describe('hashed peer ids', () => { + it('parses RSA multihash peer ids as hashed and without embedded public key', async () => { + const key = await generateKeyPair('RSA') + const peerId = peerIdFromPrivateKey(key) + const parsed = peerIdFromMultihash(peerId.toMultihash()) + + expect(peerId.type).to.equal('RSA') + expect(parsed.type).to.equal('hashed') + expect(parsed.publicKey).to.be.undefined() + expect(parsed.equals(peerId)).to.be.true() + }) + + it('parses MLDSA multihash peer ids as hashed and without embedded public key', async () => { + const key = await generateKeyPair('MLDSA') + const peerId = peerIdFromPrivateKey(key) + const parsed = peerIdFromMultihash(peerId.toMultihash()) + + expect(peerId.type).to.equal('MLDSA') + expect(parsed.type).to.equal('hashed') + expect(parsed.publicKey).to.be.undefined() + expect(parsed.equals(peerId)).to.be.true() + }) + }) }) diff --git a/packages/peer-store/src/utils/bytes-to-peer.ts b/packages/peer-store/src/utils/bytes-to-peer.ts index 87215ec455..cd00227e3b 100644 --- a/packages/peer-store/src/utils/bytes-to-peer.ts +++ b/packages/peer-store/src/utils/bytes-to-peer.ts @@ -12,9 +12,11 @@ function populatePublicKey (peerId: PeerId, protobuf: PeerPB): PeerId { let digest: Digest<18, number> | undefined - if (peerId.type === 'RSA') { + const multihash = peerId.toMultihash() + + if (multihash.code === 0x12) { // avoid hashing public key - digest = peerId.toMultihash() + digest = multihash } const publicKey = publicKeyFromProtobuf(protobuf.publicKey, digest) diff --git a/packages/peer-store/src/utils/to-peer-pb.ts b/packages/peer-store/src/utils/to-peer-pb.ts index 37625f4a16..2de34329ac 100644 --- a/packages/peer-store/src/utils/to-peer-pb.ts +++ b/packages/peer-store/src/utils/to-peer-pb.ts @@ -165,8 +165,8 @@ export async function toPeerPB (peerId: PeerId, data: Partial, strateg addr.observed = options.existingPeer?.peerPB.addresses?.find(addr => uint8ArrayEquals(addr.multiaddr, addr.multiaddr))?.observed ?? Date.now() }) - // Ed25519 and secp256k1 have their public key embedded in them so no need to duplicate it - if (peerId.type !== 'RSA') { + // identity multihashes embed their public key, hashed peer ids do not + if (peerId.toMultihash().code !== 0x12) { delete output.publicKey } diff --git a/packages/peer-store/test/index.spec.ts b/packages/peer-store/test/index.spec.ts index 5523490e1a..4470b3ea74 100644 --- a/packages/peer-store/test/index.spec.ts +++ b/packages/peer-store/test/index.spec.ts @@ -44,6 +44,39 @@ describe('PersistentPeerStore', () => { expect(peers.length).to.equal(0) }) + it('hydrates MLDSA hashed peer ids with stored public keys after reload', async () => { + const datastore = new MemoryDatastore() + const selfPeerId = peerIdFromPrivateKey(await generateKeyPair('Ed25519')) + const remotePrivateKey = await generateKeyPair('MLDSA') + const remotePeerId = peerIdFromPrivateKey(remotePrivateKey) + + const peerStoreA = persistentPeerStore({ + peerId: selfPeerId, + events: new TypedEventEmitter(), + datastore, + logger: defaultLogger() + }) + + await peerStoreA.patch(remotePeerId, { + publicKey: remotePrivateKey.publicKey, + multiaddrs: [multiaddr('/ip4/127.0.0.1/tcp/1234')], + protocols: ['/ipfs/id/1.0.0'] + }) + + const peerStoreB = persistentPeerStore({ + peerId: selfPeerId, + events: new TypedEventEmitter(), + datastore, + logger: defaultLogger() + }) + + const peer = await peerStoreB.get(remotePeerId) + + expect(peer.id.toString()).to.equal(remotePeerId.toString()) + expect(peer.id).to.have.property('type', 'MLDSA') + expect(peer.id.publicKey?.equals(remotePrivateKey.publicKey!)).to.be.true() + }) + describe('has', () => { it('has peer data', async () => { await expect(peerStore.has(otherPeerId)).to.eventually.be.false() diff --git a/packages/peer-store/test/save.spec.ts b/packages/peer-store/test/save.spec.ts index 18cf2f6d12..9a4e1e6977 100644 --- a/packages/peer-store/test/save.spec.ts +++ b/packages/peer-store/test/save.spec.ts @@ -185,7 +185,7 @@ describe('save', () => { await expect(spy.getCall(1).returnValue).to.eventually.have.property('updated', false) }) - it('should not store a public key if part of peer id', async () => { + it('should only store a public key when not embedded in the peer id', async () => { // @ts-expect-error private fields const spy = sinon.spy(peerStore.store.datastore, 'put') @@ -217,6 +217,15 @@ describe('save', () => { const dbPeerRsaKey = PeerPB.decode(spy.getCall(2).args[1]) expect(dbPeerRsaKey).to.have.property('publicKey').that.equalBytes(publicKeyToProtobuf(rsaKey.publicKey)) + + const mldsaKey = await generateKeyPair('MLDSA') + const mldsaPeer = peerIdFromPrivateKey(mldsaKey) + await peerStore.save(mldsaPeer, { + publicKey: mldsaKey.publicKey + }) + + const dbPeerMldsaKey = PeerPB.decode(spy.getCall(3).args[1]) + expect(dbPeerMldsaKey).to.have.property('publicKey').that.equalBytes(publicKeyToProtobuf(mldsaKey.publicKey!)) }) it('saves all of the fields', async () => { diff --git a/packages/protocol-identify/test/index.spec.ts b/packages/protocol-identify/test/index.spec.ts index d170604834..6fd4a0c83f 100644 --- a/packages/protocol-identify/test/index.spec.ts +++ b/packages/protocol-identify/test/index.spec.ts @@ -77,7 +77,38 @@ describe('identify', () => { protocols: [ '/foo/bar/1.0' ], - publicKey: publicKeyToProtobuf(remotePeer.publicKey) + publicKey: publicKeyToProtobuf(remotePeer.publicKey!) + } + + const [outgoingStream, incomingStream] = await streamPair() + incomingStream.send(lp.encode.single(IdentifyMessage.encode(message))) + const connection = stubInterface({ + remotePeer + }) + connection.newStream.withArgs('/ipfs/id/1.0.0').resolves(outgoingStream) + + // run identify + const response = await identify.identify(connection) + + expect(response.peerId.toString()).to.equal(remotePeer.toString()) + expect(response.protocols).to.deep.equal(message.protocols) + expect(response.listenAddrs.map(ma => ma.toString())).to.deep.equal(['/ip4/123.123.123.123/tcp/123']) + }) + + it('should be able to identify another peer with MLDSA identity', async () => { + identify = new Identify(components) + + await start(identify) + + const remotePeer = peerIdFromPrivateKey(await generateKeyPair('MLDSA')) + const message: IdentifyMessage = { + listenAddrs: [ + multiaddr('/ip4/123.123.123.123/tcp/123').bytes + ], + protocols: [ + '/foo/bar/1.0' + ], + publicKey: publicKeyToProtobuf(remotePeer.publicKey!) } const [outgoingStream, incomingStream] = await streamPair() @@ -95,6 +126,53 @@ describe('identify', () => { expect(response.listenAddrs.map(ma => ma.toString())).to.deep.equal(['/ip4/123.123.123.123/tcp/123']) }) + for (const variant of ['MLDSA44', 'MLDSA65', 'MLDSA87'] as const) { + it(`should handle an ${variant} signed peer record at default max message size`, async () => { + identify = new Identify(components) + + await start(identify) + + const remotePrivateKey = await generateKeyPair('MLDSA', variant) + const remotePeer = peerIdFromPrivateKey(remotePrivateKey) + const signedPeerRecord = await RecordEnvelope.seal(new PeerRecord({ + peerId: remotePeer, + multiaddrs: [ + multiaddr('/ip4/123.123.123.123/tcp/456') + ] + }), remotePrivateKey) + const message: IdentifyMessage = { + listenAddrs: [ + multiaddr('/ip4/123.123.123.123/tcp/123').bytes + ], + protocols: [ + '/foo/bar/1.0' + ], + publicKey: publicKeyToProtobuf(remotePrivateKey.publicKey), + signedPeerRecord: signedPeerRecord.marshal() + } + + const [outgoingStream, incomingStream] = await streamPair() + incomingStream.send(lp.encode.single(IdentifyMessage.encode(message))) + const connection = stubInterface({ + remotePeer + }) + connection.newStream.withArgs('/ipfs/id/1.0.0').resolves(outgoingStream) + + const response = await identify.identify(connection) + + expect(response.peerId.toString()).to.equal(remotePeer.toString()) + expect(response.signedPeerRecord).to.exist() + expect(response.listenAddrs.map(ma => ma.toString())).to.deep.equal(['/ip4/123.123.123.123/tcp/123']) + + // should have stored addresses from signed peer record + const peer = components.peerStore.patch.getCall(0).args[1] + expect(peer.addresses).to.deep.equal([{ + isCertified: true, + multiaddr: multiaddr('/ip4/123.123.123.123/tcp/456') + }]) + }) + } + it('should throw if identified peer is the wrong peer', async () => { identify = new Identify(components) @@ -107,7 +185,32 @@ describe('identify', () => { incomingStream.send(lp.encode.single(IdentifyMessage.encode({ listenAddrs: [], protocols: [], - publicKey: publicKeyToProtobuf(otherPeer.publicKey) + publicKey: publicKeyToProtobuf(otherPeer.publicKey!) + }))) + const connection = stubInterface({ + remotePeer + }) + connection.newStream.withArgs('/ipfs/id/1.0.0').resolves(outgoingStream) + + // run identify + await expect(identify.identify(connection)) + .to.eventually.be.rejected() + .and.to.have.property('name', 'InvalidMessageError') + }) + + it('should throw if identified MLDSA peer does not match the expected peer', async () => { + identify = new Identify(components) + + await start(identify) + + const remotePeer = peerIdFromPrivateKey(await generateKeyPair('MLDSA')) + const otherPeer = peerIdFromPrivateKey(await generateKeyPair('MLDSA')) + + const [outgoingStream, incomingStream] = await streamPair() + incomingStream.send(lp.encode.single(IdentifyMessage.encode({ + listenAddrs: [], + protocols: [], + publicKey: publicKeyToProtobuf(otherPeer.publicKey!) }))) const connection = stubInterface({ remotePeer diff --git a/packages/protocol-identify/test/push.spec.ts b/packages/protocol-identify/test/push.spec.ts index 920598a03c..44b3e9c5f8 100644 --- a/packages/protocol-identify/test/push.spec.ts +++ b/packages/protocol-identify/test/push.spec.ts @@ -2,6 +2,7 @@ import { generateKeyPair, publicKeyToProtobuf } from '@libp2p/crypto/keys' import { start, stop } from '@libp2p/interface' import { defaultLogger } from '@libp2p/logger' import { peerIdFromPrivateKey } from '@libp2p/peer-id' +import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record' import { streamPair, pbStream } from '@libp2p/utils' import { multiaddr } from '@multiformats/multiaddr' import { expect } from 'aegir/chai' @@ -128,7 +129,7 @@ describe('identify (push)', () => { const pb = pbStream(outgoingStream) void pb.write({ - publicKey: publicKeyToProtobuf(remotePeer.publicKey), + publicKey: publicKeyToProtobuf(remotePeer.publicKey!), protocols: [ updatedProtocol ], @@ -150,6 +151,49 @@ describe('identify (push)', () => { expect(update.addresses?.map(({ multiaddr }) => multiaddr.toString())).deep.equals([updatedAddress.toString()]) }) + for (const variant of ['MLDSA44', 'MLDSA65', 'MLDSA87'] as const) { + it(`should handle incoming push with an ${variant} signed peer record at default max message size`, async () => { + identify = new IdentifyPush(components) + + await start(identify) + + const remotePrivateKey = await generateKeyPair('MLDSA', variant) + const remotePeer = peerIdFromPrivateKey(remotePrivateKey) + const [outgoingStream, incomingStream] = await streamPair() + const connection = stubInterface({ + remotePeer + }) + + const signedPeerRecord = await RecordEnvelope.seal(new PeerRecord({ + peerId: remotePeer, + multiaddrs: [ + multiaddr('/ip4/127.0.0.1/tcp/48323') + ] + }), remotePrivateKey) + + const pb = pbStream(outgoingStream) + void pb.write({ + publicKey: publicKeyToProtobuf(remotePrivateKey.publicKey), + protocols: [ + '/special-new-protocol/1.0.0' + ], + listenAddrs: [ + multiaddr('/ip4/127.0.0.1/tcp/48322').bytes + ], + signedPeerRecord: signedPeerRecord.marshal() + }, IdentifyMessage) + + components.peerStore.patch.reset() + + await identify.handleProtocol(incomingStream, connection) + + expect(components.peerStore.patch.callCount).to.equal(1) + const update = components.peerStore.patch.getCall(0).args[1] + expect(update.addresses?.map(({ multiaddr }) => multiaddr.toString())).deep.equals(['/ip4/127.0.0.1/tcp/48323']) + expect(update.addresses?.[0]).to.have.property('isCertified', true) + }) + } + it('should time out during push identify', async () => { identify = new IdentifyPush(components, { timeout: 10