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
9import { verifyDpopProof } from "@t3tools/shared/dpop";9import { verifyDpopProof } from "@t3tools/shared/dpop";
10 10
11import {11import {
12 CloudDpopProofError,
13 CloudDpopStorageError,
12 createDpopProof,14 createDpopProof,
13 generateDpopProofKeyPair,15 generateDpopProofKeyPair,
16 isCloudDpopError,
14 loadOrCreateDpopProofKeyPair,17 loadOrCreateDpopProofKeyPair,
15 cryptoLayer,18 cryptoLayer,
16} from "./dpop";19} from "./dpop";
@@ -95,7 +98,82 @@ describe("mobile DPoP", () => { · 78 unmodified lines
95 98
96 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);99 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
97 100
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");
99 }).pipe(Effect.provide(cryptoLayer)),177 }).pipe(Effect.provide(cryptoLayer)),
100 );178 );
101 179
apps/mobile/src/features/cloud/dpop.ts+211−56
@@ -1,8 +1,8 @@
1import * as Clock from "effect/Clock";1import * as Clock from "effect/Clock";
2import * as Crypto from "effect/Crypto";2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";4import * as Encoding from "effect/Encoding";
5import * as Layer from "effect/Layer";
6import * as Result from "effect/Result";6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import * as ExpoCrypto from "expo-crypto";8import * as ExpoCrypto from "expo-crypto";
@@ -12,19 +12,91 @@ import { · 3 unmodified lines
12 computeDpopAccessTokenHash,12 computeDpopAccessTokenHash,
13 computeDpopJwkThumbprint,13 computeDpopJwkThumbprint,
14 DpopPublicJwk,14 DpopPublicJwk,
15 normalizeDpopHtu,15 redactDpopRequestTarget,
16} from "@t3tools/shared/dpop";16} from "@t3tools/shared/dpop";
17import * as Layer from "effect/Layer";
18 17
19export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{18export class CloudDpopStorageError extends Schema.TaggedErrorClass<CloudDpopStorageError>()(
20 readonly message: string;19 "CloudDpopStorageError",
21 readonly cause?: unknown;20 {
22}> {}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
31export 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
43export 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}
23 66
24function cloudDpopError(message: string) {67export class DpopPublicKeyFormatError extends Schema.TaggedErrorClass<DpopPublicKeyFormatError>()(
25 return (cause: unknown) => new CloudDpopError({ message, cause });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 }
26}81}
27 82
83export 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
92export const CloudDpopError = Schema.Union([
93 CloudDpopStorageError,
94 CloudDpopKeyError,
95 CloudDpopProofError,
96]);
97export type CloudDpopError = typeof CloudDpopError.Type;
98export const isCloudDpopError = Schema.is(CloudDpopError);
99
28const DpopPrivateJwkSchema = Schema.Struct({100const DpopPrivateJwkSchema = Schema.Struct({
29 ...DpopPublicJwk.fields,101 ...DpopPublicJwk.fields,
30 d: Schema.String,102 d: Schema.String,
@@ -97,29 +169,12 @@ function base64UrlToBytes(value: string): Uint8Array { · 66 unmodified lines
97 return Result.getOrThrow(Encoding.decodeBase64Url(value));169 return Result.getOrThrow(Encoding.decodeBase64Url(value));
98}170}
99 171
100function 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
110function 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
120function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {172function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {
121 if (publicKey.length !== 65 || publicKey[0] !== 0x04) {173 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 });
123 }178 }
124 return {179 return {
125 kty: "EC",180 kty: "EC",
@@ -144,14 +199,16 @@ export function generateDpopProofKeyPair(): Effect.Effect< · 18 unmodified lines
144 return Effect.gen(function* () {199 return Effect.gen(function* () {
145 let privateKey: Uint8Array;200 let privateKey: Uint8Array;
146 do {201 do {
147 privateKey = yield* secureRandomBytes(202 privateKey = yield* Crypto.Crypto.pipe(
148 p256.CURVE.nByteLength,203 Effect.flatMap((crypto) => crypto.randomBytes(p256.CURVE.nByteLength)),
149 "Could not generate DPoP key pair randomness.",204 Effect.mapError(
205 (cause) => new CloudDpopKeyError({ operation: "generate-randomness", cause }),
206 ),
150 );207 );
151 } while (!p256.utils.isValidPrivateKey(privateKey));208 } while (!p256.utils.isValidPrivateKey(privateKey));
152 const publicJwk = yield* Effect.try({209 const publicJwk = yield* Effect.try({
153 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),210 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 }),
155 });212 });
156 const thumbprint = computeDpopJwkThumbprint(publicJwk);213 const thumbprint = computeDpopJwkThumbprint(publicJwk);
157 return {214 return {
@@ -170,11 +227,23 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 12 unmodified lines
170 return Effect.gen(function* () {227 return Effect.gen(function* () {
171 const stored = yield* Effect.tryPromise({228 const stored = yield* Effect.tryPromise({
172 try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY),229 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 }),
174 });236 });
175 if (stored) {237 if (stored) {
176 const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe(238 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 ),
178 );247 );
179 const restored = yield* Effect.try({248 const restored = yield* Effect.try({
180 try: () => {249 try: () => {
@@ -183,11 +252,16 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 2 unmodified lines
183 p256.getPublicKey(privateKey, false),252 p256.getPublicKey(privateKey, false),
184 );253 );
185 if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) {254 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();
187 }256 }
188 return { privateJwk: storedPrivateJwk, publicJwk };257 return { privateJwk: storedPrivateJwk, publicJwk };
189 },258 },
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 }),
191 });265 });
192 return {266 return {
193 ...restored,267 ...restored,
@@ -196,23 +270,28 @@ export function loadOrCreateDpopProofKeyPair(): Effect.Effect< · 2 unmodified lines
196 }270 }
197 const generated = yield* generateDpopProofKeyPair();271 const generated = yield* generateDpopProofKeyPair();
198 const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(272 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 ),
200 );281 );
201 yield* Effect.tryPromise({282 yield* Effect.tryPromise({
202 try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk),283 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 }),
204 });290 });
205 return generated;291 return generated;
206 });292 });
207}293}
208 294
209function 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
216export function createDpopProof(input: {295export function createDpopProof(input: {
217 readonly method: string;296 readonly method: string;
218 readonly url: string;297 readonly url: string;
@@ -225,23 +304,69 @@ export function createDpopProof(input: { · 6 unmodified lines
225> {304> {
226 return Effect.gen(function* () {305 return Effect.gen(function* () {
227 const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair());306 const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair());
307 const requestTarget = redactDpopRequestTarget(input.url);
308 const urlLength = input.url.length;
228 const privateKey = yield* Effect.try({309 const privateKey = yield* Effect.try({
229 try: () => base64UrlToBytes(keyPair.privateJwk.d),310 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 }),
231 });320 });
232 const nowMs = yield* Clock.currentTimeMillis;321 const nowMs = yield* Clock.currentTimeMillis;
233 const jti = yield* Crypto.Crypto.pipe(322 const jti = yield* Crypto.Crypto.pipe(
234 Effect.flatMap((crypto) => crypto.randomUUIDv4),323 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 ),
236 );335 );
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 });
238 const header = yield* encodeDpopJwtHeaderJson({353 const header = yield* encodeDpopJwtHeaderJson({
239 typ: "dpop+jwt",354 typ: "dpop+jwt",
240 alg: "ES256",355 alg: "ES256",
241 jwk: keyPair.publicJwk,356 jwk: keyPair.publicJwk,
242 }).pipe(357 }).pipe(
243 Effect.map(Encoding.encodeBase64Url),358 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 ),
245 );370 );
246 const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;371 const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;
247 const payload = yield* encodeDpopJwtPayloadJson({372 const payload = yield* encodeDpopJwtPayloadJson({
@@ -252,15 +377,45 @@ export function createDpopProof(input: { · 4 unmodified lines
252 ...(ath ? { ath } : {}),377 ...(ath ? { ath } : {}),
253 }).pipe(378 }).pipe(
254 Effect.map(Encoding.encodeBase64Url),379 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 ),
256 );391 );
257 const signatureInputHash = yield* sha256Digest(392 const signatureInputHash = yield* Crypto.Crypto.pipe(
258 new TextEncoder().encode(`${header}.${payload}`),393 Effect.flatMap((crypto) =>
259 "Could not hash DPoP signing input.",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 ),
260 );407 );
261 const signature = yield* Effect.try({408 const signature = yield* Effect.try({
262 try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(),409 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 }),
264 });419 });
265 return {420 return {
266 proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`,421 proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`,