From 922123f72bf0a5c6650ffdfeac06c09a6242de5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:54:02 -0700 Subject: [PATCH 1/7] Share redacted DPoP request targets Co-authored-by: codex --- packages/shared/src/dpop.test.ts | 13 +++++++++++++ packages/shared/src/dpop.ts | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts index c4ba298f66c..767a6ecf267 100644 --- a/packages/shared/src/dpop.test.ts +++ b/packages/shared/src/dpop.test.ts @@ -6,6 +6,7 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, normalizeDpopHtu, + redactDpopRequestTarget, type DpopPublicJwk, verifyDpopProof, } from "./dpop.ts"; @@ -41,6 +42,18 @@ function signDpopProof(input: { return `${header}.${payload}.${signature}`; } +describe("redactDpopRequestTarget", () => { + it("retains the scheme, host, port, and path while removing sensitive URL components", () => { + const url = "https://user:password@example.com:8443/oauth/token?code=secret#fragment"; + + assert.equal(redactDpopRequestTarget(url), "https://example.com:8443/oauth/token"); + }); + + it("returns a safe sentinel for invalid input", () => { + assert.equal(redactDpopRequestTarget("not a URL?token=secret"), ""); + }); +}); + describe("verifyDpopProof", () => { const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256", diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 88dcf8e3090..f2dd17516d0 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -88,6 +88,15 @@ export function normalizeDpopHtu(url: string): string | null { } } +export function redactDpopRequestTarget(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + } catch { + return ""; + } +} + export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); } From ef9ed1e31f0138d8d344d2e4aabda7318bdd6e48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:15:02 -0700 Subject: [PATCH 2/7] Share safe URL diagnostics Co-authored-by: codex --- packages/shared/package.json | 4 ++++ packages/shared/src/urlDiagnostics.test.ts | 24 ++++++++++++++++++++++ packages/shared/src/urlDiagnostics.ts | 19 +++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 packages/shared/src/urlDiagnostics.test.ts create mode 100644 packages/shared/src/urlDiagnostics.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 23705178bef..497a7e84f97 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,6 +103,10 @@ "types": "./src/dpopCommon.ts", "import": "./src/dpopCommon.ts" }, + "./urlDiagnostics": { + "types": "./src/urlDiagnostics.ts", + "import": "./src/urlDiagnostics.ts" + }, "./relayAuth": { "types": "./src/relayAuth.ts", "import": "./src/relayAuth.ts" diff --git a/packages/shared/src/urlDiagnostics.test.ts b/packages/shared/src/urlDiagnostics.test.ts new file mode 100644 index 00000000000..b3ff378ee55 --- /dev/null +++ b/packages/shared/src/urlDiagnostics.test.ts @@ -0,0 +1,24 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { getUrlDiagnostics } from "./urlDiagnostics.ts"; + +describe("getUrlDiagnostics", () => { + it("retains only input length, protocol, and hostname for valid URLs", () => { + const input = + "https://user:password@example.com:8443/private/path?access_token=secret#fragment"; + + assert.deepStrictEqual(getUrlDiagnostics(input), { + inputLength: input.length, + protocol: "https:", + hostname: "example.com", + }); + }); + + it("returns only input length for invalid URLs", () => { + const input = "not a URL?access_token=secret"; + + assert.deepStrictEqual(getUrlDiagnostics(input), { + inputLength: input.length, + }); + }); +}); diff --git a/packages/shared/src/urlDiagnostics.ts b/packages/shared/src/urlDiagnostics.ts new file mode 100644 index 00000000000..6705e1ee7b4 --- /dev/null +++ b/packages/shared/src/urlDiagnostics.ts @@ -0,0 +1,19 @@ +export interface UrlDiagnostics { + readonly inputLength: number; + readonly protocol?: string; + readonly hostname?: string; +} + +export function getUrlDiagnostics(input: string): UrlDiagnostics { + const inputLength = input.length; + try { + const url = new URL(input); + return { + inputLength, + protocol: url.protocol, + hostname: url.hostname, + }; + } catch { + return { inputLength }; + } +} From ef7c2d5e8f300533e8421e088f6fc2678509f4c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 04:42:17 -0700 Subject: [PATCH 3/7] refactor(mobile): structure DPoP failures Co-authored-by: codex --- apps/mobile/src/features/cloud/dpop.test.ts | 34 ++- apps/mobile/src/features/cloud/dpop.ts | 229 +++++++++++++++----- 2 files changed, 208 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index 8945d148ee9..a32bf190eb2 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -9,8 +9,11 @@ import * as Effect from "effect/Effect"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import { + CloudDpopProofError, + CloudDpopStorageError, createDpopProof, generateDpopProofKeyPair, + isCloudDpopError, loadOrCreateDpopProofKeyPair, cryptoLayer, } from "./dpop"; @@ -95,7 +98,36 @@ describe("mobile DPoP", () => { const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); - expect(error.message).toBe("Stored DPoP proof key is invalid."); + expect(error).toBeInstanceOf(CloudDpopStorageError); + expect(error).toMatchObject({ + operation: "decode", + storageKey: "t3code.cloud.dpop-proof-key", + }); + expect(error.cause).toMatchObject({ _tag: "SchemaError" }); + expect(error.message).not.toContain(String(error.cause)); + expect(isCloudDpopError(error)).toBe(true); + }).pipe(Effect.provide(cryptoLayer)), + ); + + it.effect("preserves request context and the parser cause for an invalid proof URL", () => + Effect.gen(function* () { + const proofKey = yield* generateDpopProofKeyPair(); + const error = yield* createDpopProof({ + method: "POST", + url: "http://", + proofKey, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CloudDpopProofError); + expect(error).toMatchObject({ + operation: "normalize-url", + method: "POST", + url: "http://", + thumbprint: proofKey.thumbprint, + }); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).not.toContain((error.cause as Error).message); + expect(isCloudDpopError(error)).toBe(true); }).pipe(Effect.provide(cryptoLayer)), ); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 0bd4b7ff1bd..9770ba7011e 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -1,8 +1,8 @@ import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as ExpoCrypto from "expo-crypto"; @@ -12,19 +12,65 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, DpopPublicJwk, - normalizeDpopHtu, } from "@t3tools/shared/dpop"; -import * as Layer from "effect/Layer"; -export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class CloudDpopStorageError extends Schema.TaggedErrorClass()( + "CloudDpopStorageError", + { + operation: Schema.Literals(["read", "decode", "restore", "encode", "write"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile DPoP key storage operation "${this.operation}" failed for key "${this.storageKey}".`; + } +} + +export class CloudDpopKeyError extends Schema.TaggedErrorClass()( + "CloudDpopKeyError", + { + operation: Schema.Literals(["generate-randomness", "derive-public-key"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile DPoP key operation "${this.operation}" failed.`; + } +} -function cloudDpopError(message: string) { - return (cause: unknown) => new CloudDpopError({ message, cause }); +export class CloudDpopProofError extends Schema.TaggedErrorClass()( + "CloudDpopProofError", + { + operation: Schema.Literals([ + "import-private-key", + "generate-id", + "normalize-url", + "encode-header", + "encode-payload", + "hash-signing-input", + "sign", + ]), + method: Schema.String, + url: Schema.String, + normalizedUrl: Schema.optionalKey(Schema.String), + thumbprint: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.url}.`; + } } +export const CloudDpopError = Schema.Union([ + CloudDpopStorageError, + CloudDpopKeyError, + CloudDpopProofError, +]); +export type CloudDpopError = typeof CloudDpopError.Type; +export const isCloudDpopError = Schema.is(CloudDpopError); + const DpopPrivateJwkSchema = Schema.Struct({ ...DpopPublicJwk.fields, d: Schema.String, @@ -97,26 +143,6 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } -function sha256Digest( - data: Uint8Array, - message: string, -): Effect.Effect { - return Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.digest("SHA-256", data)), - Effect.mapError(cloudDpopError(message)), - ); -} - -function secureRandomBytes( - byteCount: number, - message: string, -): Effect.Effect { - return Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomBytes(byteCount)), - Effect.mapError(cloudDpopError(message)), - ); -} - function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { if (publicKey.length !== 65 || publicKey[0] !== 0x04) { throw new Error("Generated DPoP public key is not an uncompressed P-256 point."); @@ -144,14 +170,16 @@ export function generateDpopProofKeyPair(): Effect.Effect< return Effect.gen(function* () { let privateKey: Uint8Array; do { - privateKey = yield* secureRandomBytes( - p256.CURVE.nByteLength, - "Could not generate DPoP key pair randomness.", + privateKey = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomBytes(p256.CURVE.nByteLength)), + Effect.mapError( + (cause) => new CloudDpopKeyError({ operation: "generate-randomness", cause }), + ), ); } while (!p256.utils.isValidPrivateKey(privateKey)); const publicJwk = yield* Effect.try({ try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), - catch: cloudDpopError("Generated DPoP public key is invalid."), + catch: (cause) => new CloudDpopKeyError({ operation: "derive-public-key", cause }), }); const thumbprint = computeDpopJwkThumbprint(publicJwk); return { @@ -170,11 +198,23 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< return Effect.gen(function* () { const stored = yield* Effect.tryPromise({ try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), - catch: cloudDpopError("Could not read the DPoP proof key."), + catch: (cause) => + new CloudDpopStorageError({ + operation: "read", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), }); if (stored) { const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( - Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")), + Effect.mapError( + (cause) => + new CloudDpopStorageError({ + operation: "decode", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), + ), ); const restored = yield* Effect.try({ try: () => { @@ -187,7 +227,12 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< } return { privateJwk: storedPrivateJwk, publicJwk }; }, - catch: cloudDpopError("Stored DPoP proof key is invalid."), + catch: (cause) => + new CloudDpopStorageError({ + operation: "restore", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), }); return { ...restored, @@ -196,23 +241,28 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< } const generated = yield* generateDpopProofKeyPair(); const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( - Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")), + Effect.mapError( + (cause) => + new CloudDpopStorageError({ + operation: "encode", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), + ), ); yield* Effect.tryPromise({ try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk), - catch: cloudDpopError("Could not store the DPoP proof key."), + catch: (cause) => + new CloudDpopStorageError({ + operation: "write", + storageKey: DPOP_PROOF_KEY_STORAGE_KEY, + cause, + }), }); return generated; }); } -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 function createDpopProof(input: { readonly method: string; readonly url: string; @@ -227,21 +277,62 @@ export function createDpopProof(input: { const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); const privateKey = yield* Effect.try({ try: () => base64UrlToBytes(keyPair.privateJwk.d), - catch: cloudDpopError("Could not import DPoP private key."), + catch: (cause) => + new CloudDpopProofError({ + operation: "import-private-key", + method: input.method, + url: input.url, + thumbprint: keyPair.thumbprint, + cause, + }), }); const nowMs = yield* Clock.currentTimeMillis; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "generate-id", + method: input.method, + url: input.url, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); - const htu = yield* normalizeHtu(input.url); + const htu = yield* Effect.try({ + try: () => { + const parsed = new URL(input.url); + parsed.hash = ""; + parsed.search = ""; + return parsed.toString(); + }, + catch: (cause) => + new CloudDpopProofError({ + operation: "normalize-url", + method: input.method, + url: input.url, + thumbprint: keyPair.thumbprint, + cause, + }), + }); const header = yield* encodeDpopJwtHeaderJson({ typ: "dpop+jwt", alg: "ES256", jwk: keyPair.publicJwk, }).pipe( Effect.map(Encoding.encodeBase64Url), - Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "encode-header", + method: input.method, + url: input.url, + normalizedUrl: htu, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; const payload = yield* encodeDpopJwtPayloadJson({ @@ -252,15 +343,45 @@ export function createDpopProof(input: { ...(ath ? { ath } : {}), }).pipe( Effect.map(Encoding.encodeBase64Url), - Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "encode-payload", + method: input.method, + url: input.url, + normalizedUrl: htu, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); - const signatureInputHash = yield* sha256Digest( - new TextEncoder().encode(`${header}.${payload}`), - "Could not hash DPoP signing input.", + const signatureInputHash = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => + crypto.digest("SHA-256", new TextEncoder().encode(`${header}.${payload}`)), + ), + Effect.mapError( + (cause) => + new CloudDpopProofError({ + operation: "hash-signing-input", + method: input.method, + url: input.url, + normalizedUrl: htu, + thumbprint: keyPair.thumbprint, + cause, + }), + ), ); const signature = yield* Effect.try({ try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), - catch: cloudDpopError("Could not sign DPoP proof."), + catch: (cause) => + new CloudDpopProofError({ + operation: "sign", + method: input.method, + url: input.url, + normalizedUrl: htu, + thumbprint: keyPair.thumbprint, + cause, + }), }); return { proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`, From ef61a9b940bb89f2e3fe1b18c75fb6b15ab6a117 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:35:04 -0700 Subject: [PATCH 4/7] fix(mobile): structure DPoP key validation causes Co-authored-by: codex --- apps/mobile/src/features/cloud/dpop.test.ts | 21 ++++++++++++++ apps/mobile/src/features/cloud/dpop.ts | 32 +++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index a32bf190eb2..caba1def5dc 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -12,6 +12,7 @@ import { CloudDpopProofError, CloudDpopStorageError, createDpopProof, + DpopStoredPublicKeyMismatchError, generateDpopProofKeyPair, isCloudDpopError, loadOrCreateDpopProofKeyPair, @@ -109,6 +110,26 @@ describe("mobile DPoP", () => { }).pipe(Effect.provide(cryptoLayer)), ); + it.effect("preserves a structured cause when stored key material does not match", () => + Effect.gen(function* () { + secureStore.clear(); + const generated = yield* generateDpopProofKeyPair(); + secureStore.set( + "t3code.cloud.dpop-proof-key", + JSON.stringify({ ...generated.privateJwk, x: generated.privateJwk.y }), + ); + + const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); + + expect(error).toBeInstanceOf(CloudDpopStorageError); + expect(error).toMatchObject({ + operation: "restore", + storageKey: "t3code.cloud.dpop-proof-key", + }); + expect(error.cause).toBeInstanceOf(DpopStoredPublicKeyMismatchError); + }).pipe(Effect.provide(cryptoLayer)), + ); + it.effect("preserves request context and the parser cause for an invalid proof URL", () => Effect.gen(function* () { const proofKey = yield* generateDpopProofKeyPair(); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 9770ba7011e..8a32c616ad9 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -63,6 +63,31 @@ export class CloudDpopProofError extends Schema.TaggedErrorClass()( + "DpopPublicKeyFormatError", + { + byteLength: Schema.Number, + firstByte: Schema.optional(Schema.Number), + }, +) { + override get message(): string { + const prefix = + this.firstByte === undefined + ? "no prefix byte" + : `prefix 0x${this.firstByte.toString(16).padStart(2, "0")}`; + return `Expected a 65-byte uncompressed P-256 public key beginning with 0x04; received ${this.byteLength} bytes with ${prefix}.`; + } +} + +export class DpopStoredPublicKeyMismatchError extends Schema.TaggedErrorClass()( + "DpopStoredPublicKeyMismatchError", + {}, +) { + override get message(): string { + return "Stored DPoP private and public key material do not match."; + } +} + export const CloudDpopError = Schema.Union([ CloudDpopStorageError, CloudDpopKeyError, @@ -145,7 +170,10 @@ function base64UrlToBytes(value: string): Uint8Array { function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { if (publicKey.length !== 65 || publicKey[0] !== 0x04) { - throw new Error("Generated DPoP public key is not an uncompressed P-256 point."); + throw new DpopPublicKeyFormatError({ + byteLength: publicKey.length, + ...(publicKey[0] === undefined ? {} : { firstByte: publicKey[0] }), + }); } return { kty: "EC", @@ -223,7 +251,7 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< 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."); + throw new DpopStoredPublicKeyMismatchError(); } return { privateJwk: storedPrivateJwk, publicJwk }; }, From a5042526aa5278bc2cca1357547df5e5ea41b230 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:40:37 -0700 Subject: [PATCH 5/7] Redact mobile DPoP error targets Co-authored-by: codex --- apps/mobile/src/features/cloud/dpop.test.ts | 34 ++++++++++++++++- apps/mobile/src/features/cloud/dpop.ts | 42 ++++++++++++++------- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index caba1def5dc..bd5b20f9a81 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -143,7 +143,8 @@ describe("mobile DPoP", () => { expect(error).toMatchObject({ operation: "normalize-url", method: "POST", - url: "http://", + requestTarget: "", + urlLength: "http://".length, thumbprint: proofKey.thumbprint, }); expect(error.cause).toBeInstanceOf(Error); @@ -152,6 +153,37 @@ describe("mobile DPoP", () => { }).pipe(Effect.provide(cryptoLayer)), ); + it.effect("redacts credentials and non-HTU URL components from proof failures", () => + Effect.gen(function* () { + const proofKey = yield* generateDpopProofKeyPair(); + const url = "https://user:password@example.com/oauth/token?access_token=secret#fragment"; + const error = yield* createDpopProof({ + method: "POST", + url, + proofKey: { + ...proofKey, + privateJwk: { ...proofKey.privateJwk, d: "%" }, + }, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(CloudDpopProofError); + expect(error).toMatchObject({ + operation: "import-private-key", + method: "POST", + requestTarget: "https://example.com/oauth/token", + urlLength: url.length, + thumbprint: proofKey.thumbprint, + }); + expect(error.cause).toMatchObject({ _tag: "EncodingError" }); + expect(error).not.toHaveProperty("url"); + expect(error.message).not.toContain("user"); + expect(error.message).not.toContain("password"); + expect(error.message).not.toContain("access_token"); + expect(error.message).not.toContain("secret"); + expect(error.message).not.toContain("fragment"); + }).pipe(Effect.provide(cryptoLayer)), + ); + it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => Effect.gen(function* () { const proofKey = yield* generateDpopProofKeyPair(); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 8a32c616ad9..cb93715b820 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -52,14 +52,14 @@ export class CloudDpopProofError extends Schema.TaggedErrorClass"; + } +} + function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { if (publicKey.length !== 65 || publicKey[0] !== 0x04) { throw new DpopPublicKeyFormatError({ @@ -303,13 +312,16 @@ export function createDpopProof(input: { > { return Effect.gen(function* () { const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); + const requestTarget = redactDpopRequestTarget(input.url); + const urlLength = input.url.length; const privateKey = yield* Effect.try({ try: () => base64UrlToBytes(keyPair.privateJwk.d), catch: (cause) => new CloudDpopProofError({ operation: "import-private-key", method: input.method, - url: input.url, + requestTarget, + urlLength, thumbprint: keyPair.thumbprint, cause, }), @@ -322,7 +334,8 @@ export function createDpopProof(input: { new CloudDpopProofError({ operation: "generate-id", method: input.method, - url: input.url, + requestTarget, + urlLength, thumbprint: keyPair.thumbprint, cause, }), @@ -339,7 +352,8 @@ export function createDpopProof(input: { new CloudDpopProofError({ operation: "normalize-url", method: input.method, - url: input.url, + requestTarget, + urlLength, thumbprint: keyPair.thumbprint, cause, }), @@ -355,8 +369,8 @@ export function createDpopProof(input: { new CloudDpopProofError({ operation: "encode-header", method: input.method, - url: input.url, - normalizedUrl: htu, + requestTarget, + urlLength, thumbprint: keyPair.thumbprint, cause, }), @@ -376,8 +390,8 @@ export function createDpopProof(input: { new CloudDpopProofError({ operation: "encode-payload", method: input.method, - url: input.url, - normalizedUrl: htu, + requestTarget, + urlLength, thumbprint: keyPair.thumbprint, cause, }), @@ -392,8 +406,8 @@ export function createDpopProof(input: { new CloudDpopProofError({ operation: "hash-signing-input", method: input.method, - url: input.url, - normalizedUrl: htu, + requestTarget, + urlLength, thumbprint: keyPair.thumbprint, cause, }), @@ -405,8 +419,8 @@ export function createDpopProof(input: { new CloudDpopProofError({ operation: "sign", method: input.method, - url: input.url, - normalizedUrl: htu, + requestTarget, + urlLength, thumbprint: keyPair.thumbprint, cause, }), From 22e7600f98d1ed7b2d60d3077ffddd3b70285fe0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:56:01 -0700 Subject: [PATCH 6/7] Reuse shared DPoP request redaction Co-authored-by: codex --- apps/mobile/src/features/cloud/dpop.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index cb93715b820..3590ace8073 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -12,6 +12,7 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, DpopPublicJwk, + redactDpopRequestTarget, } from "@t3tools/shared/dpop"; export class CloudDpopStorageError extends Schema.TaggedErrorClass()( @@ -168,15 +169,6 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } -function redactDpopRequestTarget(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; - } catch { - return ""; - } -} - function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { if (publicKey.length !== 65 || publicKey[0] !== 0x04) { throw new DpopPublicKeyFormatError({ From 3ba6a952ae7f4798a4e40d457f344507cd7d278a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:12:07 -0700 Subject: [PATCH 7/7] test(mobile): focus DPoP coverage on behavior Co-authored-by: codex --- apps/mobile/src/features/cloud/dpop.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index bd5b20f9a81..608275ae8fc 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -12,7 +12,6 @@ import { CloudDpopProofError, CloudDpopStorageError, createDpopProof, - DpopStoredPublicKeyMismatchError, generateDpopProofKeyPair, isCloudDpopError, loadOrCreateDpopProofKeyPair, @@ -104,13 +103,11 @@ describe("mobile DPoP", () => { operation: "decode", storageKey: "t3code.cloud.dpop-proof-key", }); - expect(error.cause).toMatchObject({ _tag: "SchemaError" }); - expect(error.message).not.toContain(String(error.cause)); expect(isCloudDpopError(error)).toBe(true); }).pipe(Effect.provide(cryptoLayer)), ); - it.effect("preserves a structured cause when stored key material does not match", () => + it.effect("rejects stored key material whose public coordinates do not match", () => Effect.gen(function* () { secureStore.clear(); const generated = yield* generateDpopProofKeyPair(); @@ -126,11 +123,10 @@ describe("mobile DPoP", () => { operation: "restore", storageKey: "t3code.cloud.dpop-proof-key", }); - expect(error.cause).toBeInstanceOf(DpopStoredPublicKeyMismatchError); }).pipe(Effect.provide(cryptoLayer)), ); - it.effect("preserves request context and the parser cause for an invalid proof URL", () => + it.effect("preserves request context for an invalid proof URL", () => Effect.gen(function* () { const proofKey = yield* generateDpopProofKeyPair(); const error = yield* createDpopProof({ @@ -147,8 +143,6 @@ describe("mobile DPoP", () => { urlLength: "http://".length, thumbprint: proofKey.thumbprint, }); - expect(error.cause).toBeInstanceOf(Error); - expect(error.message).not.toContain((error.cause as Error).message); expect(isCloudDpopError(error)).toBe(true); }).pipe(Effect.provide(cryptoLayer)), ); @@ -174,7 +168,6 @@ describe("mobile DPoP", () => { urlLength: url.length, thumbprint: proofKey.thumbprint, }); - expect(error.cause).toMatchObject({ _tag: "EncodingError" }); expect(error).not.toHaveProperty("url"); expect(error.message).not.toContain("user"); expect(error.message).not.toContain("password");