Open History to see the last commit
1/// <reference types="node" />
2
3import * as NodeCrypto from "node:crypto";
4
5import { vi } from "vite-plus/test";
6import { describe, expect, it } from "@effect/vitest";
7import { DpopProof } from "@t3tools/client-runtime/relay";
8import { verifyDpopProof } from "@t3tools/shared/dpop";
9import * as Digest from "effect/crypto/Digest";
10import * as Effect from "effect/Effect";
11
12import { loadOrCreateDpopProofKeyPair, loadOrCreateProofKey } from "./dpop";
13import * as Dpop from "./dpop";
14
15vi.mock("expo-crypto", () => ({
16 CryptoDigestAlgorithm: {
17 SHA1: "SHA-1",
18 SHA256: "SHA-256",
19 SHA384: "SHA-384",
20 SHA512: "SHA-512",
21 },
22 getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)),
23 getRandomBytesAsync: (byteCount: number) =>
24 Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))),
25 digest: (algorithm: string, data: unknown) => {
26 if (!(data instanceof Uint8Array)) {
27 return Promise.reject(new TypeError("expo-crypto digest data must be a typed array."));
28 }
29 return Promise.resolve(
30 new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer,
31 );
32 },
33}));
34
35const secureStore = new Map<string, string>();
36vi.mock("expo-secure-store", () => ({
37 getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null),
38 setItemAsync: (key: string, value: string) => {
39 secureStore.set(key, value);
40 return Promise.resolve();
41 },
42}));
43
44vi.mock("expo", async () => {
45 const { createNodeDpopKeysModule } = await import("./dpop.testing");
46 const dpopKeys = createNodeDpopKeysModule();
47 return { requireNativeModule: () => dpopKeys };
48});
49
50describe("mobile DPoP", () => {
51 it.effect("passes typed-array digest input through the Expo Crypto adapter", () =>
52 Effect.gen(function* () {
53 const digest = yield* Digest.Digest;
54 const hex = yield* digest.digestHex(Digest.sha256, new TextEncoder().encode("typed-array"));
55
56 expect(hex).toBe(NodeCrypto.createHash("sha256").update("typed-array").digest("hex"));
57 }).pipe(Effect.provide(Dpop.layer)),
58 );
59
60 it.effect("persists and reuses the installation proof key", () =>
61 Effect.gen(function* () {
62 secureStore.clear();
63 const first = yield* loadOrCreateDpopProofKeyPair();
64 const second = yield* loadOrCreateDpopProofKeyPair();
65
66 expect(second.privateJwk).toEqual(first.privateJwk);
67 }).pipe(Effect.provide(Dpop.layer)),
68 );
69
70 it.effect("rejects malformed persisted proof keys", () =>
71 Effect.gen(function* () {
72 secureStore.set("t3code.cloud.dpop-proof-key", `{"kty":"EC","crv":"P-256","d":42}`);
73
74 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
75
76 expect(error.message).toBe("Stored DPoP proof key is invalid.");
77 }).pipe(Effect.provide(Dpop.layer)),
78 );
79
80 it.effect("rejects a persisted key whose public half belongs to another key", () =>
81 Effect.gen(function* () {
82 secureStore.clear();
83 const { privateJwk } = yield* loadOrCreateDpopProofKeyPair();
84 secureStore.clear();
85 const other = yield* loadOrCreateDpopProofKeyPair();
86 secureStore.set(
87 "t3code.cloud.dpop-proof-key",
88 JSON.stringify({ ...privateJwk, x: other.publicJwk.x, y: other.publicJwk.y }),
89 );
90
91 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
92
93 expect(error.message).toBe("Stored DPoP proof key is invalid.");
94 }),
95 );
96
97 // X ends in 0x00, so a zero-filled 31-byte X, or a 33-byte X whose extra byte Y overwrites,
98 // still names the same point.
99 const privateJwkWithZeroEndedX = {
100 kty: "EC",
101 crv: "P-256",
102 x: "TCE1CTXQ3-B54Fwv9yDlkE3rgcWsJsuGIv2Q9QHcZAA",
103 y: "zL4Eewo43mYmVjtqh597_dGnKa7lkxxB5XaQk32V9UM",
104 d: "mzWsJz_1-NerI3_NPtF_hcQhQrXnEITcNEmu5BzaDK4",
105 };
106 const xBytes = Buffer.from(privateJwkWithZeroEndedX.x, "base64url");
107 it.effect.each([
108 { length: "short", x: xBytes.subarray(0, 31) },
109 { length: "long", x: Buffer.concat([xBytes, Buffer.of(7)]) },
110 ])("rejects a persisted key with a $length coordinate", ({ x }) =>
111 Effect.gen(function* () {
112 secureStore.set(
113 "t3code.cloud.dpop-proof-key",
114 JSON.stringify({ ...privateJwkWithZeroEndedX, x: x.toString("base64url") }),
115 );
116
117 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
118
119 expect(error.message).toBe("Stored DPoP proof key is invalid.");
120 }),
121 );
122
123 it.effect("signs proofs that the relay's verifier accepts", () =>
124 Effect.gen(function* () {
125 secureStore.clear();
126 const key = yield* loadOrCreateProofKey();
127 const url = "https://relay.t3.codes/v1/environments";
128 const proof = yield* DpopProof.createProof({
129 method: "GET",
130 url,
131 accessToken: "access-token",
132 key,
133 });
134
135 // The proof's `iat` comes from the TestClock, which starts at 0.
136 expect(
137 verifyDpopProof({
138 proof,
139 method: "GET",
140 url,
141 nowEpochSeconds: 0,
142 expectedAccessToken: "access-token",
143 }),
144 ).toMatchObject({ ok: true });
145 }).pipe(Effect.provide(Dpop.layer)),
146 );
147});