diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index fdc9d9a991a73..ee8dcaaaf3520 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -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", () => ({ @@ -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* () { @@ -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)), ); @@ -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)), - ); }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index a9296601256db..c26050dc5d4e4 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -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"; @@ -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")<{ @@ -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": @@ -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"; @@ -110,40 +88,6 @@ function sha256Digest( ); } -function base64UrlSha256( - data: Uint8Array, - message: string, -): Effect.Effect { - 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 { - return base64UrlSha256( - new TextEncoder().encode(dpopThumbprintInput(jwk)), - "Could not hash the DPoP public key thumbprint.", - ); -} - -function computeDpopAccessTokenHashEffect( - accessToken: string, -): Effect.Effect { - return base64UrlSha256( - new TextEncoder().encode(accessToken), - "Could not hash the DPoP access token.", - ); -} - function secureRandomBytes( byteCount: number, message: string, @@ -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 }; }, ); @@ -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( @@ -244,63 +179,28 @@ export const loadOrCreateDpopProofKeyPair = Effect.fn("dpop.loadOrCreateDpopProo }, ); -function normalizeHtu(url: string): Effect.Effect { - 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; }); diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 2ae13197dcca6..e7e51fc569a30 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -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({ diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts deleted file mode 100644 index da869e08522d0..0000000000000 --- a/apps/web/src/cloud/dpop.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { verifyDpopProof } from "@t3tools/shared/dpop"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; -import { vi } from "vite-plus/test"; - -import { createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; -import * as Dpop from "./dpop"; - -describe("browser DPoP proofs", () => { - it.effect("signs relay resource proofs with an access-token hash", () => - Effect.gen(function* () { - vi.stubGlobal("indexedDB", undefined); - yield* TestClock.setTime(1_700_000_000_000); - const proofKey = yield* generateBrowserDpopKey; - const proof = yield* createBrowserDpopProof({ - method: "POST", - url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", - accessToken: "relay-access-token", - proofKey, - }).pipe(Effect.provide(Dpop.layer)); - - expect( - verifyDpopProof({ - proof: proof.proof, - method: "POST", - url: "https://relay.example.test/v1/environments/env-1/connect", - expectedThumbprint: proof.thumbprint, - expectedAccessToken: "relay-access-token", - nowEpochSeconds: 1_700_000_000, - }), - ).toMatchObject({ ok: true }); - }), - ); -}); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 203222303e818..442f00aaacfba 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -1,17 +1,11 @@ -import { - computeDpopAccessTokenHash, - computeDpopJwkThumbprint, - DpopPublicJwk, -} from "@t3tools/shared/dpop"; +import { DpopProof } from "@t3tools/client-runtime/relay"; +import { computeDpopJwkThumbprint, DpopPublicJwk } from "@t3tools/shared/dpop"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Signature from "effect/crypto/Signature"; -import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; -import * as Jwt from "effect/jose/Jwt"; import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; export interface BrowserDpopKey { @@ -56,7 +50,7 @@ function openDpopDatabase(): Effect.Effect { }); } -export function readStoredBrowserDpopKey(): Effect.Effect { +function readStoredBrowserDpopKey(): Effect.Effect { if (typeof indexedDB === "undefined") { return Effect.succeed(null); } @@ -79,9 +73,7 @@ export function readStoredBrowserDpopKey(): Effect.Effect { +function writeStoredBrowserDpopKey(key: BrowserDpopKey): Effect.Effect { if (typeof indexedDB === "undefined") { return Effect.void; } @@ -102,7 +94,7 @@ export function writeStoredBrowserDpopKey( ); } -export const generateBrowserDpopKey = Effect.gen(function* () { +const generateBrowserDpopKey = Effect.gen(function* () { const generated = yield* Effect.tryPromise({ try: () => crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ @@ -140,41 +132,26 @@ export const generateBrowserDpopKey = Effect.gen(function* () { }; }); -export const createBrowserDpopProof = Effect.fn("dpop.createBrowserDpopProof")(function* (input: { - readonly method: string; - readonly url: string; - readonly accessToken?: string; - readonly proofKey: BrowserDpopKey; -}): Effect.fn.Return< - { readonly proof: string; readonly thumbprint: string }, - BrowserDpopError, - SecureRandom.SecureRandom | Signature.Signature -> { - const normalizedUrl = yield* Effect.try({ - try: () => new URL(input.url), - catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), - }); - normalizedUrl.search = ""; - normalizedUrl.hash = ""; - const jti = yield* SecureRandom.SecureRandom.pipe(Effect.flatMap((random) => random.uuidV4())); - const key = yield* Signature.privateKeyFromCryptoKey( +/** The stored key, or a new one persisted on first use, as a DPoP proof key. */ +export const loadOrCreateProofKey = Effect.fn("dpop.loadOrCreateProofKey")(function* () { + const signature = yield* Signature.Signature; + const stored = yield* readStoredBrowserDpopKey(); + const browserKey = stored ?? (yield* generateBrowserDpopKey); + if (stored === null) yield* writeStoredBrowserDpopKey(browserKey); + const privateKey = yield* Signature.privateKeyFromCryptoKey( Signature.ecdsaP256, - input.proofKey.privateKey, + browserKey.privateKey, ).pipe(Effect.mapError((cause) => dpopError("Could not load DPoP proof key.", cause))); - const proof = yield* Jwt.sign( - Schema.Json, - { - htm: input.method.toUpperCase(), - htu: normalizedUrl.toString(), - jti, - iat: Math.floor((yield* Clock.currentTimeMillis) / 1_000), - ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), - }, - { _tag: "PrivateKey", algorithm: "ES256", kid: undefined, key }, - { typ: "dpop+jwt", jwk: input.proofKey.publicJwk }, - ).pipe( - Effect.map(Redacted.value), - Effect.mapError((cause) => dpopError("Could not sign DPoP proof.", cause)), - ); - return { proof, thumbprint: input.proofKey.thumbprint }; + return { + publicJwk: browserKey.publicJwk, + sign: (signingInput) => + signature + .sign(privateKey, signingInput) + .pipe( + Effect.mapError( + (cause) => + new DpopProof.DpopProofError({ message: "Could not sign DPoP proof.", cause }), + ), + ), + } satisfies DpopProof.DpopProofKey; }); diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 9d20528728f56..4295e7773c7cc 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,83 +1,13 @@ -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { DpopProof, ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; -import * as SecureRandom from "effect/crypto/SecureRandom"; -import * as Signature from "effect/crypto/Signature"; -import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Semaphore from "effect/Semaphore"; -import { - createBrowserDpopProof, - generateBrowserDpopKey, - readStoredBrowserDpopKey, - writeStoredBrowserDpopKey, - type BrowserDpopKey, -} from "./dpop"; +import { loadOrCreateProofKey } from "./dpop"; -const layerRelayDpopSigner = Layer.effect( - ManagedRelay.ManagedRelayDpopSigner, - Effect.gen(function* () { - const random = yield* SecureRandom.SecureRandom; - const signature = yield* Signature.Signature; - const keyLoadSemaphore = yield* Semaphore.make(1); - let loadedKey: BrowserDpopKey | null = null; - const loadOrCreateBrowserDpopKey = keyLoadSemaphore.withPermit( - Effect.gen(function* () { - if (loadedKey) { - return loadedKey; - } - const stored = yield* readStoredBrowserDpopKey(); - if (stored) { - loadedKey = stored; - return stored; - } - const generated = yield* generateBrowserDpopKey; - yield* writeStoredBrowserDpopKey(generated); - loadedKey = generated; - return generated; - }), - ); - - return ManagedRelay.ManagedRelayDpopSigner.of({ - thumbprint: loadOrCreateBrowserDpopKey.pipe( - Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError( - (error) => - new ManagedRelay.ManagedRelayDpopKeyLoadError({ - keyStore: "indexed-db", - cause: error, - }), - ), - Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), - ), - createProof: Effect.fn("web.managedRelayDpopSigner.createProof")(function* (input) { - const proofKey = yield* loadOrCreateBrowserDpopKey.pipe( - Effect.mapError( - (error) => - new ManagedRelay.ManagedRelayDpopProofCreationError({ - method: input.method, - url: input.url, - cause: error, - }), - ), - ); - return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( - Effect.provideService(SecureRandom.SecureRandom, random), - Effect.provideService(Signature.Signature, signature), - Effect.map((proof) => proof.proof), - Effect.mapError( - (error) => - new ManagedRelay.ManagedRelayDpopProofCreationError({ - method: input.method, - url: input.url, - cause: error, - }), - ), - ); - }), - }); - }), -); +const layerRelayDpopSigner = DpopProof.layerSigner({ + keyStore: "indexed-db", + loadOrCreateKey: loadOrCreateProofKey(), +}); export const layer = (relayUrl: string) => ManagedRelay.layer({ relayUrl, clientId: RelayWebClientId }).pipe( diff --git a/packages/client-runtime/src/relay/dpopProof.test.ts b/packages/client-runtime/src/relay/dpopProof.test.ts new file mode 100644 index 0000000000000..bced68c504b00 --- /dev/null +++ b/packages/client-runtime/src/relay/dpopProof.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; +import { DpopPublicJwk, verifyDpopProof } from "@t3tools/shared/dpop"; +import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Signature from "effect/crypto/Signature"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import * as DpopProof from "./dpopProof.ts"; + +const decodePublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); + +const layer = Layer.mergeAll(Digest.layerWebCrypto, Signature.layerWebCrypto).pipe( + Layer.provideMerge(SecureRandom.layerWebCrypto), +); + +// Mirrors the web adapter: a non-extractable WebCrypto key signed through Signature. +const webCryptoProofKey = Effect.gen(function* () { + const signature = yield* Signature.Signature; + const pair = yield* Effect.promise( + () => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [ + "sign", + "verify", + ]) as Promise, + ); + const publicJwk = yield* Effect.promise(() => + crypto.subtle.exportKey("jwk", pair.publicKey), + ).pipe(Effect.flatMap(decodePublicJwk), Effect.orDie); + const privateKey = yield* Signature.privateKeyFromCryptoKey( + Signature.ecdsaP256, + pair.privateKey, + ).pipe(Effect.orDie); + return { + publicJwk, + sign: (data) => signature.sign(privateKey, data).pipe(Effect.orDie), + } satisfies DpopProof.DpopProofKey; +}); + +describe("DpopProof", () => { + it.effect("creates proofs the relay verifier accepts, with htu normalized and ath bound", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_700_000_000_000); + const key = yield* webCryptoProofKey; + const thumbprint = yield* DpopProof.thumbprint(key.publicJwk); + const url = "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored"; + const proof = yield* DpopProof.createProof({ + method: "post", + url, + accessToken: "relay-access-token", + key, + }); + + expect( + verifyDpopProof({ + proof, + method: "POST", + url, + expectedThumbprint: thumbprint, + expectedAccessToken: "relay-access-token", + nowEpochSeconds: 1_700_000_000, + }), + ).toMatchObject({ ok: true, thumbprint }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("rejects an unparseable URL before signing", () => + Effect.gen(function* () { + const key = yield* webCryptoProofKey; + const error = yield* Effect.flip( + DpopProof.createProof({ method: "GET", url: "not a url", key }), + ); + expect(error.message).toBe("DPoP URL is invalid."); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/packages/client-runtime/src/relay/dpopProof.ts b/packages/client-runtime/src/relay/dpopProof.ts new file mode 100644 index 0000000000000..7d9822921cd68 --- /dev/null +++ b/packages/client-runtime/src/relay/dpopProof.ts @@ -0,0 +1,111 @@ +// 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). +import { normalizeDpopHtu, type DpopPublicJwk } from "@t3tools/shared/dpopCommon"; +import * as Cache from "effect/Cache"; +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"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Base64Url from "effect/encoding/Base64Url"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import { + ManagedRelayDpopKeyLoadError, + ManagedRelayDpopProofCreationError, + ManagedRelayDpopSigner, +} from "./managedRelay.ts"; + +export class DpopProofError extends Data.TaggedError("DpopProofError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** A P-256 proof key. `sign` returns the raw `r‖s` ES256 signature over the JWS signing input. */ +export interface DpopProofKey { + readonly publicJwk: DpopPublicJwk; + readonly sign: (signingInput: Uint8Array) => Effect.Effect; +} + +const utf8 = (value: string) => new TextEncoder().encode(value); +const encodeSegment = (value: unknown) => Base64Url.encode(utf8(JSON.stringify(value))); + +const sha256Base64Url = (data: Uint8Array) => + Effect.flatMap(Digest.Digest, (digest) => digest.digest(Digest.sha256, data)).pipe( + Effect.map(Base64Url.encode), + ); + +/** RFC 7638 thumbprint: SHA-256 over the required members in lexicographic order. */ +export const thumbprint = (jwk: DpopPublicJwk) => + sha256Base64Url(utf8(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }))); + +export const createProof = Effect.fn("DpopProof.createProof")(function* (input: { + readonly method: string; + readonly url: string; + readonly accessToken?: string | undefined; + readonly key: DpopProofKey; +}) { + const htu = normalizeDpopHtu(input.url); + if (htu === null) return yield* new DpopProofError({ message: "DPoP URL is invalid." }); + const random = yield* SecureRandom.SecureRandom; + const header = encodeSegment({ typ: "dpop+jwt", alg: "ES256", jwk: input.key.publicJwk }); + const payload = encodeSegment({ + htm: input.method.toUpperCase(), + htu, + jti: yield* random.uuidV4(), + iat: Math.floor((yield* Clock.currentTimeMillis) / 1_000), + ...(input.accessToken ? { ath: yield* sha256Base64Url(utf8(input.accessToken)) } : {}), + }); + const signature = yield* input.key.sign(utf8(`${header}.${payload}`)); + return `${header}.${payload}.${Base64Url.encode(signature)}`; +}); + +/** A relay DPoP signer that loads its key once and keeps it; a failed load is retried. */ +export const layerSigner = (options: { + readonly keyStore: ManagedRelayDpopKeyLoadError["keyStore"]; + readonly loadOrCreateKey: Effect.Effect; +}) => + Layer.effect( + ManagedRelayDpopSigner, + Effect.gen(function* () { + const random = yield* SecureRandom.SecureRandom; + const digest = yield* Digest.Digest; + const context = yield* Effect.context(); + const keyCache = yield* Cache.makeWith( + () => + options.loadOrCreateKey.pipe( + Effect.flatMap((key) => + thumbprint(key.publicJwk).pipe(Effect.map((value) => ({ key, thumbprint: value }))), + ), + Effect.provideService(Digest.Digest, digest), + Effect.provideContext(context), + ), + { + capacity: 1, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }, + ); + const loadKey = Cache.get(keyCache, undefined); + return ManagedRelayDpopSigner.of({ + thumbprint: loadKey.pipe( + Effect.map((loaded) => loaded.thumbprint), + Effect.mapError( + (cause) => new ManagedRelayDpopKeyLoadError({ keyStore: options.keyStore, cause }), + ), + Effect.withSpan("managedRelayDpopSigner.loadThumbprint"), + ), + createProof: Effect.fn("managedRelayDpopSigner.createProof")(function* (input) { + const creationError = (cause: unknown) => + new ManagedRelayDpopProofCreationError({ method: input.method, url: input.url, cause }); + const loaded = yield* loadKey.pipe(Effect.mapError(creationError)); + return yield* createProof({ ...input, key: loaded.key }).pipe( + Effect.provideService(SecureRandom.SecureRandom, random), + Effect.provideService(Digest.Digest, digest), + Effect.mapError(creationError), + ); + }), + }); + }), + ); diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts index 09f144368919d..9a6635a5f9b95 100644 --- a/packages/client-runtime/src/relay/index.ts +++ b/packages/client-runtime/src/relay/index.ts @@ -1,4 +1,5 @@ export * as Discovery from "./discovery.ts"; +export * as DpopProof from "./dpopProof.ts"; export * from "./cloudAccountService.ts"; export * from "./errorPresentation.ts"; export * as ManagedRelay from "./managedRelay.ts";