Skip to content
Merged
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
7 changes: 7 additions & 0 deletions apps/mobile/modules/t3-native-controls/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package expo.modules.t3nativecontrols

import java.math.BigInteger
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"

// 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 }

private fun newKeyPair() = KeyPairGenerator.getInstance("EC").run {
initialize(ECGenParameterSpec(CURVE))
generateKeyPair()
}

/** The private scalar followed by the public point. */
fun generate(): ByteArray {
val pair = newKeyPair()
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class T3NativeControlsModule : Module() {
DateFormat.is24HourFormat(context)
}

AsyncFunction("generateDpopKey") { T3DpopKeys.generate() }

AsyncFunction("dpopKeyMatches") { privateKey: ByteArray, publicKey: ByteArray ->
T3DpopKeys.matches(privateKey, publicKey)
}

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." }
val activity = appContext.currentActivity ?: error("The app is not active.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 20 additions & 0 deletions apps/mobile/modules/t3-native-controls/ios/T3DpopKeys.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
78 changes: 77 additions & 1 deletion apps/mobile/src/features/cloud/dpop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -39,6 +41,12 @@ vi.mock("expo-secure-store", () => ({
},
}));

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", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -68,4 +76,72 @@ 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.");
}),
);

// 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();
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)),
);
});
Loading
Loading