Skip to content

Conversation

@ArmanKolozyan
Copy link
Contributor

@ArmanKolozyan ArmanKolozyan commented Oct 24, 2025

Currently, the key exchange between the mobile and TEE uses P-256 ECDH, which is vulnerable to attacks from quantum computers ("harvest now, decrypt later"). This PR adds support for PQXDH (Post-Quantum Extended Diffie-Hellman), a hybrid protocol that combines classical X25519 with post-quantum ML-KEM-768 (Kyber) to provide quantum-resistant key exchange.

The implementation follows Signal's PQXDH specification and maintains backward compatibility with the existing P-256 flow. When a client connects, it advertises support for both Self-PQXDH-1 and legacy-p256 suites, and the TEE picks the one it supports.

This PR only implements the client-side components and thus partially handles #1315. The TEE-side implementation is not included yet.

How It Works

  1. Client opens WebSocket and sends hello with the options ["Self-PQXDH-1", "legacy-p256"]
  2. TEE responds with attestation and selected suite
  3. If Self-PQXDH-1 selected:
    • Client validates attestation and extracts TEE's X25519 + Kyber public keys
    • Client computes X25519 shared secret and performs Kyber encapsulation
    • Client derives session key by combining both shared secrets with HKDF
    • Client sends Kyber ciphertext back to TEE
    • TEE decapsulates, derives same session key, confirms completion
  4. If legacy-p256 selected, existing P-256 flow is used

Changes

New Files

common/src/utils/proving/pqxdh-crypto.ts

  • generateX25519Keypair() - generates X25519 keypairs for ECDH
  • kyberEncapsulate() - performs ML-KEM-768 encapsulation
  • computeX25519SharedSecret() - computes X25519 shared secret
  • deriveSessionKey() - derives session key from both X25519 and Kyber shared secrets using HKDF
  • getSupportedSuites() - returns supported cipher suites in preference order

common/tests/pqxdh-crypto.test.ts

  • 15 tests
  • Tests for Signal spec compliance (F prefix, zero salt, info string format)

common/tests/pqxdh-cross-language.test.ts

  • Cross-language integration test spawning actual Rust TEE server
  • Tests complete PQXDH handshake between TypeScript client and Rust server
  • Verifies both sides derive identical session keys
  • Tests legacy P-256 fallback, suite negotiation, and error handling

Modified Files

common/src/utils/proving/types.ts

  • Extended HelloResponse type with optional selected_suite, x25519_pubkey, kyber_pubkey fields
  • These fields are only present when the TEE selects Self-PQXDH-1

packages/mobile-sdk-alpha/src/proving/provingMachine.ts

  • Added selectedSuite, kyberCiphertext, sharedKey to proving store state
  • Modified _handleWsOpen() to advertise supported suites in hello message
  • Modified _handleWebSocketMessage() to:
    • Handle suite selection from TEE
    • Perform hybrid X25519 + Kyber key exchange when PQXDH is selected
    • Send key exchange message with Kyber ciphertext
    • Wait for TEE confirmation before proceeding
    • Fall back to legacy P-256 flow if that's what the TEE selects
  • Updated _closeConnections() and init() to clean up PQXDH state

Testing

Unit Tests

yarn workspace @selfxyz/common test pqxdh-crypto

Integration Tests

yarn workspace @selfxyz/common test pqxdh-cross-language

Summary by CodeRabbit

  • New Features

    • Added post-quantum key-exchange (PQXDH) support with suite negotiation and runtime handling
    • Enhanced state to track selected suite and key-exchange artifacts; new analytics events for key-exchange progress
  • Tests

    • Added unit and end-to-end cross-language tests covering PQXDH primitives and full key-exchange flows
  • Chores

    • Added post-quantum crypto libraries to dependencies and updated test module resolution config

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 24, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

A PQXDH (X25519 + ML-KEM-768) post-quantum key exchange implementation is added: new crypto primitives, types, and test suites in common, plus suite negotiation and dual-path proving logic in the mobile SDK proving machine and related test/config updates.

Changes

Cohort / File(s) Summary
Dependencies
common/package.json
Added @noble/curves@^2.0.1 and @noble/post-quantum@^0.5.2.
PQXDH Types
common/src/utils/proving/pqxdh-types.ts
New TypeScript type definitions for PQXDH: CryptoSuite, HelloParams, HelloResponse, KeyExchangeParams, X25519Keypair, SessionKeyMaterial.
PQXDH Crypto Implementation
common/src/utils/proving/pqxdh-crypto.ts
New crypto primitives: generateX25519Keypair, kyberEncapsulate, computeX25519SharedSecret, deriveSessionKey (HKDF-SHA256 with F-prefix), getSupportedSuites, and re-export ml_kem768.
Proving Utilities Re-exports
common/src/utils/proving.ts
Re-exports PQXDH types and crypto functions; module-level x25519Keys generated at load time.
Proving Machine Integration
packages/mobile-sdk-alpha/src/proving/provingMachine.ts
Added selectedSuite and kyberCiphertext to ProvingState; hello now advertises supported_suites; attestation handling and proof flow branch for Self-PQXDH-1 vs legacy-p256; Kyber encapsulation, X25519 shared secret, HKDF session derivation, websocket key_exchange send/wait logic added.
Analytics Events
packages/mobile-sdk-alpha/src/constants/analytics.ts
Added PQXDH_KEY_EXCHANGE_SENT and PQXDH_KEY_EXCHANGE_COMPLETE.
Tests — Crypto Unit
common/tests/pqxdh-crypto.test.ts
New vitest suite exercising keypair gen, Kyber encapsulation/decapsulation, X25519 shared secret, HKDF deriveSessionKey, suite listing, spec-compliance checks, and end-to-end exchange.
Tests — Cross-Language E2E
common/tests/pqxdh-cross-language.test.ts
New end-to-end WebSocket tests interacting with a spawned Rust TEE server to validate full PQXDH handshake, fallback, and error cases.
Vitest Resolution Aliases
common/vitest.config.ts, packages/mobile-sdk-alpha/vitest.config.ts
Added resolve.alias mapping '@noble/curves/ed25519''@noble/curves/ed25519.js' for tests.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant TEE as TEE_Server

    rect rgb(240,248,255)
    note over Client,TEE: Hello & Suite Negotiation
    Client->>TEE: WS hello (user_pubkey, uuid, supported_suites)
    TEE->>Client: hello_response (attestation, selected_suite, pubkeys)
    end

    alt selected_suite == 'Self-PQXDH-1'
        rect rgb(220,255,230)
        note over Client: PQXDH path (post-quantum)
        Client->>Client: kyberEncapsulate(server_kyber_pubkey) → {kyberShared, ciphertext}
        Client->>Client: computeX25519SharedSecret(client_priv, server_x25519_pub) → x25519Shared
        Client->>Client: deriveSessionKey(x25519Shared, kyberShared) → sessionKey
        Client->>TEE: WS key_exchange (kyber_ciphertext)
        TEE->>Client: key_exchange_complete
        Client->>Client: Transition to CONNECT_SUCCESS
        end
    else selected_suite == 'legacy-p256'
        rect rgb(255,240,230)
        note over Client: Legacy P-256 ECDH
        Client->>Client: deriveECDH(client_p256_priv, server_p256_pub) → sessionKey
        Client->>Client: Transition to CONNECT_SUCCESS
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45–60 minutes

Areas requiring focused attention:

  • Cryptographic correctness of deriveSessionKey (HKDF parameters, F-prefix, info string, zero salt) vs. PQXDH spec.
  • provingMachine branching and async websocket flows (race conditions, state resets, error paths).
  • Validation of Kyber ciphertext and shared-secret lengths / types, and handling unexpected/missing server pubkeys in hello_response.
  • Cross-language E2E test orchestration (spawned Rust server lifecycle, timeouts, cleanup).

Possibly related PRs

Suggested labels

codex

Suggested reviewers

  • aaronmgdr
  • shazarre
  • seshanthS

Poem

⚔️ Quantum dusk meets X25519 light,
Kyber ciphers weave through night,
Old curves bow, new suites take flight,
Proofs align, the handshake's tight—
Future-safe keys, the app sleeps right. 🌙

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title "feat: Post-Quantum Extended Diffie–Hellman (PQXDH) on the Mobile" accurately reflects the primary objective of this pull request. The changeset implements client-side PQXDH support with new cryptographic utilities, type definitions, comprehensive test coverage, and integration into the proving state machine, all focused on adding post-quantum key exchange capabilities to the mobile application. The title is concise, uses conventional commit format, and avoids vague language or noise; it provides sufficient clarity for teammates scanning git history to immediately understand the main change.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

cursor[bot]

This comment was marked as outdated.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
common/tests/pqxdh-crypto.test.ts (1)

16-228: LGTM: Excellent test coverage for PQXDH implementation.

The test suite comprehensively validates:

  • Cryptographic primitive correctness (keypair generation, encapsulation, shared secrets)
  • Deterministic behavior where expected (HKDF derivation)
  • Probabilistic behavior where expected (Kyber ciphertext randomization)
  • Signal PQXDH specification compliance
  • End-to-end key exchange agreement

Once input validation is added to the crypto functions, consider adding negative test cases to verify error handling for invalid input lengths.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4f00dcd and 1f3fefe.

📒 Files selected for processing (9)
  • common/package.json (1 hunks)
  • common/src/utils/proving.ts (3 hunks)
  • common/src/utils/proving/pqxdh-crypto.ts (1 hunks)
  • common/src/utils/proving/pqxdh-types.ts (1 hunks)
  • common/tests/pqxdh-crypto.test.ts (1 hunks)
  • common/vitest.config.ts (1 hunks)
  • packages/mobile-sdk-alpha/src/constants/analytics.ts (1 hunks)
  • packages/mobile-sdk-alpha/src/proving/provingMachine.ts (9 hunks)
  • packages/mobile-sdk-alpha/vitest.config.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (8)
**/package.json

📄 CodeRabbit inference engine (AGENTS.md)

Use Yarn only for package management (yarn install/add/remove); do not use npm or pnpm in scripts

Files:

  • common/package.json
packages/mobile-sdk-alpha/**/*.{ts,tsx}

📄 CodeRabbit inference engine (packages/mobile-sdk-alpha/AGENTS.md)

packages/mobile-sdk-alpha/**/*.{ts,tsx}: Use strict TypeScript type checking across the codebase
Follow ESLint TypeScript-specific rules
Avoid introducing circular dependencies

Files:

  • packages/mobile-sdk-alpha/src/constants/analytics.ts
  • packages/mobile-sdk-alpha/vitest.config.ts
  • packages/mobile-sdk-alpha/src/proving/provingMachine.ts
**/*.{js,ts,tsx,jsx,sol,nr}

📄 CodeRabbit inference engine (.cursorrules)

**/*.{js,ts,tsx,jsx,sol,nr}: NEVER log sensitive data including PII (names, DOB, passport numbers, addresses), credentials, tokens, API keys, private keys, or session identifiers.
ALWAYS redact/mask sensitive fields in logs using consistent patterns (e.g., ***-***-1234 for passport numbers, J*** D*** for names).

Files:

  • packages/mobile-sdk-alpha/src/constants/analytics.ts
  • common/vitest.config.ts
  • common/tests/pqxdh-crypto.test.ts
  • packages/mobile-sdk-alpha/vitest.config.ts
  • common/src/utils/proving/pqxdh-crypto.ts
  • common/src/utils/proving.ts
  • common/src/utils/proving/pqxdh-types.ts
  • packages/mobile-sdk-alpha/src/proving/provingMachine.ts
packages/mobile-sdk-alpha/**/*.{ts,tsx,js,jsx}

⚙️ CodeRabbit configuration file

packages/mobile-sdk-alpha/**/*.{ts,tsx,js,jsx}: Review alpha mobile SDK code for:

  • API consistency with core SDK
  • Platform-neutral abstractions
  • Performance considerations
  • Clear experimental notes or TODOs

Files:

  • packages/mobile-sdk-alpha/src/constants/analytics.ts
  • packages/mobile-sdk-alpha/vitest.config.ts
  • packages/mobile-sdk-alpha/src/proving/provingMachine.ts
**/*.{test,spec}.{ts,js,tsx,jsx}

⚙️ CodeRabbit configuration file

**/*.{test,spec}.{ts,js,tsx,jsx}: Review test files for:

  • Test coverage completeness
  • Test case quality and edge cases
  • Mock usage appropriateness
  • Test readability and maintainability

Files:

  • common/tests/pqxdh-crypto.test.ts
packages/mobile-sdk-alpha/vitest.config.ts

📄 CodeRabbit inference engine (.cursor/rules/mobile-sdk-migration.mdc)

Use Vitest in the SDK with a Node environment configured in packages/mobile-sdk-alpha/vitest.config.ts

Files:

  • packages/mobile-sdk-alpha/vitest.config.ts
common/src/**/*.{ts,tsx,js,jsx}

⚙️ CodeRabbit configuration file

common/src/**/*.{ts,tsx,js,jsx}: Review shared utilities for:

  • Reusability and modular design
  • Type safety and error handling
  • Side-effect management
  • Documentation and naming clarity

Files:

  • common/src/utils/proving/pqxdh-crypto.ts
  • common/src/utils/proving.ts
  • common/src/utils/proving/pqxdh-types.ts
packages/mobile-sdk-alpha/src/proving/**

📄 CodeRabbit inference engine (.cursor/rules/mobile-sdk-migration.mdc)

Place proof input generation in packages/mobile-sdk-alpha/src/proving/

Files:

  • packages/mobile-sdk-alpha/src/proving/provingMachine.ts
🧠 Learnings (4)
📚 Learning: 2025-08-24T18:54:04.809Z
Learnt from: CR
PR: selfxyz/self#0
File: .cursor/rules/mobile-sdk-migration.mdc:0-0
Timestamp: 2025-08-24T18:54:04.809Z
Learning: Applies to packages/mobile-sdk-alpha/vitest.config.ts : Use Vitest in the SDK with a Node environment configured in packages/mobile-sdk-alpha/vitest.config.ts

Applied to files:

  • packages/mobile-sdk-alpha/vitest.config.ts
📚 Learning: 2025-08-25T14:25:57.586Z
Learnt from: aaronmgdr
PR: selfxyz/self#951
File: app/src/providers/authProvider.web.tsx:17-18
Timestamp: 2025-08-25T14:25:57.586Z
Learning: The selfxyz/mobile-sdk-alpha/constants/analytics import path is properly configured with SDK exports, Metro aliases, and TypeScript resolution. Import changes from @/consts/analytics to this path are part of valid analytics migration, not TypeScript resolution issues.

Applied to files:

  • packages/mobile-sdk-alpha/vitest.config.ts
📚 Learning: 2025-08-24T18:54:04.809Z
Learnt from: CR
PR: selfxyz/self#0
File: .cursor/rules/mobile-sdk-migration.mdc:0-0
Timestamp: 2025-08-24T18:54:04.809Z
Learning: Applies to packages/mobile-sdk-alpha/tests/setup.ts : Provide Vitest setup file at packages/mobile-sdk-alpha/tests/setup.ts to suppress console noise

Applied to files:

  • packages/mobile-sdk-alpha/vitest.config.ts
📚 Learning: 2025-08-29T15:31:15.924Z
Learnt from: CR
PR: selfxyz/self#0
File: packages/mobile-sdk-alpha/AGENTS.md:0-0
Timestamp: 2025-08-29T15:31:15.924Z
Learning: Applies to packages/mobile-sdk-alpha/{**/*.test.{ts,tsx},**/__tests__/**/*.{ts,tsx}} : Use actual imports from selfxyz/mobile-sdk-alpha in tests

Applied to files:

  • packages/mobile-sdk-alpha/vitest.config.ts
🧬 Code graph analysis (2)
common/src/utils/proving.ts (1)
common/src/utils/proving/pqxdh-crypto.ts (1)
  • generateX25519Keypair (12-23)
packages/mobile-sdk-alpha/src/proving/provingMachine.ts (2)
common/src/utils/proving.ts (1)
  • x25519Keys (38-38)
packages/mobile-sdk-alpha/src/constants/analytics.ts (1)
  • ProofEvents (138-203)
🔇 Additional comments (13)
common/package.json (1)

664-666: LGTM: Reputable cryptographic dependencies added.

The @noble/curves and @noble/post-quantum libraries are well-maintained, audited cryptographic primitives from a trusted source. These dependencies are appropriate for implementing post-quantum key exchange.

packages/mobile-sdk-alpha/src/constants/analytics.ts (1)

163-166: LGTM: Analytics events properly defined.

The new PQXDH key exchange events follow the established naming pattern and are appropriately placed within the ProofEvents group.

common/vitest.config.ts (1)

10-14: LGTM: Test module resolution fix.

The alias ensures proper ESM module resolution for @noble/curves in the Vitest environment, addressing the .js extension requirement.

packages/mobile-sdk-alpha/vitest.config.ts (1)

8-12: LGTM: Consistent module resolution across packages.

This mirrors the alias in common/vitest.config.ts, ensuring uniform test module resolution.

common/src/utils/proving/pqxdh-crypto.ts (2)

12-23: LGTM: X25519 keypair generation is correct.

The implementation properly uses cryptographically secure random bytes and derives the public key correctly.


88-90: LGTM: Suite preference order is appropriate.

Prioritizing PQXDH over legacy P-256 aligns with security best practices for post-quantum readiness.

common/src/utils/proving.ts (2)

37-38: Verify: Module-level keypair generation may limit session isolation.

The X25519 keypair is generated once at module load time, similar to the legacy P-256 clientKey. Confirm whether:

  1. Keys should be regenerated per session/connection for better forward secrecy
  2. This static approach is intentional to match existing architecture
  3. Keys are rotated elsewhere in the lifecycle

Based on the PR objectives stating "client-side support" and the pattern matching the existing clientKey behavior, this may be intentional, but please verify the security model aligns with your threat model for key rotation and forward secrecy.


115-126: LGTM: Clean PQXDH API surface.

The re-exports provide a well-organized public interface for PQXDH functionality with clear separation between types and implementation.

common/src/utils/proving/pqxdh-types.ts (1)

1-43: LGTM: Well-documented type definitions.

The PQXDH types are clearly defined with appropriate comments explaining conditional fields. The structure aligns well with the protocol flow.

packages/mobile-sdk-alpha/src/proving/provingMachine.ts (4)

879-897: LGTM - Suite negotiation properly implemented.

The WebSocket hello correctly advertises both the legacy P-256 public key (for backward compatibility) and the list of supported cryptographic suites. The TEE can then select the strongest mutually-supported suite. Logging includes only non-sensitive protocol information.


560-575: LGTM - User public key validation is security-critical and correctly implemented.

The attestation validation correctly verifies that the user public key embedded in the TEE attestation matches the expected key for the selected cryptographic suite. This prevents man-in-the-middle attacks and ensures the TEE has bound the correct client identity.


979-980: LGTM - State cleanup properly includes new PQXDH fields.

The new selectedSuite and kyberCiphertext fields are correctly reset to null during initialization and connection cleanup, preventing state leakage between proving sessions.

Also applies to: 1531-1532


619-627: No performance concern—these cryptographic operations are appropriately implemented.

The synchronous crypto operations (Kyber encapsulation, X25519 ECDH, and HKDF) are correctly wrapping optimized, battle-tested libraries. These execute once during PQXDH key exchange setup, not per-frame, making the 1–2ms overhead from ML-KEM-768 entirely acceptable. X25519 and HKDF are microsecond-scale operations. The code follows Signal's PQXDH specification and requires no refactoring.

Comment on lines +28 to +40
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,
};
}
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.

Comment on lines +46 to +49
export function computeX25519SharedSecret(privateKey: Uint8Array, serverPublicKey: Uint8Array): Uint8Array {
// computing the X25519 shared secret using ECDH
return x25519.getSharedSecret(privateKey, serverPublicKey);
}
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.

Comment on lines +55 to +85
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);
}
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.

Comment on lines +534 to +548
const helloResponse = result.result as HelloResponse;
const attestationData = helloResponse.attestation;
// extracting the selected suite from TEE response, defaulting to legacy for backward compatibility
const selectedSuite = helloResponse.selected_suite || 'legacy-p256';

// storing attestation data and suite selection in state
set({
attestation: attestationData,
selectedSuite,
});

selfClient.logProofEvent('info', 'Suite selected by TEE', context, {
selected_suite: selectedSuite,
});

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

Validate HelloResponse structure before type assertion.

The type assertion result.result as HelloResponse assumes the TEE response matches the expected structure. If the TEE returns a different format or is missing fields, this could lead to runtime errors downstream (especially when accessing optional fields like x25519_pubkey and kyber_pubkey).

Consider validating critical fields before proceeding:

 const helloResponse = result.result as HelloResponse;
 const attestationData = helloResponse.attestation;
+
+// Validate response structure
+if (!attestationData) {
+  console.error('Missing attestation in TEE response');
+  actor!.send({ type: 'CONNECT_ERROR' });
+  return;
+}
+
 // extracting the selected suite from TEE response, defaulting to legacy for backward compatibility
 const selectedSuite = helloResponse.selected_suite || 'legacy-p256';
📝 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
const helloResponse = result.result as HelloResponse;
const attestationData = helloResponse.attestation;
// extracting the selected suite from TEE response, defaulting to legacy for backward compatibility
const selectedSuite = helloResponse.selected_suite || 'legacy-p256';
// storing attestation data and suite selection in state
set({
attestation: attestationData,
selectedSuite,
});
selfClient.logProofEvent('info', 'Suite selected by TEE', context, {
selected_suite: selectedSuite,
});
const helloResponse = result.result as HelloResponse;
const attestationData = helloResponse.attestation;
// Validate response structure
if (!attestationData) {
console.error('Missing attestation in TEE response');
actor!.send({ type: 'CONNECT_ERROR' });
return;
}
// extracting the selected suite from TEE response, defaulting to legacy for backward compatibility
const selectedSuite = helloResponse.selected_suite || 'legacy-p256';
// storing attestation data and suite selection in state
set({
attestation: attestationData,
selectedSuite,
});
selfClient.logProofEvent('info', 'Suite selected by TEE', context, {
selected_suite: selectedSuite,
});
🤖 Prompt for AI Agents
In packages/mobile-sdk-alpha/src/proving/provingMachine.ts around lines 534 to
548, the code blindly casts result.result to HelloResponse which can cause
runtime errors if the TEE response is malformed or missing fields; add a runtime
validation step (a type guard or simple checks) to confirm result.result is an
object and contains the critical fields you rely on (at minimum attestation and
the selected_suite or other expected keys like x25519_pubkey/kyber_pubkey when
required), log and handle invalid responses (return/throw an error or set an
error state) instead of proceeding, only set the state and call logProofEvent
after the validation passes, and keep the existing default for selected_suite
when that field is absent.

Comment on lines +664 to 669
} else if (result.id === 3 && result.result === 'key_exchange_complete') {
// receiving acknowledgment that PQXDH key exchange was successful
selfClient.logProofEvent('info', 'Key exchange complete', context);
selfClient?.trackEvent(ProofEvents.PQXDH_KEY_EXCHANGE_COMPLETE);
// key exchange complete, transitioning to connected state
actor!.send({ type: 'CONNECT_SUCCESS' });
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 timeout mechanism for key exchange acknowledgment.

After sending the Kyber ciphertext, the code waits indefinitely for the TEE to respond with key_exchange_complete. If the TEE fails to respond (network issue, TEE crash, etc.), the connection will hang in the init_tee_connexion state with no way to recover.

Consider implementing a timeout mechanism that transitions to CONNECT_ERROR if the acknowledgment isn't received within a reasonable timeframe (e.g., 30 seconds).

🤖 Prompt for AI Agents
In packages/mobile-sdk-alpha/src/proving/provingMachine.ts around lines 664 to
669, the handler for result.id===3 (key_exchange_complete) currently waits
forever; add a timeout started when the Kyber ciphertext is sent (or when
entering the init_tee_connexion state) that will be cleared on receipt of
key_exchange_complete and will send actor!.send({ type: 'CONNECT_ERROR' }) and
log/track an error if it fires (use ~30s); ensure the timeout reference is
stored on the actor/selfClient scope so it can be cleared here, and on timeout
call the same cleanup/metrics as a normal error branch to avoid leaking state.

@ArmanKolozyan ArmanKolozyan changed the title Implemented Post-Quantum Key Exchange (PQXDH) on the Mobile Implemented Post-Quantum Extended Diffie–Hellman (PQXDH) on the Mobile Oct 24, 2025
@ArmanKolozyan ArmanKolozyan changed the title Implemented Post-Quantum Extended Diffie–Hellman (PQXDH) on the Mobile feat: Post-Quantum Extended Diffie–Hellman (PQXDH) on the Mobile Oct 30, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3fefe and 1ecfe7a.

📒 Files selected for processing (1)
  • common/tests/pqxdh-cross-language.test.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,ts,tsx,jsx,sol,nr}

📄 CodeRabbit inference engine (.cursorrules)

**/*.{js,ts,tsx,jsx,sol,nr}: NEVER log sensitive data including PII (names, DOB, passport numbers, addresses), credentials, tokens, API keys, private keys, or session identifiers.
ALWAYS redact/mask sensitive fields in logs using consistent patterns (e.g., ***-***-1234 for passport numbers, J*** D*** for names).

Files:

  • common/tests/pqxdh-cross-language.test.ts
**/*.{test,spec}.{ts,js,tsx,jsx}

⚙️ CodeRabbit configuration file

**/*.{test,spec}.{ts,js,tsx,jsx}: Review test files for:

  • Test coverage completeness
  • Test case quality and edge cases
  • Mock usage appropriateness
  • Test readability and maintainability

Files:

  • common/tests/pqxdh-cross-language.test.ts
🧠 Learnings (1)
📚 Learning: 2025-08-29T15:31:15.924Z
Learnt from: CR
PR: selfxyz/self#0
File: packages/mobile-sdk-alpha/AGENTS.md:0-0
Timestamp: 2025-08-29T15:31:15.924Z
Learning: Applies to packages/mobile-sdk-alpha/{**/*.test.{ts,tsx},**/__tests__/**/*.{ts,tsx}} : Test isPassportDataValid() with realistic synthetic passport data (never real user data)

Applied to files:

  • common/tests/pqxdh-cross-language.test.ts

Comment on lines +203 to +255
console.log(` X25519 shared secret (first 8 bytes): ${Buffer.from(clientX25519Shared.slice(0, 8)).toString('hex')}`);

// encapsulating to server's Kyber public key
console.log('\n📦 Step 3: Kyber encapsulation...');
const serverKyberPublic = new Uint8Array(helloResponse.kyber_pubkey);
console.log(` Server Kyber public key length: ${serverKyberPublic.length} bytes`);
console.log(` Server Kyber public key (first 16 bytes): ${Buffer.from(serverKyberPublic.slice(0, 16)).toString('hex')}`);

const { sharedSecret: clientKyberShared, ciphertext } = kyberEncapsulate(serverKyberPublic);

expect(ciphertext.length).toBe(1088); // ML-KEM-768 ciphertext
expect(clientKyberShared.length).toBe(32);

console.log(` Kyber ciphertext: ${ciphertext.length} bytes`);
console.log(` Kyber ciphertext (first 16 bytes): ${Buffer.from(ciphertext.slice(0, 16)).toString('hex')}`);
console.log(` Kyber shared secret (first 8 bytes): ${Buffer.from(clientKyberShared.slice(0, 8)).toString('hex')}`);

// sending key_exchange with Kyber ciphertext
console.log('\n📤 Step 4: Sending key_exchange...');
const keyExchangeResponse = await sendRpcRequest(
ws,
'openpassport_key_exchange',
{
uuid,
kyber_ciphertext: Array.from(ciphertext),
},
2
);

expect(keyExchangeResponse).toBe('key_exchange_complete');
console.log('✅ Server completed key exchange');

// deriving client-side session key
console.log('\n🔐 Step 5: Deriving session key...');
const clientSessionKey = deriveSessionKey(clientX25519Shared, clientKyberShared);

console.log(` Client session key (first 8 bytes): ${clientSessionKey.subarray(0, 8).toString('hex')}`);

// getting server's session key for verification (DEBUG ONLY)
console.log('\n🔍 Step 6: Verifying keys match (DEBUG)...');
const serverSessionKey = await sendRpcRequest(
ws,
'openpassport_debug_get_session_key',
{ uuid },
10
);

const serverKeyBuffer = Buffer.from(serverSessionKey);
console.log(` Server session key (first 8 bytes): ${serverKeyBuffer.subarray(0, 8).toString('hex')}`);

// verifying session keys match
expect(serverKeyBuffer.length).toBe(32);
expect(clientSessionKey).toEqual(serverKeyBuffer);
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 | 🔴 Critical

Strip secret material from logs

These console.log statements print X25519 shared secrets, Kyber shared secrets, and the derived session key (even partially). That is sensitive key material, and per our JS/TS logging policy we must never emit secrets—even in tests—because logs can leak into CI artifacts or developer machines. Please delete or redact these logs before landing.

-      console.log(`   X25519 shared secret (first 8 bytes): ${Buffer.from(clientX25519Shared.slice(0, 8)).toString('hex')}`);-      console.log(`   Kyber shared secret (first 8 bytes): ${Buffer.from(clientKyberShared.slice(0, 8)).toString('hex')}`);-      console.log(`   Client session key (first 8 bytes): ${clientSessionKey.subarray(0, 8).toString('hex')}`);-      console.log(`   Server session key (first 8 bytes): ${serverKeyBuffer.subarray(0, 8).toString('hex')}`);

As per coding guidelines

📝 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
console.log(` X25519 shared secret (first 8 bytes): ${Buffer.from(clientX25519Shared.slice(0, 8)).toString('hex')}`);
// encapsulating to server's Kyber public key
console.log('\n📦 Step 3: Kyber encapsulation...');
const serverKyberPublic = new Uint8Array(helloResponse.kyber_pubkey);
console.log(` Server Kyber public key length: ${serverKyberPublic.length} bytes`);
console.log(` Server Kyber public key (first 16 bytes): ${Buffer.from(serverKyberPublic.slice(0, 16)).toString('hex')}`);
const { sharedSecret: clientKyberShared, ciphertext } = kyberEncapsulate(serverKyberPublic);
expect(ciphertext.length).toBe(1088); // ML-KEM-768 ciphertext
expect(clientKyberShared.length).toBe(32);
console.log(` Kyber ciphertext: ${ciphertext.length} bytes`);
console.log(` Kyber ciphertext (first 16 bytes): ${Buffer.from(ciphertext.slice(0, 16)).toString('hex')}`);
console.log(` Kyber shared secret (first 8 bytes): ${Buffer.from(clientKyberShared.slice(0, 8)).toString('hex')}`);
// sending key_exchange with Kyber ciphertext
console.log('\n📤 Step 4: Sending key_exchange...');
const keyExchangeResponse = await sendRpcRequest(
ws,
'openpassport_key_exchange',
{
uuid,
kyber_ciphertext: Array.from(ciphertext),
},
2
);
expect(keyExchangeResponse).toBe('key_exchange_complete');
console.log('✅ Server completed key exchange');
// deriving client-side session key
console.log('\n🔐 Step 5: Deriving session key...');
const clientSessionKey = deriveSessionKey(clientX25519Shared, clientKyberShared);
console.log(` Client session key (first 8 bytes): ${clientSessionKey.subarray(0, 8).toString('hex')}`);
// getting server's session key for verification (DEBUG ONLY)
console.log('\n🔍 Step 6: Verifying keys match (DEBUG)...');
const serverSessionKey = await sendRpcRequest(
ws,
'openpassport_debug_get_session_key',
{ uuid },
10
);
const serverKeyBuffer = Buffer.from(serverSessionKey);
console.log(` Server session key (first 8 bytes): ${serverKeyBuffer.subarray(0, 8).toString('hex')}`);
// verifying session keys match
expect(serverKeyBuffer.length).toBe(32);
expect(clientSessionKey).toEqual(serverKeyBuffer);
// encapsulating to server's Kyber public key
console.log('\n📦 Step 3: Kyber encapsulation...');
const serverKyberPublic = new Uint8Array(helloResponse.kyber_pubkey);
console.log(` Server Kyber public key length: ${serverKyberPublic.length} bytes`);
console.log(` Server Kyber public key (first 16 bytes): ${Buffer.from(serverKyberPublic.slice(0, 16)).toString('hex')}`);
const { sharedSecret: clientKyberShared, ciphertext } = kyberEncapsulate(serverKyberPublic);
expect(ciphertext.length).toBe(1088); // ML-KEM-768 ciphertext
expect(clientKyberShared.length).toBe(32);
console.log(` Kyber ciphertext: ${ciphertext.length} bytes`);
console.log(` Kyber ciphertext (first 16 bytes): ${Buffer.from(ciphertext.slice(0, 16)).toString('hex')}`);
// sending key_exchange with Kyber ciphertext
console.log('\n📤 Step 4: Sending key_exchange...');
const keyExchangeResponse = await sendRpcRequest(
ws,
'openpassport_key_exchange',
{
uuid,
kyber_ciphertext: Array.from(ciphertext),
},
2
);
expect(keyExchangeResponse).toBe('key_exchange_complete');
console.log('✅ Server completed key exchange');
// deriving client-side session key
console.log('\n🔐 Step 5: Deriving session key...');
const clientSessionKey = deriveSessionKey(clientX25519Shared, clientKyberShared);
// getting server's session key for verification (DEBUG ONLY)
console.log('\n🔍 Step 6: Verifying keys match (DEBUG)...');
const serverSessionKey = await sendRpcRequest(
ws,
'openpassport_debug_get_session_key',
{ uuid },
10
);
const serverKeyBuffer = Buffer.from(serverSessionKey);
// verifying session keys match
expect(serverKeyBuffer.length).toBe(32);
expect(clientSessionKey).toEqual(serverKeyBuffer);
🤖 Prompt for AI Agents
In common/tests/pqxdh-cross-language.test.ts around lines 203 to 255, the test
currently logs X25519 shared secrets, Kyber shared secrets, and the derived
session key (even partial bytes). Remove or redact any console.log that prints
secret material; instead log only non-sensitive metadata such as byte lengths or
a deterministic constant/placeholder, or log a hash/hmac of the secret if you
need verifiability (ensure the hash key is not a secret). Ensure no
Buffer.toString of shared secrets or session keys remains in the file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant