/// 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(); 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: "", 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)), ); });