| @@ -1,8 +1,8 @@ |
| 1 | import * as Clock from "effect/Clock"; | 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Crypto from "effect/Crypto"; | 2 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as Data from "effect/Data"; | | |
| 4 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 5 | import * as Encoding from "effect/Encoding"; | 4 | import * as Encoding from "effect/Encoding"; |
| | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Result from "effect/Result"; | 6 | import * as Result from "effect/Result"; |
| 7 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as ExpoCrypto from "expo-crypto"; | 8 | import * 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"; |
| 17 | import * as Layer from "effect/Layer"; | | |
| 18 | | 17 | |
| 19 | export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ | 18 | export 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 | |
| | 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 | } |
| 23 | | 66 | |
| 24 | function cloudDpopError(message: string) { | 67 | export 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 | |
| | 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 | |
| 28 | const DpopPrivateJwkSchema = Schema.Struct({ | 100 | const 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 | |
| 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 | | | |
| 120 | function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { | 172 | function 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 | |
| 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 | | | |
| 216 | export function createDpopProof(input: { | 295 | export 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)}`, |