History

[codex] Structure mobile DPoP failures (#3307)

Julius Marminge committed 4 months ago

Commit 249a5ad1 parent d407d902 files · +290 −57Browse files

Unified · Split

2 files
  1. apps/mobile/src/features/cloud/dpop.test.ts +79 −1
  2. apps/mobile/src/features/cloud/dpop.ts +211 −56
apps/mobile/src/features/cloud/dpop.test.ts+79−1
@@ -9,8 +9,11 @@ import * as Effect from "effect/Effect"; · 8 unmodified lines
99 import { verifyDpopProof } from "@t3tools/shared/dpop";
1010
1111 import {
12+ CloudDpopProofError,
13+ CloudDpopStorageError,
1214 createDpopProof,
1315 generateDpopProofKeyPair,
16+ isCloudDpopError,
1417 loadOrCreateDpopProofKeyPair,
1518 cryptoLayer,
1619 } from "./dpop";
@@ -95,7 +98,82 @@ describe("mobile DPoP", () => { · 78 unmodified lines
9598
9699 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
97100
98− expect(error.message).toBe("Stored DPoP proof key is invalid.");
101+ expect(error).toBeInstanceOf(CloudDpopStorageError);
102+ expect(error).toMatchObject({
103+ operation: "decode",
104+ storageKey: "t3code.cloud.dpop-proof-key",
105+ });
106+ expect(isCloudDpopError(error)).toBe(true);
107+ }).pipe(Effect.provide(cryptoLayer)),
108+ );
109+
110+ it.effect("rejects stored key material whose public coordinates do not match", () =>
111+ Effect.gen(function* () {
112+ secureStore.clear();
113+ const generated = yield* generateDpopProofKeyPair();
114+ secureStore.set(
115+ "t3code.cloud.dpop-proof-key",
116+ JSON.stringify({ ...generated.privateJwk, x: generated.privateJwk.y }),
117+ );
118+
119+ const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
120+
121+ expect(error).toBeInstanceOf(CloudDpopStorageError);
122+ expect(error).toMatchObject({
123+ operation: "restore",
124+ storageKey: "t3code.cloud.dpop-proof-key",
125+ });
126+ }).pipe(Effect.provide(cryptoLayer)),
127+ );
128+
129+ it.effect("preserves request context for an invalid proof URL", () =>
130+ Effect.gen(function* () {
131+ const proofKey = yield* generateDpopProofKeyPair();
132+ const error = yield* createDpopProof({
133+ method: "POST",
134+ url: "http://",
135+ proofKey,
136+ }).pipe(Effect.flip);
137+
138+ expect(error).toBeInstanceOf(CloudDpopProofError);
139+ expect(error).toMatchObject({
140+ operation: "normalize-url",
141+ method: "POST",
142+ requestTarget: "<invalid-url>",
143+ urlLength: "http://".length,
144+ thumbprint: proofKey.thumbprint,
145+ });
146+ expect(isCloudDpopError(error)).toBe(true);
147+ }).pipe(Effect.provide(cryptoLayer)),
148+ );
149+
150+ it.effect("redacts credentials and non-HTU URL components from proof failures", () =>
151+ Effect.gen(function* () {
152+ const proofKey = yield* generateDpopProofKeyPair();
153+ const url = "https://user:password@example.com/oauth/token?access_token=secret#fragment";
154+ const error = yield* createDpopProof({
155+ method: "POST",
156+ url,
157+ proofKey: {
158+ ...proofKey,
159+ privateJwk: { ...proofKey.privateJwk, d: "%" },
160+ },
161+ }).pipe(Effect.flip);
162+
163+ expect(error).toBeInstanceOf(CloudDpopProofError);
164+ expect(error).toMatchObject({
165+ operation: "import-private-key",
166+ method: "POST",
167+ requestTarget: "https://example.com/oauth/token",
168+ urlLength: url.length,
169+ thumbprint: proofKey.thumbprint,
170+ });
171+ expect(error).not.toHaveProperty("url");
172+ expect(error.message).not.toContain("user");
173+ expect(error.message).not.toContain("password");
174+ expect(error.message).not.toContain("access_token");
175+ expect(error.message).not.toContain("secret");
176+ expect(error.message).not.toContain("fragment");
99177 }).pipe(Effect.provide(cryptoLayer)),
100178 );
101179
apps/mobile/src/features/cloud/dpop.ts+211−56
@@ -1,8 +1,8 @@
11 import * as Clock from "effect/Clock";
22 import * as Crypto from "effect/Crypto";
3−import * as Data from "effect/Data";
43 import * as Effect from "effect/Effect";
54 import * as Encoding from "effect/Encoding";
5+import * as Layer from "effect/Layer";
66 import * as Result from "effect/Result";
77 import * as Schema from "effect/Schema";
88 import * as ExpoCrypto from "expo-crypto";
@@ -12,19 +12,91 @@ import { · 3 unmodified lines
1212 computeDpopAccessTokenHash,
1313 computeDpopJwkThumbprint,
1414 DpopPublicJwk,
15− normalizeDpopHtu,
15+ redactDpopRequestTarget,
1616 } from "@t3tools/shared/dpop";
17−import * as Layer from "effect/Layer";
1817
19−export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
20− readonly message: string;
21− readonly cause?: unknown;
22−}> {}
18+export class CloudDpopStorageError extends Schema.TaggedErrorClass<CloudDpopStorageError>()(
19+ "CloudDpopStorageError",
20+ {
21+ operation: Schema.Literals(["read", "decode", "restore", "encode", "write"]),
22+ storageKey: Schema.String,
23+ cause: Schema.Defect(),
24+ },
25+) {
26+ override get message(): string {
27+ return `Mobile DPoP key storage operation "${this.operation}" failed for key "${this.storageKey}".`;
28+ }
29+}
30+
31+export class CloudDpopKeyError extends Schema.TaggedErrorClass<CloudDpopKeyError>()(
32+ "CloudDpopKeyError",
33+ {
34+ operation: Schema.Literals(["generate-randomness", "derive-public-key"]),
35+ cause: Schema.Defect(),
36+ },
37+) {
38+ override get message(): string {
39+ return `Mobile DPoP key operation "${this.operation}" failed.`;
40+ }
41+}
42+
43+export class CloudDpopProofError extends Schema.TaggedErrorClass<CloudDpopProofError>()(
44+ "CloudDpopProofError",
45+ {
46+ operation: Schema.Literals([
47+ "import-private-key",
48+ "generate-id",
49+ "normalize-url",
50+ "encode-header",
51+ "encode-payload",
52+ "hash-signing-input",
53+ "sign",
54+ ]),
55+ method: Schema.String,
56+ requestTarget: Schema.String,
57+ urlLength: Schema.Number,
58+ thumbprint: Schema.String,
59+ cause: Schema.Defect(),
60+ },
61+) {
62+ override get message(): string {
63+ return `Mobile DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.requestTarget}.`;
64+ }
65+}
2366
24−function cloudDpopError(message: string) {
25− return (cause: unknown) => new CloudDpopError({ message, cause });
67+export class DpopPublicKeyFormatError extends Schema.TaggedErrorClass<DpopPublicKeyFormatError>()(
68+ "DpopPublicKeyFormatError",
69+ {
70+ byteLength: Schema.Number,
71+ firstByte: Schema.optional(Schema.Number),
72+ },
73+) {
74+ override get message(): string {
75+ const prefix =
76+ this.firstByte === undefined
77+ ? "no prefix byte"
78+ : `prefix 0x${this.firstByte.toString(16).padStart(2, "0")}`;
79+ return `Expected a 65-byte uncompressed P-256 public key beginning with 0x04; received ${this.byteLength} bytes with ${prefix}.`;
80+ }
2681 }
2782
83+export class DpopStoredPublicKeyMismatchError extends Schema.TaggedErrorClass<DpopStoredPublicKeyMismatchError>()(
84+ "DpopStoredPublicKeyMismatchError",
85+ {},
86+) {
87+ override get message(): string {
88+ return "Stored DPoP private and public key material do not match.";
89+ }
90+}
91+
92+export const CloudDpopError = Schema.Union([
93+ CloudDpopStorageError,
94+ CloudDpopKeyError,
95+ CloudDpopProofError,
96+]);
97+export type CloudDpopError = typeof CloudDpopError.Type;
98+export const isCloudDpopError = Schema.is(CloudDpopError);
99+
28100 const DpopPrivateJwkSchema = Schema.Struct({
29101 ...DpopPublicJwk.fields,
30102 d: Schema.String,
@@ -97,29 +169,12 @@ function base64UrlToBytes(value: string): Uint8Array { · 66 unmodified lines
97169 return Result.getOrThrow(Encoding.decodeBase64Url(value));
98170 }
99171
100−function sha256Digest(
101− data: Uint8Array,
102− message: string,
103−): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
104− return Crypto.Crypto.pipe(
105− Effect.flatMap((crypto) => crypto.digest("SHA-256", data)),
106− Effect.mapError(cloudDpopError(message)),
107− );
108−}
109−
110−function secureRandomBytes(
111− byteCount: number,
112− message: string,
113−): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
114− return Crypto.Crypto.pipe(
115− Effect.flatMap((crypto) => crypto.randomBytes(byteCount)),
116− Effect.mapError(cloudDpopError(message)),
117− );
118−}
119−
120172 function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {
121173 if (publicKey.length !== 65 || publicKey[0] !== 0x04) {
122− throw new Error("Generated DPoP public key is not an uncompressed P-256 point.");
174+ throw new DpopPublicKeyFormatError({
175+ byteLength: publicKey.length,
176+ ...(publicKey[0] === undefined ? {} : { firstByte: publicKey[0] }),
177+ });
123178 }
124179 return {
125180 kty: "EC",
@@ -144,14 +199,16 @@ export function generateDpopProofKeyPair(): Effect.Effect< · 18 unmodified lines
144199 return Effect.gen(function* () {
145200 let privateKey: Uint8Array;
146201 do {
147− privateKey = yield* secureRandomBytes(
148− p256.CURVE.nByteLength,
149− "Could not generate DPoP key pair randomness.",
202+ privateKey = yield* Crypto.Crypto.pipe(
203+ Effect.flatMap((crypto) => crypto.randomBytes(p256.CURVE.nByteLength)),
204+ Effect.mapError(
205+ (cause) => new CloudDpopKeyError({ operation: "generate-randomness", cause }),
206+ ),
150207 );
151208 } while (!p256.utils.isValidPrivateKey(privateKey));
152209 const publicJwk = yield* Effect.try({
153210 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
154− catch: cloudDpopError("Generated DPoP public key is invalid."),
211+ catch: (cause) => new CloudDpopKeyError({ operation: "derive-public-key", cause }),
155212 });
156213 const thumbprint = computeDpopJwkThumbprint(publicJwk);
157214 return {
@@ -170,11 +227,23 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 12 unmodified lines
170227 return Effect.gen(function* () {
171228 const stored = yield* Effect.tryPromise({
172229 try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY),
173− catch: cloudDpopError("Could not read the DPoP proof key."),
230+ catch: (cause) =>
231+ new CloudDpopStorageError({
232+ operation: "read",
233+ storageKey: DPOP_PROOF_KEY_STORAGE_KEY,
234+ cause,
235+ }),
174236 });
175237 if (stored) {
176238 const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe(
177− Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")),
239+ Effect.mapError(
240+ (cause) =>
241+ new CloudDpopStorageError({
242+ operation: "decode",
243+ storageKey: DPOP_PROOF_KEY_STORAGE_KEY,
244+ cause,
245+ }),
246+ ),
178247 );
179248 const restored = yield* Effect.try({
180249 try: () => {
@@ -183,11 +252,16 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 2 unmodified lines
183252 p256.getPublicKey(privateKey, false),
184253 );
185254 if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) {
186− throw new Error("Stored DPoP key does not match its public key.");
255+ throw new DpopStoredPublicKeyMismatchError();
187256 }
188257 return { privateJwk: storedPrivateJwk, publicJwk };
189258 },
190− catch: cloudDpopError("Stored DPoP proof key is invalid."),
259+ catch: (cause) =>
260+ new CloudDpopStorageError({
261+ operation: "restore",
262+ storageKey: DPOP_PROOF_KEY_STORAGE_KEY,
263+ cause,
264+ }),
191265 });
192266 return {
193267 ...restored,
@@ -196,23 +270,28 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 2 unmodified lines
196270 }
197271 const generated = yield* generateDpopProofKeyPair();
198272 const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(
199− Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")),
273+ Effect.mapError(
274+ (cause) =>
275+ new CloudDpopStorageError({
276+ operation: "encode",
277+ storageKey: DPOP_PROOF_KEY_STORAGE_KEY,
278+ cause,
279+ }),
280+ ),
200281 );
201282 yield* Effect.tryPromise({
202283 try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk),
203− catch: cloudDpopError("Could not store the DPoP proof key."),
284+ catch: (cause) =>
285+ new CloudDpopStorageError({
286+ operation: "write",
287+ storageKey: DPOP_PROOF_KEY_STORAGE_KEY,
288+ cause,
289+ }),
204290 });
205291 return generated;
206292 });
207293 }
208294
209−function normalizeHtu(url: string): Effect.Effect<string, CloudDpopError> {
210− const normalized = normalizeDpopHtu(url);
211− return normalized
212− ? Effect.succeed(normalized)
213− : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." }));
214−}
215−
216295 export function createDpopProof(input: {
217296 readonly method: string;
218297 readonly url: string;
@@ -225,23 +304,69 @@ export function createDpopProof(input: { · 6 unmodified lines
225304 > {
226305 return Effect.gen(function* () {
227306 const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair());
307+ const requestTarget = redactDpopRequestTarget(input.url);
308+ const urlLength = input.url.length;
228309 const privateKey = yield* Effect.try({
229310 try: () => base64UrlToBytes(keyPair.privateJwk.d),
230− catch: cloudDpopError("Could not import DPoP private key."),
311+ catch: (cause) =>
312+ new CloudDpopProofError({
313+ operation: "import-private-key",
314+ method: input.method,
315+ requestTarget,
316+ urlLength,
317+ thumbprint: keyPair.thumbprint,
318+ cause,
319+ }),
231320 });
232321 const nowMs = yield* Clock.currentTimeMillis;
233322 const jti = yield* Crypto.Crypto.pipe(
234323 Effect.flatMap((crypto) => crypto.randomUUIDv4),
235− Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")),
324+ Effect.mapError(
325+ (cause) =>
326+ new CloudDpopProofError({
327+ operation: "generate-id",
328+ method: input.method,
329+ requestTarget,
330+ urlLength,
331+ thumbprint: keyPair.thumbprint,
332+ cause,
333+ }),
334+ ),
236335 );
237− const htu = yield* normalizeHtu(input.url);
336+ const htu = yield* Effect.try({
337+ try: () => {
338+ const parsed = new URL(input.url);
339+ parsed.hash = "";
340+ parsed.search = "";
341+ return parsed.toString();
342+ },
343+ catch: (cause) =>
344+ new CloudDpopProofError({
345+ operation: "normalize-url",
346+ method: input.method,
347+ requestTarget,
348+ urlLength,
349+ thumbprint: keyPair.thumbprint,
350+ cause,
351+ }),
352+ });
238353 const header = yield* encodeDpopJwtHeaderJson({
239354 typ: "dpop+jwt",
240355 alg: "ES256",
241356 jwk: keyPair.publicJwk,
242357 }).pipe(
243358 Effect.map(Encoding.encodeBase64Url),
244− Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
359+ Effect.mapError(
360+ (cause) =>
361+ new CloudDpopProofError({
362+ operation: "encode-header",
363+ method: input.method,
364+ requestTarget,
365+ urlLength,
366+ thumbprint: keyPair.thumbprint,
367+ cause,
368+ }),
369+ ),
245370 );
246371 const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;
247372 const payload = yield* encodeDpopJwtPayloadJson({
@@ -252,15 +377,45 @@ export function createDpopProof(input: { · 4 unmodified lines
252377 ...(ath ? { ath } : {}),
253378 }).pipe(
254379 Effect.map(Encoding.encodeBase64Url),
255− Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")),
380+ Effect.mapError(
381+ (cause) =>
382+ new CloudDpopProofError({
383+ operation: "encode-payload",
384+ method: input.method,
385+ requestTarget,
386+ urlLength,
387+ thumbprint: keyPair.thumbprint,
388+ cause,
389+ }),
390+ ),
256391 );
257− const signatureInputHash = yield* sha256Digest(
258− new TextEncoder().encode(`${header}.${payload}`),
259− "Could not hash DPoP signing input.",
392+ const signatureInputHash = yield* Crypto.Crypto.pipe(
393+ Effect.flatMap((crypto) =>
394+ crypto.digest("SHA-256", new TextEncoder().encode(`${header}.${payload}`)),
395+ ),
396+ Effect.mapError(
397+ (cause) =>
398+ new CloudDpopProofError({
399+ operation: "hash-signing-input",
400+ method: input.method,
401+ requestTarget,
402+ urlLength,
403+ thumbprint: keyPair.thumbprint,
404+ cause,
405+ }),
406+ ),
260407 );
261408 const signature = yield* Effect.try({
262409 try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(),
263− catch: cloudDpopError("Could not sign DPoP proof."),
410+ catch: (cause) =>
411+ new CloudDpopProofError({
412+ operation: "sign",
413+ method: input.method,
414+ requestTarget,
415+ urlLength,
416+ thumbprint: keyPair.thumbprint,
417+ cause,
418+ }),
264419 });
265420 return {
266421 proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`,