From 95b9fa3d193dc54bf7dc5769d322146497a0259a Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Fri, 6 Nov 2026 11:40:16 +0000 Subject: [PATCH 1/7] perf(mobile): sign DPoP proofs with the platform's P-256 On mobile, DPoP proofs were signed with @noble/curves. Hermes interprets it, so the first P-256 key load in each session blocked the JS thread for about 924 ms on a Solana Seeker while noble built its base-point table, and every proof then spent about 7 ms signing on the JS thread. Key generation, the stored-key check and signing now go through three T3NativeControls async functions: CryptoKit on iOS and the platform's SHA256withECDSA on Android. Stored keys keep their JWK format, so existing installs keep their key and relay sessions. @noble/curves is no longer a mobile dependency. --- .../modules/t3nativecontrols/T3DpopKeys.kt | 114 ++++++++++++++++++ .../T3NativeControlsModule.kt | 10 ++ .../t3-native-controls/ios/T3DpopKeys.swift | 20 +++ .../ios/T3NativeControlsModule.swift | 10 ++ apps/mobile/package.json | 1 - apps/mobile/src/features/cloud/dpop.test.ts | 83 ++++++++++++- apps/mobile/src/features/cloud/dpop.ts | 107 +++++++--------- .../client-runtime/src/relay/dpopProof.ts | 2 +- pnpm-lock.yaml | 3 - 9 files changed, 280 insertions(+), 70 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3DpopKeys.swift diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt new file mode 100644 index 000000000000..2f6659d837c1 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt @@ -0,0 +1,114 @@ +package expo.modules.t3nativecontrols + +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPrivateKeySpec +import java.security.spec.ECPublicKeySpec + +/** P-256 keys for DPoP proofs, as 32-byte private scalars and 65-byte uncompressed public points. */ +internal object T3DpopKeys { + private const val SCALAR_BYTES = 32 + private const val CURVE = "secp256r1" + private const val ALGORITHM = "SHA256withECDSA" + private val curve: ECParameterSpec by lazy { + AlgorithmParameters.getInstance("EC").run { + init(ECGenParameterSpec(CURVE)) + getParameterSpec(ECParameterSpec::class.java) + } + } + + /** The private scalar followed by the public point. */ + fun generate(): ByteArray { + val pair = KeyPairGenerator.getInstance("EC").run { + initialize(ECGenParameterSpec(CURVE)) + generateKeyPair() + } + val point = (pair.public as ECPublicKey).w + return scalar((pair.private as ECPrivateKey).s) + + byteArrayOf(UNCOMPRESSED) + + scalar(point.affineX) + + scalar(point.affineY) + } + + // JCA cannot derive a public point, so prove the pair by signing and verifying. + fun matches(privateKey: ByteArray, publicKey: ByteArray): Boolean { + val probe = "t3code dpop key check".toByteArray() + val signature = Signature.getInstance(ALGORITHM).run { + initSign(privateKey(privateKey)) + update(probe) + sign() + } + return Signature.getInstance(ALGORITHM).run { + initVerify(publicKey(publicKey)) + update(probe) + verify(signature) + } + } + + /** ES256 over the JWS signing input, as the raw `r‖s` pair JWS uses. */ + fun sign(privateKey: ByteArray, signingInput: ByteArray): ByteArray { + val der = Signature.getInstance(ALGORITHM).run { + initSign(privateKey(privateKey)) + update(signingInput) + sign() + } + return rawSignature(der) + } + + /** Converts JCA's DER `SEQUENCE { INTEGER r, INTEGER s }` to fixed-width `r‖s`. */ + internal fun rawSignature(der: ByteArray): ByteArray { + var offset = 0 + fun expect(tag: Int) = require(der[offset++].toInt() == tag) { "Malformed ECDSA signature." } + fun length(): Int { + val first = der[offset++].toInt() and 0xff + if (first < 0x80) return first + var length = 0 + repeat(first and 0x7f) { length = (length shl 8) or (der[offset++].toInt() and 0xff) } + return length + } + fun integer(): BigInteger { + expect(0x02) + val length = length() + return BigInteger(1, der.copyOfRange(offset, offset + length)).also { offset += length } + } + expect(0x30) + length() + return scalar(integer()) + scalar(integer()) + } + + private fun privateKey(bytes: ByteArray): PrivateKey { + require(bytes.size == SCALAR_BYTES) { "A DPoP private key is 32 bytes." } + return KeyFactory.getInstance("EC") + .generatePrivate(ECPrivateKeySpec(BigInteger(1, bytes), curve)) + } + + private fun publicKey(bytes: ByteArray): PublicKey { + require(bytes.size == 1 + 2 * SCALAR_BYTES && bytes[0] == UNCOMPRESSED) { + "A DPoP public key is an uncompressed P-256 point." + } + val x = BigInteger(1, bytes.copyOfRange(1, 1 + SCALAR_BYTES)) + val y = BigInteger(1, bytes.copyOfRange(1 + SCALAR_BYTES, bytes.size)) + return KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(ECPoint(x, y), curve)) + } + + /** Big-endian and left-padded to 32 bytes, without BigInteger's sign byte. */ + private fun scalar(value: BigInteger): ByteArray { + val bytes = value.toByteArray() + return when { + bytes.size > SCALAR_BYTES -> bytes.copyOfRange(bytes.size - SCALAR_BYTES, bytes.size) + else -> ByteArray(SCALAR_BYTES - bytes.size) + bytes + } + } + + private const val UNCOMPRESSED: Byte = 0x04 +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 7a5e11319288..836295e5f0be 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -21,6 +21,16 @@ class T3NativeControlsModule : Module() { DateFormat.is24HourFormat(context) } + AsyncFunction("generateDpopKey") { T3DpopKeys.generate() } + + AsyncFunction("dpopKeyMatches") { privateKey: ByteArray, publicKey: ByteArray -> + T3DpopKeys.matches(privateKey, publicKey) + } + + AsyncFunction("signDpop") { privateKey: ByteArray, signingInput: ByteArray -> + T3DpopKeys.sign(privateKey, signingInput) + } + AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> check(filePreviewPromise == null) { "A document viewer is already open." } val activity = appContext.currentActivity ?: error("The app is not active.") diff --git a/apps/mobile/modules/t3-native-controls/ios/T3DpopKeys.swift b/apps/mobile/modules/t3-native-controls/ios/T3DpopKeys.swift new file mode 100644 index 000000000000..a7bc36f1df63 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3DpopKeys.swift @@ -0,0 +1,20 @@ +import CryptoKit +import Foundation + +/// P-256 keys for DPoP proofs, as 32-byte private scalars and 65-byte uncompressed public points. +enum T3DpopKeys { + /// The private scalar followed by the public point. + static func generate() -> Data { + let key = P256.Signing.PrivateKey() + return key.rawRepresentation + key.publicKey.x963Representation + } + + static func matches(privateKey: Data, publicKey: Data) throws -> Bool { + try P256.Signing.PrivateKey(rawRepresentation: privateKey).publicKey.x963Representation == publicKey + } + + /// ES256 over the JWS signing input, as the raw `r‖s` pair JWS uses. + static func sign(privateKey: Data, signingInput: Data) throws -> Data { + try P256.Signing.PrivateKey(rawRepresentation: privateKey).signature(for: signingInput).rawRepresentation + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index f1d338a5478e..ef16afbb3438 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -51,6 +51,16 @@ public final class T3NativeControlsModule: Module { ViewName("ScrollEdgeBleed") } + AsyncFunction("generateDpopKey") { T3DpopKeys.generate() } + + AsyncFunction("dpopKeyMatches") { (privateKey: Data, publicKey: Data) in + try T3DpopKeys.matches(privateKey: privateKey, publicKey: publicKey) + } + + AsyncFunction("signDpop") { (privateKey: Data, signingInput: Data) in + try T3DpopKeys.sign(privateKey: privateKey, signingInput: signingInput) + } + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e93c0733fa09..123ea5918a2a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -60,7 +60,6 @@ "@expo/ui": "58.0.20", "@legendapp/list": "catalog:", "@material/material-color-utilities": "0.3.0", - "@noble/curves": "catalog:", "@pierre/diffs": "catalog:", "@pierre/highlights": "catalog:", "@react-native-ai/apple": "0.12.0", diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index ee8dcaaaf352..21e6cf096910 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -4,10 +4,12 @@ import * as NodeCrypto from "node:crypto"; import { vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; +import { DpopProof } from "@t3tools/client-runtime/relay"; +import { verifyDpopProof } from "@t3tools/shared/dpop"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; -import { loadOrCreateDpopProofKeyPair } from "./dpop"; +import { loadOrCreateDpopProofKeyPair, loadOrCreateProofKey } from "./dpop"; import * as Dpop from "./dpop"; vi.mock("expo-crypto", () => ({ @@ -39,6 +41,43 @@ vi.mock("expo-secure-store", () => ({ }, })); +function publicKeyFor(privateKey: Uint8Array) { + const ecdh = NodeCrypto.createECDH("prime256v1"); + ecdh.setPrivateKey(privateKey); + return ecdh.getPublicKey(); +} + +// The native module's contract, backed by Node's P-256. +const nativeDpopKeys = { + generateDpopKey: () => { + const ecdh = NodeCrypto.createECDH("prime256v1"); + const publicKey = ecdh.generateKeys(); + const privateKey = ecdh.getPrivateKey(); + return Promise.resolve( + new Uint8Array(Buffer.concat([Buffer.alloc(32 - privateKey.length), privateKey, publicKey])), + ); + }, + dpopKeyMatches: (privateKey: Uint8Array, publicKey: Uint8Array) => + Promise.resolve(publicKeyFor(privateKey).equals(publicKey)), + signDpop: (privateKey: Uint8Array, signingInput: Uint8Array) => { + const publicKey = publicKeyFor(privateKey); + const key = NodeCrypto.createPrivateKey({ + format: "jwk", + key: { + kty: "EC", + crv: "P-256", + d: Buffer.from(privateKey).toString("base64url"), + x: publicKey.subarray(1, 33).toString("base64url"), + y: publicKey.subarray(33).toString("base64url"), + }, + }); + return Promise.resolve( + new Uint8Array(NodeCrypto.sign("sha256", signingInput, { key, dsaEncoding: "ieee-p1363" })), + ); + }, +}; +vi.mock("expo", () => ({ requireNativeModule: () => nativeDpopKeys })); + describe("mobile DPoP", () => { it.effect("passes typed-array digest input through the Expo Crypto adapter", () => Effect.gen(function* () { @@ -68,4 +107,46 @@ describe("mobile DPoP", () => { expect(error.message).toBe("Stored DPoP proof key is invalid."); }).pipe(Effect.provide(Dpop.layer)), ); + + it.effect("rejects a persisted key whose public half belongs to another key", () => + Effect.gen(function* () { + secureStore.clear(); + const { privateJwk } = yield* loadOrCreateDpopProofKeyPair(); + secureStore.clear(); + const other = yield* loadOrCreateDpopProofKeyPair(); + secureStore.set( + "t3code.cloud.dpop-proof-key", + JSON.stringify({ ...privateJwk, x: other.publicJwk.x, y: other.publicJwk.y }), + ); + + const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); + + expect(error.message).toBe("Stored DPoP proof key is invalid."); + }), + ); + + it.effect("signs proofs that the relay's verifier accepts", () => + Effect.gen(function* () { + secureStore.clear(); + const key = yield* loadOrCreateProofKey(); + const url = "https://relay.t3.codes/v1/environments"; + const proof = yield* DpopProof.createProof({ + method: "GET", + url, + accessToken: "access-token", + key, + }); + + // The proof's `iat` comes from the TestClock, which starts at 0. + expect( + verifyDpopProof({ + proof, + method: "GET", + url, + nowEpochSeconds: 0, + expectedAccessToken: "access-token", + }), + ).toMatchObject({ ok: true }); + }).pipe(Effect.provide(Dpop.layer)), + ); }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index b427861b1e36..9d22fe28e374 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -5,9 +5,9 @@ import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import { requireNativeModule } from "expo"; import * as ExpoCrypto from "expo-crypto"; import * as SecureStore from "expo-secure-store"; -import { p256 } from "@noble/curves/nist"; import { DpopProof } from "@t3tools/client-runtime/relay"; import { DpopPublicJwk } from "@t3tools/shared/dpopCommon"; import * as Layer from "effect/Layer"; @@ -75,28 +75,27 @@ export interface DpopProofKeyPair { const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key"; -function base64UrlToBytes(value: string): Uint8Array { - return Result.getOrThrow(Base64Url.decode(value)); +interface NativeDpopKeys { + /** A 32-byte private scalar followed by its 65-byte uncompressed public point. */ + generateDpopKey(): Promise; + dpopKeyMatches(privateKey: Uint8Array, publicKey: Uint8Array): Promise; + /** ES256 over the signing input, as the raw 64-byte `r‖s` pair. */ + signDpop(privateKey: Uint8Array, signingInput: Uint8Array): Promise; } -function sha256Digest( - data: Uint8Array, - message: string, -): Effect.Effect { - return Digest.Digest.pipe( - Effect.flatMap((service) => service.digest(Digest.sha256, data)), - Effect.mapError(cloudDpopError(message)), - ); +// Platform crypto: in Hermes, @noble/curves blocks JS for about a second on its first P-256 key. +const nativeDpopKeys = () => requireNativeModule("T3NativeControls"); + +function base64UrlToBytes(value: string): Uint8Array { + return Result.getOrThrow(Base64Url.decode(value)); } -function secureRandomBytes( - byteCount: number, - message: string, -): Effect.Effect { - return SecureRandom.SecureRandom.pipe( - Effect.flatMap((random) => random.randomBytes(byteCount)), - Effect.mapError(cloudDpopError(message)), - ); +function uncompressedPublicKeyFromJwk(jwk: DpopPublicJwk): Uint8Array { + const publicKey = new Uint8Array(65); + publicKey[0] = 0x04; + publicKey.set(base64UrlToBytes(jwk.x), 1); + publicKey.set(base64UrlToBytes(jwk.y), 33); + return publicKey; } function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { @@ -119,32 +118,21 @@ function privateJwkFromPrivateKey( } export const generateDpopProofKeyPair = Effect.fn("dpop.generateDpopProofKeyPair")( - function* (): Effect.fn.Return< - DpopProofKeyPair, - CloudDpopError, - SecureRandom.SecureRandom | Digest.Digest - > { - let privateKey: Uint8Array; - do { - privateKey = yield* secureRandomBytes( - p256.CURVE.nByteLength, - "Could not generate DPoP key pair randomness.", - ); - } while (!p256.utils.isValidPrivateKey(privateKey)); + function* (): Effect.fn.Return { + const key = yield* Effect.tryPromise({ + try: () => nativeDpopKeys().generateDpopKey(), + catch: cloudDpopError("Could not generate the DPoP key pair."), + }); const publicJwk = yield* Effect.try({ - try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), + try: () => publicJwkFromUncompressedPublicKey(key.slice(32)), catch: cloudDpopError("Generated DPoP public key is invalid."), }); - return { privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), publicJwk }; + return { privateJwk: privateJwkFromPrivateKey(key.slice(0, 32), publicJwk), publicJwk }; }, ); export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProofKeyPair")( - function* (): Effect.fn.Return< - DpopProofKeyPair, - CloudDpopError, - SecureRandom.SecureRandom | Digest.Digest - > { + function* (): Effect.fn.Return { const stored = yield* Effect.tryPromise({ try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), catch: cloudDpopError("Could not read the DPoP proof key."), @@ -153,20 +141,19 @@ export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProo const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")), ); - const restored = yield* Effect.try({ - try: () => { - const privateKey = base64UrlToBytes(storedPrivateJwk.d); - const publicJwk = publicJwkFromUncompressedPublicKey( - p256.getPublicKey(privateKey, false), - ); - if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) { - throw new Error("Stored DPoP key does not match its public key."); - } - return { privateJwk: storedPrivateJwk, publicJwk }; - }, + const { d, ...publicJwk } = storedPrivateJwk; + const matches = yield* Effect.tryPromise({ + try: () => + nativeDpopKeys().dpopKeyMatches( + base64UrlToBytes(d), + uncompressedPublicKeyFromJwk(publicJwk), + ), catch: cloudDpopError("Stored DPoP proof key is invalid."), }); - return restored; + if (!matches) { + return yield* new CloudDpopError({ message: "Stored DPoP proof key is invalid." }); + } + return { privateJwk: storedPrivateJwk, publicJwk }; } const generated = yield* generateDpopProofKeyPair(); const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( @@ -180,9 +167,8 @@ export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProo }, ); -/** The installation's DPoP key, signing with @noble/curves over an Expo SHA-256 digest. */ +/** The installation's DPoP key, signing with the platform's P-256. */ export const loadOrCreateProofKey = Effect.fn("dpop.loadOrCreateProofKey")(function* () { - const digest = yield* Digest.Digest; const keyPair = yield* loadOrCreateDpopProofKeyPair(); const privateKey = yield* Effect.try({ try: () => base64UrlToBytes(keyPair.privateJwk.d), @@ -191,17 +177,10 @@ export const loadOrCreateProofKey = Effect.fn("dpop.loadOrCreateProofKey")(funct return { publicJwk: keyPair.publicJwk, sign: (signingInput) => - sha256Digest(signingInput, "Could not hash DPoP signing input.").pipe( - Effect.provideService(Digest.Digest, digest), - Effect.flatMap((hash) => - Effect.try({ - try: () => p256.sign(hash, privateKey, { prehash: false }).toCompactRawBytes(), - catch: cloudDpopError("Could not sign DPoP proof."), - }), - ), - Effect.mapError( - (cause) => new DpopProof.DpopProofError({ message: "Could not sign DPoP proof.", cause }), - ), - ), + Effect.tryPromise({ + try: () => nativeDpopKeys().signDpop(privateKey, signingInput), + catch: (cause) => + new DpopProof.DpopProofError({ message: "Could not sign DPoP proof.", cause }), + }), } satisfies DpopProof.DpopProofKey; }); diff --git a/packages/client-runtime/src/relay/dpopProof.ts b/packages/client-runtime/src/relay/dpopProof.ts index fcab78278829..295f58f89125 100644 --- a/packages/client-runtime/src/relay/dpopProof.ts +++ b/packages/client-runtime/src/relay/dpopProof.ts @@ -1,5 +1,5 @@ // RFC 9449 DPoP proofs for relay requests. Platforms supply the key: web keeps a -// non-extractable WebCrypto key, mobile signs with @noble/curves (Hermes has no SubtleCrypto). +// non-extractable WebCrypto key, mobile signs with the platform's P-256 through a native module. import { normalizeDpopHtu, type DpopPublicJwk } from "@t3tools/shared/dpopCommon"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e1dd4d26a46..864f422701ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,9 +548,6 @@ importers: '@material/material-color-utilities': specifier: 0.3.0 version: 0.3.0 - '@noble/curves': - specifier: 'catalog:' - version: 1.9.1 '@pierre/diffs': specifier: 1.6.1 version: 1.6.1(patch_hash=322ac5f5db8b5715189c010802047f8235c3a45d8ed5ca0ba60fac266e2eba34)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) From b2050af8010590005da087b81c596eb10a3bd1c0 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Fri, 6 Nov 2026 11:43:47 +0000 Subject: [PATCH 2/7] fix(mobile): reject stored DPoP keys with non-canonical coordinates The native key check rebuilt the public point from the stored JWK by copying each decoded coordinate into a fixed buffer, so a 31- or 33-byte coordinate could still name the stored point and pass. The old string comparison rejected these keys; require 32 canonical bytes again. --- apps/mobile/src/features/cloud/dpop.test.ts | 26 +++++++++++++++++++++ apps/mobile/src/features/cloud/dpop.ts | 12 ++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index 21e6cf096910..dfbb46f0627e 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -125,6 +125,32 @@ describe("mobile DPoP", () => { }), ); + // X ends in 0x00, so a zero-filled 31-byte X, or a 33-byte X whose extra byte Y overwrites, + // still names the same point. + const privateJwkWithZeroEndedX = { + kty: "EC", + crv: "P-256", + x: "TCE1CTXQ3-B54Fwv9yDlkE3rgcWsJsuGIv2Q9QHcZAA", + y: "zL4Eewo43mYmVjtqh597_dGnKa7lkxxB5XaQk32V9UM", + d: "mzWsJz_1-NerI3_NPtF_hcQhQrXnEITcNEmu5BzaDK4", + }; + const xBytes = Buffer.from(privateJwkWithZeroEndedX.x, "base64url"); + it.effect.each([ + { length: "short", x: xBytes.subarray(0, 31) }, + { length: "long", x: Buffer.concat([xBytes, Buffer.of(7)]) }, + ])("rejects a persisted key with a $length coordinate", ({ x }) => + Effect.gen(function* () { + secureStore.set( + "t3code.cloud.dpop-proof-key", + JSON.stringify({ ...privateJwkWithZeroEndedX, x: x.toString("base64url") }), + ); + + const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); + + expect(error.message).toBe("Stored DPoP proof key is invalid."); + }), + ); + it.effect("signs proofs that the relay's verifier accepts", () => Effect.gen(function* () { secureStore.clear(); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 9d22fe28e374..c1866fdd6ddb 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -90,11 +90,19 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Base64Url.decode(value)); } +function coordinateBytes(value: string): Uint8Array { + const bytes = base64UrlToBytes(value); + if (bytes.length !== 32 || Base64Url.encode(bytes) !== value) { + throw new Error("DPoP key coordinate is not 32 canonical bytes."); + } + return bytes; +} + function uncompressedPublicKeyFromJwk(jwk: DpopPublicJwk): Uint8Array { const publicKey = new Uint8Array(65); publicKey[0] = 0x04; - publicKey.set(base64UrlToBytes(jwk.x), 1); - publicKey.set(base64UrlToBytes(jwk.y), 33); + publicKey.set(coordinateBytes(jwk.x), 1); + publicKey.set(coordinateBytes(jwk.y), 33); return publicKey; } From 6955d1a73eaaeb4ee46b02fba65b6bced133a170 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Fri, 6 Nov 2026 13:16:50 +0000 Subject: [PATCH 3/7] fix(mobile): load expo lazily in the DPoP signer Importing expo at module scope ran its async-require setup in Node tests, so suites that import the relay layer without mocking expo failed to load. Load it when a DPoP key operation runs, as attachmentDownload does, and share the Node-backed native fake with the suites that drive the real signer. --- .../remoteRegistration.test.ts | 6 +++ apps/mobile/src/features/cloud/dpop.test.ts | 41 ++-------------- .../mobile/src/features/cloud/dpop.testing.ts | 48 +++++++++++++++++++ apps/mobile/src/features/cloud/dpop.ts | 13 ++--- .../features/cloud/managedRelayLayer.test.ts | 6 +++ 5 files changed, 72 insertions(+), 42 deletions(-) create mode 100644 apps/mobile/src/features/cloud/dpop.testing.ts diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index e9f5a3b1e539..17eeb92c3cb7 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -128,6 +128,12 @@ vi.mock("expo-crypto", () => ({ }, })); +vi.mock("expo", async () => { + const { createNodeDpopKeysModule } = await import("../cloud/dpop.testing"); + const dpopKeys = createNodeDpopKeysModule(); + return { requireNativeModule: () => dpopKeys }; +}); + vi.mock("expo-secure-store", () => ({ getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null), setItemAsync: (key: string, value: string) => { diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index dfbb46f0627e..2e75dbafbf39 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -41,42 +41,11 @@ vi.mock("expo-secure-store", () => ({ }, })); -function publicKeyFor(privateKey: Uint8Array) { - const ecdh = NodeCrypto.createECDH("prime256v1"); - ecdh.setPrivateKey(privateKey); - return ecdh.getPublicKey(); -} - -// The native module's contract, backed by Node's P-256. -const nativeDpopKeys = { - generateDpopKey: () => { - const ecdh = NodeCrypto.createECDH("prime256v1"); - const publicKey = ecdh.generateKeys(); - const privateKey = ecdh.getPrivateKey(); - return Promise.resolve( - new Uint8Array(Buffer.concat([Buffer.alloc(32 - privateKey.length), privateKey, publicKey])), - ); - }, - dpopKeyMatches: (privateKey: Uint8Array, publicKey: Uint8Array) => - Promise.resolve(publicKeyFor(privateKey).equals(publicKey)), - signDpop: (privateKey: Uint8Array, signingInput: Uint8Array) => { - const publicKey = publicKeyFor(privateKey); - const key = NodeCrypto.createPrivateKey({ - format: "jwk", - key: { - kty: "EC", - crv: "P-256", - d: Buffer.from(privateKey).toString("base64url"), - x: publicKey.subarray(1, 33).toString("base64url"), - y: publicKey.subarray(33).toString("base64url"), - }, - }); - return Promise.resolve( - new Uint8Array(NodeCrypto.sign("sha256", signingInput, { key, dsaEncoding: "ieee-p1363" })), - ); - }, -}; -vi.mock("expo", () => ({ requireNativeModule: () => nativeDpopKeys })); +vi.mock("expo", async () => { + const { createNodeDpopKeysModule } = await import("./dpop.testing"); + const dpopKeys = createNodeDpopKeysModule(); + return { requireNativeModule: () => dpopKeys }; +}); describe("mobile DPoP", () => { it.effect("passes typed-array digest input through the Expo Crypto adapter", () => diff --git a/apps/mobile/src/features/cloud/dpop.testing.ts b/apps/mobile/src/features/cloud/dpop.testing.ts new file mode 100644 index 000000000000..f4848353a5eb --- /dev/null +++ b/apps/mobile/src/features/cloud/dpop.testing.ts @@ -0,0 +1,48 @@ +// @effect-diagnostics nodeBuiltinImport:off +/// + +import * as NodeCrypto from "node:crypto"; + +function publicKeyFor(privateKey: Uint8Array) { + const ecdh = NodeCrypto.createECDH("prime256v1"); + ecdh.setPrivateKey(privateKey); + return ecdh.getPublicKey(); +} + +/** + * Stands in for the T3NativeControls DPoP functions with Node's P-256. + * Return it from a mocked `expo` `requireNativeModule`. + */ +export function createNodeDpopKeysModule() { + return { + generateDpopKey: () => { + const ecdh = NodeCrypto.createECDH("prime256v1"); + const publicKey = ecdh.generateKeys(); + const privateKey = ecdh.getPrivateKey(); + return Promise.resolve( + new Uint8Array( + Buffer.concat([Buffer.alloc(32 - privateKey.length), privateKey, publicKey]), + ), + ); + }, + dpopKeyMatches: (privateKey: Uint8Array, publicKey: Uint8Array) => + Promise.resolve(publicKeyFor(privateKey).equals(publicKey)), + signDpop: (privateKey: Uint8Array, signingInput: Uint8Array) => { + const publicKey = publicKeyFor(privateKey); + const key = NodeCrypto.createPrivateKey({ + format: "jwk", + key: { + kty: "EC", + crv: "P-256", + d: Buffer.from(privateKey).toString("base64url"), + x: publicKey.subarray(1, 33).toString("base64url"), + y: publicKey.subarray(33).toString("base64url"), + }, + }); + // ieee-p1363 is the raw r‖s pair the native functions return. + return Promise.resolve( + new Uint8Array(NodeCrypto.sign("sha256", signingInput, { key, dsaEncoding: "ieee-p1363" })), + ); + }, + }; +} diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index c1866fdd6ddb..533342173937 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -5,7 +5,6 @@ import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import { requireNativeModule } from "expo"; import * as ExpoCrypto from "expo-crypto"; import * as SecureStore from "expo-secure-store"; import { DpopProof } from "@t3tools/client-runtime/relay"; @@ -84,7 +83,9 @@ interface NativeDpopKeys { } // Platform crypto: in Hermes, @noble/curves blocks JS for about a second on its first P-256 key. -const nativeDpopKeys = () => requireNativeModule("T3NativeControls"); +// Imported lazily: loading `expo` at module scope breaks Node tests that import this file. +const nativeDpopKeys = async () => + (await import("expo")).requireNativeModule("T3NativeControls"); function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Base64Url.decode(value)); @@ -128,7 +129,7 @@ function privateJwkFromPrivateKey( export const generateDpopProofKeyPair = Effect.fn("dpop.generateDpopProofKeyPair")( function* (): Effect.fn.Return { const key = yield* Effect.tryPromise({ - try: () => nativeDpopKeys().generateDpopKey(), + try: async () => (await nativeDpopKeys()).generateDpopKey(), catch: cloudDpopError("Could not generate the DPoP key pair."), }); const publicJwk = yield* Effect.try({ @@ -151,8 +152,8 @@ export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProo ); const { d, ...publicJwk } = storedPrivateJwk; const matches = yield* Effect.tryPromise({ - try: () => - nativeDpopKeys().dpopKeyMatches( + try: async () => + (await nativeDpopKeys()).dpopKeyMatches( base64UrlToBytes(d), uncompressedPublicKeyFromJwk(publicJwk), ), @@ -186,7 +187,7 @@ export const loadOrCreateProofKey = Effect.fn("dpop.loadOrCreateProofKey")(funct publicJwk: keyPair.publicJwk, sign: (signingInput) => Effect.tryPromise({ - try: () => nativeDpopKeys().signDpop(privateKey, signingInput), + try: async () => (await nativeDpopKeys()).signDpop(privateKey, signingInput), catch: (cause) => new DpopProof.DpopProofError({ message: "Could not sign DPoP proof.", cause }), }), diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.test.ts b/apps/mobile/src/features/cloud/managedRelayLayer.test.ts index b7de6a355ff2..b9462e2f93a4 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.test.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.test.ts @@ -23,6 +23,12 @@ vi.mock("expo-crypto", () => ({ const secureStore = new Map(); let failNextRead = false; +vi.mock("expo", async () => { + const { createNodeDpopKeysModule } = await import("./dpop.testing"); + const dpopKeys = createNodeDpopKeysModule(); + return { requireNativeModule: () => dpopKeys }; +}); + vi.mock("expo-secure-store", () => ({ getItemAsync: (key: string) => { if (failNextRead) { From c10ba0c202a6fc3c75d6e2eef7f88649c39626ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 21:20:14 -0800 Subject: [PATCH 4/7] fix(mobile): build the DPoP curve spec without the API 26 AlgorithmParameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlgorithmParameters.getInstance("EC") needs Android 8.0, but the app supports API 24. Take the secp256r1 spec from a generated key instead, and cover the signer's raw r‖s output with a JVM test. Co-Authored-By: Claude Opus 5.5 --- .../t3-native-controls/android/build.gradle | 7 ++ .../modules/t3nativecontrols/T3DpopKeys.kt | 17 ++--- .../t3nativecontrols/T3DpopKeysTest.kt | 66 +++++++++++++++++++ 3 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/android/src/test/java/expo/modules/t3nativecontrols/T3DpopKeysTest.kt diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle index 9ecf11f68dbc..2540cd639f19 100644 --- a/apps/mobile/modules/t3-native-controls/android/build.gradle +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -29,4 +29,11 @@ dependencies { implementation 'androidx.activity:activity:1.8.2' implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17' implementation 'androidx.window:window:1.5.1' + testImplementation 'junit:junit:4.13.2' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt index 2f6659d837c1..53eea5694958 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt @@ -1,7 +1,6 @@ package expo.modules.t3nativecontrols import java.math.BigInteger -import java.security.AlgorithmParameters import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.PrivateKey @@ -20,19 +19,17 @@ internal object T3DpopKeys { private const val SCALAR_BYTES = 32 private const val CURVE = "secp256r1" private const val ALGORITHM = "SHA256withECDSA" - private val curve: ECParameterSpec by lazy { - AlgorithmParameters.getInstance("EC").run { - init(ECGenParameterSpec(CURVE)) - getParameterSpec(ECParameterSpec::class.java) - } + // AlgorithmParameters("EC") needs API 26; a generated key carries the same spec on any level. + private val curve: ECParameterSpec by lazy { (generateKeyPair().public as ECPublicKey).params } + + private fun generateKeyPair() = KeyPairGenerator.getInstance("EC").run { + initialize(ECGenParameterSpec(CURVE)) + generateKeyPair() } /** The private scalar followed by the public point. */ fun generate(): ByteArray { - val pair = KeyPairGenerator.getInstance("EC").run { - initialize(ECGenParameterSpec(CURVE)) - generateKeyPair() - } + val pair = generateKeyPair() val point = (pair.public as ECPublicKey).w return scalar((pair.private as ECPrivateKey).s) + byteArrayOf(UNCOMPRESSED) + diff --git a/apps/mobile/modules/t3-native-controls/android/src/test/java/expo/modules/t3nativecontrols/T3DpopKeysTest.kt b/apps/mobile/modules/t3-native-controls/android/src/test/java/expo/modules/t3nativecontrols/T3DpopKeysTest.kt new file mode 100644 index 000000000000..04a44d9e0704 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/test/java/expo/modules/t3nativecontrols/T3DpopKeysTest.kt @@ -0,0 +1,66 @@ +package expo.modules.t3nativecontrols + +import java.math.BigInteger +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.security.AlgorithmParameters +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class T3DpopKeysTest { + @Test + fun signsRawSignaturesThatVerifyAgainstTheGeneratedPublicKey() { + val key = T3DpopKeys.generate() + val privateKey = key.copyOfRange(0, 32) + val publicKey = key.copyOfRange(32, 97) + assertTrue(T3DpopKeys.matches(privateKey, publicKey)) + assertFalse(T3DpopKeys.matches(T3DpopKeys.generate().copyOfRange(0, 32), publicKey)) + + val input = "header.payload".toByteArray() + repeat(32) { + val raw = T3DpopKeys.sign(privateKey, input) + assertEquals(64, raw.size) + val verifier = Signature.getInstance("SHA256withECDSA").apply { + initVerify(jcaPublicKey(publicKey)) + update(input) + } + assertTrue(verifier.verify(der(raw))) + } + } + + @Test + fun convertsShortAndSignPaddedDerIntegersToFixedWidth() { + // r has a 0x00 sign byte (33 bytes); s is a single byte. + val r = byteArrayOf(0) + ByteArray(32) { 0x80.toByte() } + val der = byteArrayOf(0x30, (2 + r.size + 3).toByte(), 0x02, r.size.toByte()) + r + + byteArrayOf(0x02, 0x01, 0x05) + val expected = ByteArray(32) { 0x80.toByte() } + ByteArray(31) + byteArrayOf(5) + assertArrayEquals(expected, T3DpopKeys.rawSignature(der)) + } + + private fun jcaPublicKey(bytes: ByteArray) = KeyFactory.getInstance("EC").generatePublic( + ECPublicKeySpec( + ECPoint(BigInteger(1, bytes.copyOfRange(1, 33)), BigInteger(1, bytes.copyOfRange(33, 65))), + AlgorithmParameters.getInstance("EC").run { + init(ECGenParameterSpec("secp256r1")) + getParameterSpec(ECParameterSpec::class.java) + }, + ), + ) + + private fun der(raw: ByteArray): ByteArray { + fun integer(bytes: ByteArray): ByteArray { + val value = BigInteger(1, bytes).toByteArray() + return byteArrayOf(0x02, value.size.toByte()) + value + } + val body = integer(raw.copyOfRange(0, 32)) + integer(raw.copyOfRange(32, 64)) + return byteArrayOf(0x30, body.size.toByte()) + body + } +} From 82b014440dd617481f3b7d943f440d056e07f70c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 21:57:13 -0800 Subject: [PATCH 5/7] refactor(mobile): name the DPoP key-pair helper apart from the JCA method Co-Authored-By: Claude Opus 5.5 --- .../main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt index 53eea5694958..e3b5960e29d4 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt @@ -20,16 +20,16 @@ internal object T3DpopKeys { private const val CURVE = "secp256r1" private const val ALGORITHM = "SHA256withECDSA" // AlgorithmParameters("EC") needs API 26; a generated key carries the same spec on any level. - private val curve: ECParameterSpec by lazy { (generateKeyPair().public as ECPublicKey).params } + private val curve: ECParameterSpec by lazy { (newKeyPair().public as ECPublicKey).params } - private fun generateKeyPair() = KeyPairGenerator.getInstance("EC").run { + private fun newKeyPair() = KeyPairGenerator.getInstance("EC").run { initialize(ECGenParameterSpec(CURVE)) generateKeyPair() } /** The private scalar followed by the public point. */ fun generate(): ByteArray { - val pair = generateKeyPair() + val pair = newKeyPair() val point = (pair.public as ECPublicKey).w return scalar((pair.private as ECPrivateKey).s) + byteArrayOf(UNCOMPRESSED) + From 92df6fcf9f6753b16f91c10d3ab6c58183ce165b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 22:01:05 -0800 Subject: [PATCH 6/7] style(mobile): blank line before the commented curve declaration Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt index e3b5960e29d4..9dd6bb82abaf 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3DpopKeys.kt @@ -19,6 +19,7 @@ internal object T3DpopKeys { private const val SCALAR_BYTES = 32 private const val CURVE = "secp256r1" private const val ALGORITHM = "SHA256withECDSA" + // AlgorithmParameters("EC") needs API 26; a generated key carries the same spec on any level. private val curve: ECParameterSpec by lazy { (newKeyPair().public as ECPublicKey).params } From 734c43c79fd94e7d70986bfa5f3c059ea1b875b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 22:05:07 -0800 Subject: [PATCH 7/7] style(mobile): keep the native controls definition under detekt's length limit Co-Authored-By: Claude Opus 5.5 --- .../expo/modules/t3nativecontrols/T3NativeControlsModule.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 836295e5f0be..9d41f4f4a7c4 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -27,9 +27,7 @@ class T3NativeControlsModule : Module() { T3DpopKeys.matches(privateKey, publicKey) } - AsyncFunction("signDpop") { privateKey: ByteArray, signingInput: ByteArray -> - T3DpopKeys.sign(privateKey, signingInput) - } + AsyncFunction("signDpop") { key: ByteArray, input: ByteArray -> T3DpopKeys.sign(key, input) } AsyncFunction("openFile") { uri: String, mimeType: String, promise: Promise -> check(filePreviewPromise == null) { "A document viewer is already open." }