Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,9 @@
},
"dependencies": {
"@anon-aadhaar/core": "npm:@selfxyz/anon-aadhaar-core@^0.0.1",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^1.5.0",
"@noble/post-quantum": "^0.5.2",
"@openpassport/zk-kit-imt": "^0.0.5",
"@openpassport/zk-kit-lean-imt": "^0.0.6",
"@openpassport/zk-kit-smt": "^0.0.1",
Expand Down
18 changes: 18 additions & 0 deletions common/src/utils/proving.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import forge from 'node-forge';
import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js';
import { initElliptic } from '../utils/certificate_parsing/elliptic.js';
import type { EndpointType } from './appType.js';
import { generateX25519Keypair } from './proving/pqxdh-crypto.js';
import type { CryptoSuite, X25519Keypair } from './proving/pqxdh-types.js';

const elliptic = initElliptic();
const { ec: EC } = elliptic;
Expand Down Expand Up @@ -32,6 +34,9 @@ export const ec = new EC('p256');
// eslint-disable-next-line -- clientKey is created from ec so must be second
export const clientKey = ec.genKeyPair();

/// Client's X25519 keypair for post-quantum key exchange.
export const x25519Keys: X25519Keypair = generateX25519Keypair();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Global Key Reuse Breaks Forward Secrecy

The x25519Keys are generated once at module load time and reused globally across all sessions. This compromises the ephemeral nature of the PQXDH key exchange and breaks forward secrecy, making all past and future sessions vulnerable if the private key is compromised.

Fix in Cursor Fix in Web


type RegisterSuffixes = '' | '_id' | '_aadhaar';
type DscSuffixes = '' | '_id';
type DiscloseSuffixes = '' | '_id' | '_aadhaar';
Expand Down Expand Up @@ -106,3 +111,16 @@ export function getWSDbRelayerUrl(endpointType: EndpointType) {
? WS_DB_RELAYER
: WS_DB_RELAYER_STAGING;
}

/// PQXDH types for post-quantum key exchange.
export type { CryptoSuite, HelloParams, HelloResponse, KeyExchangeParams, SessionKeyMaterial, X25519Keypair } from './proving/pqxdh-types.js';

/// PQXDH cryptographic functions.
export {
generateX25519Keypair,
kyberEncapsulate,
computeX25519SharedSecret,
deriveSessionKey,
getSupportedSuites,
ml_kem768,
} from './proving/pqxdh-crypto.js';
93 changes: 93 additions & 0 deletions common/src/utils/proving/pqxdh-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1

import { randomBytes } from '@noble/hashes/utils';
import { hkdf } from '@noble/hashes/hkdf';
import { sha256 } from '@noble/hashes/sha2';
import { x25519 } from '@noble/curves/ed25519.js';
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
import type { X25519Keypair } from './pqxdh-types.js';

/// Generates a fresh X25519 keypair for PQXDH.
export function generateX25519Keypair(): X25519Keypair {
// generating 32 random bytes for the private key
const privateKey = randomBytes(32);

// deriving the public key from the private key using X25519
const publicKey = x25519.getPublicKey(privateKey);

return {
privateKey,
publicKey,
};
}

/// Performs Kyber ML-KEM-768 encapsulation to derive a shared secret.
/// @param kyberPublicKey: TEE's Kyber public key (ML-KEM-768, 1184 bytes)
/// @returns Object containing shared secret and ciphertext
export function kyberEncapsulate(kyberPublicKey: Uint8Array): {
sharedSecret: Uint8Array;
ciphertext: Uint8Array;
} {

// encapsulating with the server's Kyber public key to get shared secret and ciphertext
const { cipherText, sharedSecret } = ml_kem768.encapsulate(kyberPublicKey);

return {
sharedSecret,
ciphertext: cipherText,
};
}
Comment on lines +28 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add input validation for Kyber public key length.

ML-KEM-768 requires a 1184-byte public key, but the function doesn't validate the input length. Passing an incorrect size could lead to cryptographic failures or security vulnerabilities.

 export function kyberEncapsulate(kyberPublicKey: Uint8Array): {
   sharedSecret: Uint8Array;
   ciphertext: Uint8Array;
 } {
+  if (kyberPublicKey.length !== 1184) {
+    throw new Error(`Invalid Kyber public key length: expected 1184 bytes, got ${kyberPublicKey.length}`);
+  }
 
   // encapsulating with the server's Kyber public key to get shared secret and ciphertext
   const { cipherText, sharedSecret } = ml_kem768.encapsulate(kyberPublicKey);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function kyberEncapsulate(kyberPublicKey: Uint8Array): {
sharedSecret: Uint8Array;
ciphertext: Uint8Array;
} {
// encapsulating with the server's Kyber public key to get shared secret and ciphertext
const { cipherText, sharedSecret } = ml_kem768.encapsulate(kyberPublicKey);
return {
sharedSecret,
ciphertext: cipherText,
};
}
export function kyberEncapsulate(kyberPublicKey: Uint8Array): {
sharedSecret: Uint8Array;
ciphertext: Uint8Array;
} {
if (kyberPublicKey.length !== 1184) {
throw new Error(`Invalid Kyber public key length: expected 1184 bytes, got ${kyberPublicKey.length}`);
}
// encapsulating with the server's Kyber public key to get shared secret and ciphertext
const { cipherText, sharedSecret } = ml_kem768.encapsulate(kyberPublicKey);
return {
sharedSecret,
ciphertext: cipherText,
};
}
🤖 Prompt for AI Agents
In common/src/utils/proving/pqxdh-crypto.ts around lines 28 to 40, the
kyberEncapsulate function lacks validation of the kyberPublicKey length
(ML-KEM-768 requires exactly 1184 bytes); add an input check that the argument
is a Uint8Array and its length === 1184 and throw a clear Error if validation
fails (e.g., "Invalid Kyber public key length: expected 1184 bytes"); perform
this validation before calling ml_kem768.encapsulate to avoid passing malformed
input to the crypto library.


/// Computes X25519 ECDH shared secret between client and server.
/// @param privateKey: Client's X25519 private key (32 bytes)
/// @param serverPublicKey: TEE's X25519 public key (32 bytes)
/// @returns Shared secret (32 bytes)
export function computeX25519SharedSecret(privateKey: Uint8Array, serverPublicKey: Uint8Array): Uint8Array {
// computing the X25519 shared secret using ECDH
return x25519.getSharedSecret(privateKey, serverPublicKey);
}
Comment on lines +46 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add input validation for X25519 key lengths.

X25519 requires 32-byte keys. Validate inputs to prevent cryptographic failures and improve error diagnostics.

 export function computeX25519SharedSecret(privateKey: Uint8Array, serverPublicKey: Uint8Array): Uint8Array {
+  if (privateKey.length !== 32) {
+    throw new Error(`Invalid X25519 private key length: expected 32 bytes, got ${privateKey.length}`);
+  }
+  if (serverPublicKey.length !== 32) {
+    throw new Error(`Invalid X25519 public key length: expected 32 bytes, got ${serverPublicKey.length}`);
+  }
+
   // computing the X25519 shared secret using ECDH
   return x25519.getSharedSecret(privateKey, serverPublicKey);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function computeX25519SharedSecret(privateKey: Uint8Array, serverPublicKey: Uint8Array): Uint8Array {
// computing the X25519 shared secret using ECDH
return x25519.getSharedSecret(privateKey, serverPublicKey);
}
export function computeX25519SharedSecret(privateKey: Uint8Array, serverPublicKey: Uint8Array): Uint8Array {
if (privateKey.length !== 32) {
throw new Error(`Invalid X25519 private key length: expected 32 bytes, got ${privateKey.length}`);
}
if (serverPublicKey.length !== 32) {
throw new Error(`Invalid X25519 public key length: expected 32 bytes, got ${serverPublicKey.length}`);
}
// computing the X25519 shared secret using ECDH
return x25519.getSharedSecret(privateKey, serverPublicKey);
}
🤖 Prompt for AI Agents
In common/src/utils/proving/pqxdh-crypto.ts around lines 46 to 49, the function
computeX25519SharedSecret lacks input validation for X25519 keys; add checks
that both privateKey and serverPublicKey are Uint8Array instances of length 32
and throw a clear, specific RangeError (or TypeError for wrong type) with a
message like "X25519 keys must be 32 bytes" before calling
x25519.getSharedSecret to fail fast and improve diagnostics.


/// Derives the final session key using HKDF-SHA256 (following Signal PQXDH specification).
/// @param x25519Shared: X25519 shared secret from ECDH (32 bytes)
/// @param kyberShared: Kyber shared secret from KEM (32 bytes)
/// @returns Derived 32-byte session key
export function deriveSessionKey(
x25519Shared: Uint8Array,
kyberShared: Uint8Array,
): Buffer {

// creating F prefix (32 0xFF bytes) per Signal PQXDH spec
// ensures the IKM is never a valid curve25519 scalar or point encoding
const F = new Uint8Array(32).fill(0xff);

// concatenating the two shared secrets (X25519 || Kyber) to form KM
const KM = new Uint8Array(x25519Shared.length + kyberShared.length);
KM.set(x25519Shared, 0);
KM.set(kyberShared, x25519Shared.length);

// combining F and KM to form the input key material (IKM = F || KM)
const ikm = new Uint8Array(F.length + KM.length);
ikm.set(F, 0);
ikm.set(KM, F.length);

// using zero-filled salt (32 bytes for SHA-256 output length) per Signal spec
const salt = new Uint8Array(32).fill(0);

// encoding the info string following the pattern "protocol_curve_hash_pqkem"
// per Signal spec: "MyProtocol_CURVE25519_SHA-512_CRYSTALS-KYBER-1024"
const info = new TextEncoder().encode('Self-PQXDH-1_X25519_SHA-256_ML-KEM-768');

// deriving the final 32-byte session key using HKDF-SHA256
const sessionKey = hkdf(sha256, ikm, salt, info, 32);

return Buffer.from(sessionKey);
}
Comment on lines +55 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add input validation for shared secret lengths.

Both shared secrets must be 32 bytes for the HKDF computation to work correctly per Signal PQXDH spec. Validate to catch integration errors early.

 export function deriveSessionKey(
   x25519Shared: Uint8Array,
   kyberShared: Uint8Array,
 ): Buffer {
+  if (x25519Shared.length !== 32) {
+    throw new Error(`Invalid X25519 shared secret length: expected 32 bytes, got ${x25519Shared.length}`);
+  }
+  if (kyberShared.length !== 32) {
+    throw new Error(`Invalid Kyber shared secret length: expected 32 bytes, got ${kyberShared.length}`);
+  }
 
   // creating F prefix (32 0xFF bytes) per Signal PQXDH spec
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function deriveSessionKey(
x25519Shared: Uint8Array,
kyberShared: Uint8Array,
): Buffer {
// creating F prefix (32 0xFF bytes) per Signal PQXDH spec
// ensures the IKM is never a valid curve25519 scalar or point encoding
const F = new Uint8Array(32).fill(0xff);
// concatenating the two shared secrets (X25519 || Kyber) to form KM
const KM = new Uint8Array(x25519Shared.length + kyberShared.length);
KM.set(x25519Shared, 0);
KM.set(kyberShared, x25519Shared.length);
// combining F and KM to form the input key material (IKM = F || KM)
const ikm = new Uint8Array(F.length + KM.length);
ikm.set(F, 0);
ikm.set(KM, F.length);
// using zero-filled salt (32 bytes for SHA-256 output length) per Signal spec
const salt = new Uint8Array(32).fill(0);
// encoding the info string following the pattern "protocol_curve_hash_pqkem"
// per Signal spec: "MyProtocol_CURVE25519_SHA-512_CRYSTALS-KYBER-1024"
const info = new TextEncoder().encode('Self-PQXDH-1_X25519_SHA-256_ML-KEM-768');
// deriving the final 32-byte session key using HKDF-SHA256
const sessionKey = hkdf(sha256, ikm, salt, info, 32);
return Buffer.from(sessionKey);
}
export function deriveSessionKey(
x25519Shared: Uint8Array,
kyberShared: Uint8Array,
): Buffer {
if (x25519Shared.length !== 32) {
throw new Error(`Invalid X25519 shared secret length: expected 32 bytes, got ${x25519Shared.length}`);
}
if (kyberShared.length !== 32) {
throw new Error(`Invalid Kyber shared secret length: expected 32 bytes, got ${kyberShared.length}`);
}
// creating F prefix (32 0xFF bytes) per Signal PQXDH spec
// ensures the IKM is never a valid curve25519 scalar or point encoding
const F = new Uint8Array(32).fill(0xff);
// concatenating the two shared secrets (X25519 || Kyber) to form KM
const KM = new Uint8Array(x25519Shared.length + kyberShared.length);
KM.set(x25519Shared, 0);
KM.set(kyberShared, x25519Shared.length);
// combining F and KM to form the input key material (IKM = F || KM)
const ikm = new Uint8Array(F.length + KM.length);
ikm.set(F, 0);
ikm.set(KM, F.length);
// using zero-filled salt (32 bytes for SHA-256 output length) per Signal spec
const salt = new Uint8Array(32).fill(0);
// encoding the info string following the pattern "protocol_curve_hash_pqkem"
// per Signal spec: "MyProtocol_CURVE25519_SHA-512_CRYSTALS-KYBER-1024"
const info = new TextEncoder().encode('Self-PQXDH-1_X25519_SHA-256_ML-KEM-768');
// deriving the final 32-byte session key using HKDF-SHA256
const sessionKey = hkdf(sha256, ikm, salt, info, 32);
return Buffer.from(sessionKey);
}
🤖 Prompt for AI Agents
In common/src/utils/proving/pqxdh-crypto.ts around lines 55 to 85, the function
deriveSessionKey does not validate inputs; add explicit checks that both
x25519Shared and kyberShared are exactly 32 bytes long and throw a clear Error
(or return) if not, to fail fast on integration mistakes. Place the validations
at the top of the function before any concatenation (check both are Uint8Array
and length === 32), and include a descriptive message like "invalid shared
secret length: expected 32 bytes for x25519Shared/kyberShared" so callers can
diagnose the problem immediately.


/// Returns supported cryptographic suites in preference order (PQXDH first, then legacy P-256).
export function getSupportedSuites(): ('Self-PQXDH-1' | 'legacy-p256')[] {
return ['Self-PQXDH-1', 'legacy-p256'];
}

/// ML-KEM-768 (Kyber) implementation for testing and advanced usage.
export { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
43 changes: 43 additions & 0 deletions common/src/utils/proving/pqxdh-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1

/// Cryptographic suite identifier (legacy P-256 ECDH or post-quantum PQXDH).
export type CryptoSuite = 'legacy-p256' | 'Self-PQXDH-1';

/// Parameters for the initial hello message with suite negotiation.
export interface HelloParams {
user_pubkey: number[];
uuid: string;
supported_suites: CryptoSuite[];
}

/// TEE's response to hello message with selected suite and public keys.
/// For legacy-p256: attestation contains embedded server P-256 public key.
/// For Self-PQXDH-1: separate X25519 and Kyber public keys are provided.
export interface HelloResponse {
attestation: number[];
attestation_hash?: number[];
selected_suite: CryptoSuite;
// only present when selected_suite is 'Self-PQXDH-1'
x25519_pubkey?: number[];
kyber_pubkey?: number[];
}

/// Parameters for PQXDH key exchange completion message.
export interface KeyExchangeParams {
uuid: string;
kyber_ciphertext: number[];
}

/// X25519 keypair for PQXDH key exchange.
export interface X25519Keypair {
privateKey: Uint8Array;
publicKey: Uint8Array;
}

/// Derived session key material after key exchange.
export interface SessionKeyMaterial {
sharedKey: Buffer;
x25519_shared?: Uint8Array;
kyber_shared?: Uint8Array;
}
Loading