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 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, normalizeDpopHtu, } from "@t3tools/shared/dpop"; import * as Layer from "effect/Layer"; export class CloudDpopError extends Schema.TaggedErrorClass()("CloudDpopError", { operation: Schema.Literals([ "generate-key-randomness", "derive-public-key", "read-key", "decode-key", "validate-key", "encode-key", "store-key", "normalize-proof-url", "import-private-key", "generate-proof-id", "encode-proof-header", "encode-proof-payload", "hash-signing-input", "sign-proof", ]), cause: Schema.optional(Schema.Defect()), }) { override get message(): string { return `Cloud DPoP operation "${this.operation}" failed.`; } } 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."; } } 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 sha256Digest(data: Uint8Array): Effect.Effect { return Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.digest("SHA-256", data)), Effect.mapError((cause) => new CloudDpopError({ operation: "hash-signing-input", cause })), ); } function secureRandomBytes( byteCount: number, ): Effect.Effect { return Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomBytes(byteCount)), Effect.mapError((cause) => new CloudDpopError({ operation: "generate-key-randomness", cause })), ); } 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* secureRandomBytes(p256.CURVE.nByteLength); } while (!p256.utils.isValidPrivateKey(privateKey)); const publicJwk = yield* Effect.try({ try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), catch: (cause) => new CloudDpopError({ 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 CloudDpopError({ operation: "read-key", cause }), }); if (stored) { const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( Effect.mapError((cause) => new CloudDpopError({ operation: "decode-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 CloudDpopError({ operation: "validate-key", cause }), }); return { ...restored, thumbprint: computeDpopJwkThumbprint(restored.publicJwk), }; } const generated = yield* generateDpopProofKeyPair(); const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( Effect.mapError((cause) => new CloudDpopError({ operation: "encode-key", cause })), ); yield* Effect.tryPromise({ try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk), catch: (cause) => new CloudDpopError({ operation: "store-key", cause }), }); return generated; }); } function normalizeHtu(url: string): Effect.Effect { const normalized = normalizeDpopHtu(url); return normalized ? Effect.succeed(normalized) : Effect.fail(new CloudDpopError({ operation: "normalize-proof-url" })); } 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 privateKey = yield* Effect.try({ try: () => base64UrlToBytes(keyPair.privateJwk.d), catch: (cause) => new CloudDpopError({ operation: "import-private-key", cause }), }); const nowMs = yield* Clock.currentTimeMillis; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.mapError((cause) => new CloudDpopError({ operation: "generate-proof-id", cause })), ); const htu = yield* normalizeHtu(input.url); const header = yield* encodeDpopJwtHeaderJson({ typ: "dpop+jwt", alg: "ES256", jwk: keyPair.publicJwk, }).pipe( Effect.map(Encoding.encodeBase64Url), Effect.mapError((cause) => new CloudDpopError({ operation: "encode-proof-header", 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 CloudDpopError({ operation: "encode-proof-payload", cause })), ); const signatureInputHash = yield* sha256Digest( new TextEncoder().encode(`${header}.${payload}`), ); const signature = yield* Effect.try({ try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), catch: (cause) => new CloudDpopError({ operation: "sign-proof", cause }), }); return { proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`, thumbprint: keyPair.thumbprint, }; }); }