244 lines · 8.2 KB · TypeScript
RawDownload
/// <reference types="node" /> import * as NodeCrypto from "node:crypto"; import { vi } from "vite-plus/test";import { describe, expect, it } from "@effect/vitest";import * as Crypto from "effect/Crypto";import * as Effect from "effect/Effect";import { verifyDpopProof } from "@t3tools/shared/dpop"; import {  CloudDpopProofError,  CloudDpopStorageError,  createDpopProof,  generateDpopProofKeyPair,  isCloudDpopError,  loadOrCreateDpopProofKeyPair,  cryptoLayer,} from "./dpop"; vi.mock("expo-crypto", () => ({  CryptoDigestAlgorithm: {    SHA1: "SHA-1",    SHA256: "SHA-256",    SHA384: "SHA-384",    SHA512: "SHA-512",  },  getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)),  getRandomBytesAsync: (byteCount: number) =>    Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))),  digest: (algorithm: string, data: unknown) => {    if (!(data instanceof Uint8Array)) {      return Promise.reject(new TypeError("expo-crypto digest data must be a typed array."));    }    return Promise.resolve(      new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer,    );  },})); const secureStore = new Map<string, string>();vi.mock("expo-secure-store", () => ({  getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null),  setItemAsync: (key: string, value: string) => {    secureStore.set(key, value);    return Promise.resolve();  },})); function proofIat(proof: string): number {  const payload = proof.split(".")[1];  if (!payload) {    throw new Error("Missing DPoP payload.");  }  const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {    readonly iat: number;  };  return decoded.iat;} function proofHtu(proof: string): string {  const payload = proof.split(".")[1];  if (!payload) {    throw new Error("Missing DPoP payload.");  }  const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {    readonly htu: string;  };  return decoded.htu;} describe("mobile DPoP", () => {  it.effect("passes typed-array digest input through the Expo Crypto adapter", () =>    Effect.gen(function* () {      const crypto = yield* Crypto.Crypto;      const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode("typed-array"));       expect(Buffer.from(digest).toString("hex")).toBe(        NodeCrypto.createHash("sha256").update("typed-array").digest("hex"),      );    }).pipe(Effect.provide(cryptoLayer)),  );   it.effect("persists and reuses the installation proof key", () =>    Effect.gen(function* () {      secureStore.clear();      const first = yield* loadOrCreateDpopProofKeyPair();      const second = yield* loadOrCreateDpopProofKeyPair();       expect(second.thumbprint).toBe(first.thumbprint);      expect(second.privateJwk).toEqual(first.privateJwk);    }).pipe(Effect.provide(cryptoLayer)),  );   it.effect("rejects malformed persisted proof keys", () =>    Effect.gen(function* () {      secureStore.set("t3code.cloud.dpop-proof-key", `{"kty":"EC","crv":"P-256","d":42}`);       const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);       expect(error).toBeInstanceOf(CloudDpopStorageError);      expect(error).toMatchObject({        operation: "decode",        storageKey: "t3code.cloud.dpop-proof-key",      });      expect(isCloudDpopError(error)).toBe(true);    }).pipe(Effect.provide(cryptoLayer)),  );   it.effect("rejects stored key material whose public coordinates do not match", () =>    Effect.gen(function* () {      secureStore.clear();      const generated = yield* generateDpopProofKeyPair();      secureStore.set(        "t3code.cloud.dpop-proof-key",        JSON.stringify({ ...generated.privateJwk, x: generated.privateJwk.y }),      );       const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);       expect(error).toBeInstanceOf(CloudDpopStorageError);      expect(error).toMatchObject({        operation: "restore",        storageKey: "t3code.cloud.dpop-proof-key",      });    }).pipe(Effect.provide(cryptoLayer)),  );   it.effect("preserves request context for an invalid proof URL", () =>    Effect.gen(function* () {      const proofKey = yield* generateDpopProofKeyPair();      const error = yield* createDpopProof({        method: "POST",        url: "http://",        proofKey,      }).pipe(Effect.flip);       expect(error).toBeInstanceOf(CloudDpopProofError);      expect(error).toMatchObject({        operation: "normalize-url",        method: "POST",        requestTarget: "<invalid-url>",        urlLength: "http://".length,        thumbprint: proofKey.thumbprint,      });      expect(isCloudDpopError(error)).toBe(true);    }).pipe(Effect.provide(cryptoLayer)),  );   it.effect("redacts credentials and non-HTU URL components from proof failures", () =>    Effect.gen(function* () {      const proofKey = yield* generateDpopProofKeyPair();      const url = "https://user:password@example.com/oauth/token?access_token=secret#fragment";      const error = yield* createDpopProof({        method: "POST",        url,        proofKey: {          ...proofKey,          privateJwk: { ...proofKey.privateJwk, d: "%" },        },      }).pipe(Effect.flip);       expect(error).toBeInstanceOf(CloudDpopProofError);      expect(error).toMatchObject({        operation: "import-private-key",        method: "POST",        requestTarget: "https://example.com/oauth/token",        urlLength: url.length,        thumbprint: proofKey.thumbprint,      });      expect(error).not.toHaveProperty("url");      expect(error.message).not.toContain("user");      expect(error.message).not.toContain("password");      expect(error.message).not.toContain("access_token");      expect(error.message).not.toContain("secret");      expect(error.message).not.toContain("fragment");    }).pipe(Effect.provide(cryptoLayer)),  );   it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () =>    Effect.gen(function* () {      const proofKey = yield* generateDpopProofKeyPair();      const connect = yield* createDpopProof({        method: "POST",        url: "https://relay.example.test/v1/environments/env-1/connect",        accessToken: "clerk-token",        proofKey,      });      const bootstrap = yield* createDpopProof({        method: "POST",        url: "https://desktop.example.test/oauth/token",        proofKey,      });       expect(connect.thumbprint).toBe(proofKey.thumbprint);      expect(bootstrap.thumbprint).toBe(proofKey.thumbprint);      expect(        verifyDpopProof({          proof: connect.proof,          method: "POST",          url: "https://relay.example.test/v1/environments/env-1/connect",          expectedThumbprint: proofKey.thumbprint,          expectedAccessToken: "clerk-token",          nowEpochSeconds: proofIat(connect.proof),        }),      ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });      expect(        verifyDpopProof({          proof: bootstrap.proof,          method: "POST",          url: "https://desktop.example.test/oauth/token",          expectedThumbprint: proofKey.thumbprint,          nowEpochSeconds: proofIat(bootstrap.proof),        }),      ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });    }).pipe(Effect.provide(cryptoLayer)),  );   it.effect("signs DPoP proofs with RFC 9449 htu normalization", () =>    Effect.gen(function* () {      const proofKey = yield* generateDpopProofKeyPair();      const proof = yield* createDpopProof({        method: "POST",        url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",        accessToken: "clerk-token",        proofKey,      });       expect(proofHtu(proof.proof)).toBe(        "https://relay.example.test/v1/environments/env-1/connect",      );      expect(        verifyDpopProof({          proof: proof.proof,          method: "POST",          url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",          expectedThumbprint: proofKey.thumbprint,          expectedAccessToken: "clerk-token",          nowEpochSeconds: proofIat(proof.proof),        }),      ).toMatchObject({ ok: true });    }).pipe(Effect.provide(cryptoLayer)),  );});