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
91 changes: 1 addition & 90 deletions apps/mobile/src/features/cloud/dpop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import { vi } from "vite-plus/test";
import { describe, expect, it } from "@effect/vitest";
import * as Digest from "effect/crypto/Digest";
import * as Effect from "effect/Effect";
import { verifyDpopProof } from "@t3tools/shared/dpop";

import { createDpopProof, generateDpopProofKeyPair, loadOrCreateDpopProofKeyPair } from "./dpop";
import { loadOrCreateDpopProofKeyPair } from "./dpop";
import * as Dpop from "./dpop";

vi.mock("expo-crypto", () => ({
Expand Down Expand Up @@ -40,28 +39,6 @@ vi.mock("expo-secure-store", () => ({
},
}));

function proofIat(proof: string): number {
const payload = proof.split(".")[1];
if (!payload) {
throw new Error("Missing DPoP payload.");
}
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
readonly iat: number;
};
return decoded.iat;
}

function proofHtu(proof: string): string {
const payload = proof.split(".")[1];
if (!payload) {
throw new Error("Missing DPoP payload.");
}
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
readonly htu: string;
};
return decoded.htu;
}

describe("mobile DPoP", () => {
it.effect("passes typed-array digest input through the Expo Crypto adapter", () =>
Effect.gen(function* () {
Expand All @@ -78,7 +55,6 @@ describe("mobile DPoP", () => {
const first = yield* loadOrCreateDpopProofKeyPair();
const second = yield* loadOrCreateDpopProofKeyPair();

expect(second.thumbprint).toBe(first.thumbprint);
expect(second.privateJwk).toEqual(first.privateJwk);
}).pipe(Effect.provide(Dpop.layer)),
);
Expand All @@ -92,69 +68,4 @@ describe("mobile DPoP", () => {
expect(error.message).toBe("Stored DPoP proof key is invalid.");
}).pipe(Effect.provide(Dpop.layer)),
);

it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () =>
Effect.gen(function* () {
const proofKey = yield* generateDpopProofKeyPair();
const connect = yield* createDpopProof({
method: "POST",
url: "https://relay.example.test/v1/environments/env-1/connect",
accessToken: "clerk-token",
proofKey,
});
const bootstrap = yield* createDpopProof({
method: "POST",
url: "https://desktop.example.test/oauth/token",
proofKey,
});

expect(connect.thumbprint).toBe(proofKey.thumbprint);
expect(bootstrap.thumbprint).toBe(proofKey.thumbprint);
expect(
verifyDpopProof({
proof: connect.proof,
method: "POST",
url: "https://relay.example.test/v1/environments/env-1/connect",
expectedThumbprint: proofKey.thumbprint,
expectedAccessToken: "clerk-token",
nowEpochSeconds: proofIat(connect.proof),
}),
).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });
expect(
verifyDpopProof({
proof: bootstrap.proof,
method: "POST",
url: "https://desktop.example.test/oauth/token",
expectedThumbprint: proofKey.thumbprint,
nowEpochSeconds: proofIat(bootstrap.proof),
}),
).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });
}).pipe(Effect.provide(Dpop.layer)),
);

it.effect("signs DPoP proofs with RFC 9449 htu normalization", () =>
Effect.gen(function* () {
const proofKey = yield* generateDpopProofKeyPair();
const proof = yield* createDpopProof({
method: "POST",
url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",
accessToken: "clerk-token",
proofKey,
});

expect(proofHtu(proof.proof)).toBe(
"https://relay.example.test/v1/environments/env-1/connect",
);
expect(
verifyDpopProof({
proof: proof.proof,
method: "POST",
url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",
expectedThumbprint: proofKey.thumbprint,
expectedAccessToken: "clerk-token",
nowEpochSeconds: proofIat(proof.proof),
}),
).toMatchObject({ ok: true });
}).pipe(Effect.provide(Dpop.layer)),
);
});
146 changes: 23 additions & 123 deletions apps/mobile/src/features/cloud/dpop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as Clock from "effect/Clock";
import * as Digest from "effect/crypto/Digest";
import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Data from "effect/Data";
Expand All @@ -10,7 +9,8 @@ import * as Schema from "effect/Schema";
import * as ExpoCrypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import { p256 } from "@noble/curves/nist";
import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon";
import { DpopProof } from "@t3tools/client-runtime/relay";
import { DpopPublicJwk } from "@t3tools/shared/dpopCommon";
import * as Layer from "effect/Layer";

export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
Expand All @@ -31,27 +31,6 @@ const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema);
const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson);
const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson);

const DpopJwtHeaderJson = Schema.fromJsonString(
Schema.Struct({
typ: Schema.Literal("dpop+jwt"),
alg: Schema.Literal("ES256"),
jwk: DpopPublicJwk,
}),
);

const DpopJwtPayloadJson = Schema.fromJsonString(
Schema.Struct({
htm: Schema.String,
htu: Schema.String,
jti: Schema.String,
iat: Schema.Int,
ath: Schema.optionalKey(Schema.String),
}),
);

const encodeDpopJwtHeaderJson = Schema.encodeEffect(DpopJwtHeaderJson);
const encodeDpopJwtPayloadJson = Schema.encodeEffect(DpopJwtPayloadJson);

function toExpoDigestAlgorithm(algorithm: Digest.Algorithm): ExpoCrypto.CryptoDigestAlgorithm {
switch (algorithm.name) {
case "MD5":
Expand Down Expand Up @@ -91,7 +70,6 @@ type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type;
export interface DpopProofKeyPair {
readonly privateJwk: DpopPrivateJwk;
readonly publicJwk: DpopPublicJwk;
readonly thumbprint: string;
}

const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key";
Expand All @@ -110,40 +88,6 @@ function sha256Digest(
);
}

function base64UrlSha256(
data: Uint8Array,
message: string,
): Effect.Effect<string, CloudDpopError, Digest.Digest> {
return sha256Digest(data, message).pipe(Effect.map(Base64Url.encode));
}

function dpopThumbprintInput(jwk: DpopPublicJwk): string {
return JSON.stringify({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
});
}

function computeDpopJwkThumbprintEffect(
jwk: DpopPublicJwk,
): Effect.Effect<string, CloudDpopError, Digest.Digest> {
return base64UrlSha256(
new TextEncoder().encode(dpopThumbprintInput(jwk)),
"Could not hash the DPoP public key thumbprint.",
);
}

function computeDpopAccessTokenHashEffect(
accessToken: string,
): Effect.Effect<string, CloudDpopError, Digest.Digest> {
return base64UrlSha256(
new TextEncoder().encode(accessToken),
"Could not hash the DPoP access token.",
);
}

function secureRandomBytes(
byteCount: number,
message: string,
Expand Down Expand Up @@ -190,12 +134,7 @@ export const generateDpopProofKeyPair = Effect.fn("dpop.generateDpopProofKeyPair
try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
catch: cloudDpopError("Generated DPoP public key is invalid."),
});
const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk);
return {
privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),
publicJwk,
thumbprint,
};
return { privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), publicJwk };
},
);

Expand Down Expand Up @@ -226,11 +165,7 @@ export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProo
},
catch: cloudDpopError("Stored DPoP proof key is invalid."),
});
const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk);
return {
...restored,
thumbprint,
};
return restored;
}
const generated = yield* generateDpopProofKeyPair();
const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(
Expand All @@ -244,63 +179,28 @@ export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProo
},
);

function normalizeHtu(url: string): Effect.Effect<string, CloudDpopError> {
const normalized = normalizeDpopHtu(url);
return normalized
? Effect.succeed(normalized)
: Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." }));
}

export const createDpopProof = Effect.fn("dpop.createDpopProof")(function* (input: {
readonly method: string;
readonly url: string;
readonly accessToken?: string;
readonly proofKey?: DpopProofKeyPair;
}): Effect.fn.Return<
{ readonly proof: string; readonly thumbprint: string },
CloudDpopError,
SecureRandom.SecureRandom | Digest.Digest
> {
const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair());
/** The installation's DPoP key, signing with @noble/curves over an Expo SHA-256 digest. */
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),
catch: cloudDpopError("Could not import DPoP private key."),
});
const nowMs = yield* Clock.currentTimeMillis;
const jti = yield* SecureRandom.SecureRandom.pipe(
Effect.flatMap((random) => random.uuidV4()),
Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")),
);
const htu = yield* normalizeHtu(input.url);
const header = yield* encodeDpopJwtHeaderJson({
typ: "dpop+jwt",
alg: "ES256",
jwk: keyPair.publicJwk,
}).pipe(
Effect.map(Base64Url.encode),
Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
);
const ath = input.accessToken ? yield* computeDpopAccessTokenHashEffect(input.accessToken) : null;
const payload = yield* encodeDpopJwtPayloadJson({
htm: input.method.toUpperCase(),
htu,
jti,
iat: Math.floor(nowMs / 1_000),
...(ath ? { ath } : {}),
}).pipe(
Effect.map(Base64Url.encode),
Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")),
);
const signatureInputHash = yield* sha256Digest(
new TextEncoder().encode(`${header}.${payload}`),
"Could not hash DPoP signing input.",
);
const signature = yield* Effect.try({
try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(),
catch: cloudDpopError("Could not sign DPoP proof."),
});
return {
proof: `${header}.${payload}.${Base64Url.encode(signature)}`,
thumbprint: keyPair.thumbprint,
};
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 }),
),
),
} satisfies DpopProof.DpopProofKey;
});
71 changes: 6 additions & 65 deletions apps/mobile/src/features/cloud/managedRelayLayer.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,14 @@
import { ManagedRelay } from "@t3tools/client-runtime/relay";
import { DpopProof, ManagedRelay } from "@t3tools/client-runtime/relay";
import { RelayMobileClientId } from "@t3tools/contracts/relay";
import * as Cache from "effect/Cache";
import * as Digest from "effect/crypto/Digest";
import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";

import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
import { loadOrCreateProofKey } from "./dpop";
import { managedRelayAccessTokenStore } from "./managedRelayTokenStore";

const layerRelayDpopSigner = Layer.effect(
ManagedRelay.ManagedRelayDpopSigner,
Effect.gen(function* () {
const random = yield* SecureRandom.SecureRandom;
const digest = yield* Digest.Digest;
const proofKeyCache = yield* Cache.makeWith(
() =>
loadOrCreateDpopProofKeyPair().pipe(
Effect.provideService(SecureRandom.SecureRandom, random),
Effect.provideService(Digest.Digest, digest),
),
{
capacity: 1,
timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
},
);
const loadProofKey = Cache.get(proofKeyCache, undefined);
return ManagedRelay.ManagedRelayDpopSigner.of({
thumbprint: loadProofKey.pipe(
Effect.map((proofKey) => proofKey.thumbprint),
Effect.mapError(
(error) =>
new ManagedRelay.ManagedRelayDpopKeyLoadError({
keyStore: "expo-secure-store",
cause: error,
}),
),
Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"),
),
createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")(function* (input) {
const proofKey = yield* loadProofKey.pipe(
Effect.mapError(
(error) =>
new ManagedRelay.ManagedRelayDpopProofCreationError({
method: input.method,
url: input.url,
cause: error,
}),
),
);
return yield* createDpopProof({ ...input, proofKey }).pipe(
Effect.provideService(SecureRandom.SecureRandom, random),
Effect.provideService(Digest.Digest, digest),
Effect.map((proof) => proof.proof),
Effect.mapError(
(error) =>
new ManagedRelay.ManagedRelayDpopProofCreationError({
method: input.method,
url: input.url,
cause: error,
}),
),
);
}),
});
}),
);
const layerRelayDpopSigner = DpopProof.layerSigner({
keyStore: "expo-secure-store",
loadOrCreateKey: loadOrCreateProofKey(),
});

export const layer = (relayUrl: string) =>
ManagedRelay.layer({
Expand Down
Loading
Loading