1import * as Clock from "effect/Clock";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Base64Url from "effect/encoding/Base64Url";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import * as ExpoCrypto from "expo-crypto";
9import * as SecureStore from "expo-secure-store";
10import { p256 } from "@noble/curves/nist";
11import { DpopPublicJwk, normalizeDpopHtu } from "@t3tools/shared/dpopCommon";
12import * as Layer from "effect/Layer";
13
14export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
15 readonly message: string;
16 readonly cause?: unknown;
17}> {}
18
19function cloudDpopError(message: string) {
20 return (cause: unknown) => new CloudDpopError({ message, cause });
21}
22
23const DpopPrivateJwkSchema = Schema.Struct({
24 ...DpopPublicJwk.fields,
25 d: Schema.String,
26});
27
28const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema);
29const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson);
30const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson);
31
32const DpopJwtHeaderJson = Schema.fromJsonString(
33 Schema.Struct({
34 typ: Schema.Literal("dpop+jwt"),
35 alg: Schema.Literal("ES256"),
36 jwk: DpopPublicJwk,
37 }),
38);
39
40const DpopJwtPayloadJson = Schema.fromJsonString(
41 Schema.Struct({
42 htm: Schema.String,
43 htu: Schema.String,
44 jti: Schema.String,
45 iat: Schema.Int,
46 ath: Schema.optionalKey(Schema.String),
47 }),
48);
49
50const encodeDpopJwtHeaderJson = Schema.encodeEffect(DpopJwtHeaderJson);
51const encodeDpopJwtPayloadJson = Schema.encodeEffect(DpopJwtPayloadJson);
52
53function toExpoDigestAlgorithm(
54 algorithm: Crypto.DigestAlgorithm,
55): ExpoCrypto.CryptoDigestAlgorithm {
56 switch (algorithm) {
57 case "SHA-1":
58 return ExpoCrypto.CryptoDigestAlgorithm.SHA1;
59 case "SHA-256":
60 return ExpoCrypto.CryptoDigestAlgorithm.SHA256;
61 case "SHA-384":
62 return ExpoCrypto.CryptoDigestAlgorithm.SHA384;
63 case "SHA-512":
64 return ExpoCrypto.CryptoDigestAlgorithm.SHA512;
65 }
66}
67
68export const layer = Layer.succeed(
69 Crypto.Crypto,
70 Crypto.make({
71 randomBytes: ExpoCrypto.getRandomBytes,
72 digest: (algorithm, data) =>
73 Effect.promise(async () => {
74 const input = new Uint8Array(data.length);
75 input.set(data);
76 return new Uint8Array(await ExpoCrypto.digest(toExpoDigestAlgorithm(algorithm), input));
77 }),
78 }),
79);
80
81type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type;
82
83export interface DpopProofKeyPair {
84 readonly privateJwk: DpopPrivateJwk;
85 readonly publicJwk: DpopPublicJwk;
86 readonly thumbprint: string;
87}
88
89const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key";
90
91function base64UrlToBytes(value: string): Uint8Array {
92 return Result.getOrThrow(Base64Url.decode(value));
93}
94
95function sha256Digest(
96 data: Uint8Array,
97 message: string,
98): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
99 return Crypto.Crypto.pipe(
100 Effect.flatMap((crypto) => crypto.digest("SHA-256", data)),
101 Effect.mapError(cloudDpopError(message)),
102 );
103}
104
105function base64UrlSha256(
106 data: Uint8Array,
107 message: string,
108): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
109 return sha256Digest(data, message).pipe(Effect.map(Base64Url.encode));
110}
111
112function dpopThumbprintInput(jwk: DpopPublicJwk): string {
113 return JSON.stringify({
114 crv: jwk.crv,
115 kty: jwk.kty,
116 x: jwk.x,
117 y: jwk.y,
118 });
119}
120
121function computeDpopJwkThumbprintEffect(
122 jwk: DpopPublicJwk,
123): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
124 return base64UrlSha256(
125 new TextEncoder().encode(dpopThumbprintInput(jwk)),
126 "Could not hash the DPoP public key thumbprint.",
127 );
128}
129
130function computeDpopAccessTokenHashEffect(
131 accessToken: string,
132): Effect.Effect<string, CloudDpopError, Crypto.Crypto> {
133 return base64UrlSha256(
134 new TextEncoder().encode(accessToken),
135 "Could not hash the DPoP access token.",
136 );
137}
138
139function secureRandomBytes(
140 byteCount: number,
141 message: string,
142): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
143 return Crypto.Crypto.pipe(
144 Effect.flatMap((crypto) => crypto.randomBytes(byteCount)),
145 Effect.mapError(cloudDpopError(message)),
146 );
147}
148
149function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {
150 if (publicKey.length !== 65 || publicKey[0] !== 0x04) {
151 throw new Error("Generated DPoP public key is not an uncompressed P-256 point.");
152 }
153 return {
154 kty: "EC",
155 crv: "P-256",
156 x: Base64Url.encode(publicKey.slice(1, 33)),
157 y: Base64Url.encode(publicKey.slice(33, 65)),
158 };
159}
160
161function privateJwkFromPrivateKey(
162 privateKey: Uint8Array,
163 publicJwk: DpopPublicJwk,
164): DpopPrivateJwk {
165 return { ...publicJwk, d: Base64Url.encode(privateKey) };
166}
167
168export function generateDpopProofKeyPair(): Effect.Effect<
169 DpopProofKeyPair,
170 CloudDpopError,
171 Crypto.Crypto
172> {
173 return Effect.gen(function* () {
174 let privateKey: Uint8Array;
175 do {
176 privateKey = yield* secureRandomBytes(
177 p256.CURVE.nByteLength,
178 "Could not generate DPoP key pair randomness.",
179 );
180 } while (!p256.utils.isValidPrivateKey(privateKey));
181 const publicJwk = yield* Effect.try({
182 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
183 catch: cloudDpopError("Generated DPoP public key is invalid."),
184 });
185 const thumbprint = yield* computeDpopJwkThumbprintEffect(publicJwk);
186 return {
187 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),
188 publicJwk,
189 thumbprint,
190 };
191 });
192}
193
194export function loadOrCreateDpopProofKeyPair(): Effect.Effect<
195 DpopProofKeyPair,
196 CloudDpopError,
197 Crypto.Crypto
198> {
199 return Effect.gen(function* () {
200 const stored = yield* Effect.tryPromise({
201 try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY),
202 catch: cloudDpopError("Could not read the DPoP proof key."),
203 });
204 if (stored) {
205 const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe(
206 Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")),
207 );
208 const restored = yield* Effect.try({
209 try: () => {
210 const privateKey = base64UrlToBytes(storedPrivateJwk.d);
211 const publicJwk = publicJwkFromUncompressedPublicKey(
212 p256.getPublicKey(privateKey, false),
213 );
214 if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) {
215 throw new Error("Stored DPoP key does not match its public key.");
216 }
217 return { privateJwk: storedPrivateJwk, publicJwk };
218 },
219 catch: cloudDpopError("Stored DPoP proof key is invalid."),
220 });
221 const thumbprint = yield* computeDpopJwkThumbprintEffect(restored.publicJwk);
222 return {
223 ...restored,
224 thumbprint,
225 };
226 }
227 const generated = yield* generateDpopProofKeyPair();
228 const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(
229 Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")),
230 );
231 yield* Effect.tryPromise({
232 try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk),
233 catch: cloudDpopError("Could not store the DPoP proof key."),
234 });
235 return generated;
236 });
237}
238
239function normalizeHtu(url: string): Effect.Effect<string, CloudDpopError> {
240 const normalized = normalizeDpopHtu(url);
241 return normalized
242 ? Effect.succeed(normalized)
243 : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." }));
244}
245
246export function createDpopProof(input: {
247 readonly method: string;
248 readonly url: string;
249 readonly accessToken?: string;
250 readonly proofKey?: DpopProofKeyPair;
251}): Effect.Effect<
252 { readonly proof: string; readonly thumbprint: string },
253 CloudDpopError,
254 Crypto.Crypto
255> {
256 return Effect.gen(function* () {
257 const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair());
258 const privateKey = yield* Effect.try({
259 try: () => base64UrlToBytes(keyPair.privateJwk.d),
260 catch: cloudDpopError("Could not import DPoP private key."),
261 });
262 const nowMs = yield* Clock.currentTimeMillis;
263 const jti = yield* Crypto.Crypto.pipe(
264 Effect.flatMap((crypto) => crypto.randomUUIDv4()),
265 Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")),
266 );
267 const htu = yield* normalizeHtu(input.url);
268 const header = yield* encodeDpopJwtHeaderJson({
269 typ: "dpop+jwt",
270 alg: "ES256",
271 jwk: keyPair.publicJwk,
272 }).pipe(
273 Effect.map(Base64Url.encode),
274 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
275 );
276 const ath = input.accessToken
277 ? yield* computeDpopAccessTokenHashEffect(input.accessToken)
278 : null;
279 const payload = yield* encodeDpopJwtPayloadJson({
280 htm: input.method.toUpperCase(),
281 htu,
282 jti,
283 iat: Math.floor(nowMs / 1_000),
284 ...(ath ? { ath } : {}),
285 }).pipe(
286 Effect.map(Base64Url.encode),
287 Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")),
288 );
289 const signatureInputHash = yield* sha256Digest(
290 new TextEncoder().encode(`${header}.${payload}`),
291 "Could not hash DPoP signing input.",
292 );
293 const signature = yield* Effect.try({
294 try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(),
295 catch: cloudDpopError("Could not sign DPoP proof."),
296 });
297 return {
298 proof: `${header}.${payload}.${Base64Url.encode(signature)}`,
299 thumbprint: keyPair.thumbprint,
300 };
301 });
302}