import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; 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"; import * as SecureStore from "expo-secure-store"; import { p256 } from "@noble/curves/nist"; import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, DpopPublicJwk, redactDpopRequestTarget, } from "@t3tools/shared/dpop"; 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.`; } } 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, requestTarget: Schema.String, urlLength: Schema.Number, thumbprint: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Mobile DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.requestTarget}.`; } } export class DpopPublicKeyFormatError 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, CloudDpopProofError, ]); export type CloudDpopError = typeof CloudDpopError.Type; export const isCloudDpopError = Schema.is(CloudDpopError); const DpopPrivateJwkSchema = Schema.Struct({ ...DpopPublicJwk.fields, d: Schema.String, }); 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: Crypto.DigestAlgorithm, ): ExpoCrypto.CryptoDigestAlgorithm { switch (algorithm) { case "SHA-1": return ExpoCrypto.CryptoDigestAlgorithm.SHA1; case "SHA-256": return ExpoCrypto.CryptoDigestAlgorithm.SHA256; case "SHA-384": return ExpoCrypto.CryptoDigestAlgorithm.SHA384; case "SHA-512": return ExpoCrypto.CryptoDigestAlgorithm.SHA512; } } export const cryptoLayer = Layer.succeed( Crypto.Crypto, Crypto.make({ randomBytes: ExpoCrypto.getRandomBytes, digest: (algorithm, data) => Effect.promise(async () => { const input = new Uint8Array(data.length); input.set(data); return new Uint8Array(await ExpoCrypto.digest(toExpoDigestAlgorithm(algorithm), input)); }), }), ); 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"; function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { if (publicKey.length !== 65 || publicKey[0] !== 0x04) { throw new DpopPublicKeyFormatError({ byteLength: publicKey.length, ...(publicKey[0] === undefined ? {} : { firstByte: publicKey[0] }), }); } return { kty: "EC", crv: "P-256", x: Encoding.encodeBase64Url(publicKey.slice(1, 33)), y: Encoding.encodeBase64Url(publicKey.slice(33, 65)), }; } function privateJwkFromPrivateKey( privateKey: Uint8Array, publicJwk: DpopPublicJwk, ): DpopPrivateJwk { return { ...publicJwk, d: Encoding.encodeBase64Url(privateKey) }; } export function generateDpopProofKeyPair(): Effect.Effect< DpopProofKeyPair, CloudDpopError, Crypto.Crypto > { return Effect.gen(function* () { let privateKey: Uint8Array; do { 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: (cause) => new CloudDpopKeyError({ operation: "derive-public-key", cause }), }); const thumbprint = computeDpopJwkThumbprint(publicJwk); return { privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), publicJwk, thumbprint, }; }); } export function loadOrCreateDpopProofKeyPair(): Effect.Effect< DpopProofKeyPair, CloudDpopError, Crypto.Crypto > { return Effect.gen(function* () { const stored = yield* Effect.tryPromise({ try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), catch: (cause) => new CloudDpopStorageError({ operation: "read", storageKey: DPOP_PROOF_KEY_STORAGE_KEY, cause, }), }); if (stored) { const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( Effect.mapError( (cause) => new CloudDpopStorageError({ operation: "decode", storageKey: DPOP_PROOF_KEY_STORAGE_KEY, cause, }), ), ); const restored = yield* Effect.try({ try: () => { const privateKey = base64UrlToBytes(storedPrivateJwk.d); const publicJwk = publicJwkFromUncompressedPublicKey( p256.getPublicKey(privateKey, false), ); if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) { throw new DpopStoredPublicKeyMismatchError(); } return { privateJwk: storedPrivateJwk, publicJwk }; }, catch: (cause) => new CloudDpopStorageError({ operation: "restore", storageKey: DPOP_PROOF_KEY_STORAGE_KEY, cause, }), }); return { ...restored, thumbprint: computeDpopJwkThumbprint(restored.publicJwk), }; } const generated = yield* generateDpopProofKeyPair(); const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( 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: (cause) => new CloudDpopStorageError({ operation: "write", storageKey: DPOP_PROOF_KEY_STORAGE_KEY, cause, }), }); return generated; }); } export function createDpopProof(input: { readonly method: string; readonly url: string; readonly accessToken?: string; readonly proofKey?: DpopProofKeyPair; }): Effect.Effect< { readonly proof: string; readonly thumbprint: string }, CloudDpopError, Crypto.Crypto > { 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, requestTarget, urlLength, thumbprint: keyPair.thumbprint, cause, }), }); const nowMs = yield* Clock.currentTimeMillis; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.mapError( (cause) => new CloudDpopProofError({ operation: "generate-id", method: input.method, requestTarget, urlLength, thumbprint: keyPair.thumbprint, cause, }), ), ); 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, requestTarget, urlLength, thumbprint: keyPair.thumbprint, cause, }), }); const header = yield* encodeDpopJwtHeaderJson({ typ: "dpop+jwt", alg: "ES256", jwk: keyPair.publicJwk, }).pipe( Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => new CloudDpopProofError({ operation: "encode-header", method: input.method, requestTarget, urlLength, thumbprint: keyPair.thumbprint, cause, }), ), ); const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; const payload = yield* encodeDpopJwtPayloadJson({ htm: input.method.toUpperCase(), htu, jti, iat: Math.floor(nowMs / 1_000), ...(ath ? { ath } : {}), }).pipe( Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => new CloudDpopProofError({ operation: "encode-payload", method: input.method, requestTarget, urlLength, thumbprint: keyPair.thumbprint, cause, }), ), ); 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, requestTarget, urlLength, thumbprint: keyPair.thumbprint, cause, }), ), ); const signature = yield* Effect.try({ try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), catch: (cause) => new CloudDpopProofError({ operation: "sign", method: input.method, requestTarget, urlLength, thumbprint: keyPair.thumbprint, cause, }), }); return { proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`, thumbprint: keyPair.thumbprint, }; }); }