302 lines · 9.5 KB · TypeScript
RawDownload
import * as Clock from "effect/Clock";import * as Crypto from "effect/Crypto";import * as Data from "effect/Data";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 { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon";import * as Layer from "effect/Layer"; export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{  readonly message: string;  readonly cause?: unknown;}> {} function cloudDpopError(message: string) {  return (cause: unknown) => new CloudDpopError({ message, cause });} 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,  message: string,): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {  return Crypto.Crypto.pipe(    Effect.flatMap((crypto) => crypto.digest("SHA-256", data)),    Effect.mapError(cloudDpopError(message)),  );} function base64UrlSha256(  data: Uint8Array,  message: string,): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {  return sha256Digest(data, message).pipe(Effect.map(Encoding.encodeBase64Url));} function dpopThumbprintInput(jwk: DpopPublicJwk): string {  return JSON.stringify({    crv: jwk.crv,    kty: jwk.kty,    x: jwk.x,    y: jwk.y,  });} function computeDpopJwkThumbprintEffect(  jwk: DpopPublicJwk,): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {  return base64UrlSha256(    new TextEncoder().encode(dpopThumbprintInput(jwk)),    "Could not hash the DPoP public key thumbprint.",  );} function computeDpopAccessTokenHashEffect(  accessToken: string,): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {  return base64UrlSha256(    new TextEncoder().encode(accessToken),    "Could not hash the DPoP access token.",  );} function secureRandomBytes(  byteCount: number,  message: string,): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {  return Crypto.Crypto.pipe(    Effect.flatMap((crypto) => crypto.randomBytes(byteCount)),    Effect.mapError(cloudDpopError(message)),  );} function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {  if (publicKey.length !== 65 || publicKey[0] !== 0x04) {    throw new Error("Generated DPoP public key is not an uncompressed P-256 point.");  }  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,        "Could not generate DPoP key pair randomness.",      );    } while (!p256.utils.isValidPrivateKey(privateKey));    const publicJwk = yield* Effect.try({      try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),      catch: cloudDpopError("Generated DPoP public key is invalid."),    });    const thumbprint = yield* computeDpopJwkThumbprintEffect(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: cloudDpopError("Could not read the DPoP proof key."),    });    if (stored) {      const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe(        Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")),      );      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 Error("Stored DPoP key does not match its public key.");          }          return { privateJwk: storedPrivateJwk, publicJwk };        },        catch: cloudDpopError("Stored DPoP proof key is invalid."),      });      const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk);      return {        ...restored,        thumbprint,      };    }    const generated = yield* generateDpopProofKeyPair();    const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(      Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")),    );    yield* Effect.tryPromise({      try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk),      catch: cloudDpopError("Could not store the DPoP proof key."),    });    return generated;  });} function normalizeHtu(url: string): Effect.Effect<string, CloudDpopError> {  const normalized = normalizeDpopHtu(url);  return normalized    ? Effect.succeed(normalized)    : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." }));} 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: cloudDpopError("Could not import DPoP private key."),    });    const nowMs = yield* Clock.currentTimeMillis;    const jti = yield* Crypto.Crypto.pipe(      Effect.flatMap((crypto) => crypto.randomUUIDv4),      Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")),    );    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(cloudDpopError("Could not encode DPoP proof header.")),    );    const ath = input.accessToken      ? yield* computeDpopAccessTokenHashEffect(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(cloudDpopError("Could not encode DPoP proof payload.")),    );    const signatureInputHash = yield* sha256Digest(      new TextEncoder().encode(`${header}.${payload}`),      "Could not hash DPoP signing input.",    );    const signature = yield* Effect.try({      try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(),      catch: cloudDpopError("Could not sign DPoP proof."),    });    return {      proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`,      thumbprint: keyPair.thumbprint,    };  });}