1// RFC 9449 DPoP proofs for relay requests. Platforms supply the key: web keeps a
2// non-extractable WebCrypto key, mobile signs with the platform's P-256 through a native module.
3import { normalizeDpopHtu, type DpopPublicJwk } from "@t3tools/shared/dpopCommon";
4import * as Cache from "effect/Cache";
5import * as Clock from "effect/Clock";
6import * as Digest from "effect/crypto/Digest";
7import * as SecureRandom from "effect/crypto/SecureRandom";
8import * as Schema from "effect/Schema";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Base64Url from "effect/encoding/Base64Url";
12import * as Exit from "effect/Exit";
13import * as Layer from "effect/Layer";
14
15import {
16 ManagedRelayDpopKeyLoadError,
17 ManagedRelayDpopProofCreationError,
18 ManagedRelayDpopSigner,
19} from "./managedRelay.ts";
20
21export class DpopProofError extends Schema.TaggedError<DpopProofError>()("DpopProofError", {
22 message: Schema.String,
23 cause: Schema.optional(Schema.Defect()),
24}) {}
25
26/** A P-256 proof key. `sign` returns the raw `r‖s` ES256 signature over the JWS signing input. */
27export interface DpopProofKey {
28 readonly publicJwk: DpopPublicJwk;
29 readonly sign: (signingInput: Uint8Array) => Effect.Effect<Uint8Array, DpopProofError>;
30}
31
32const utf8 = (value: string) => new TextEncoder().encode(value);
33const encodeSegment = (value: unknown) => Base64Url.encode(utf8(JSON.stringify(value)));
34
35const sha256Base64Url = (data: Uint8Array) =>
36 Effect.flatMap(Digest.Digest, (digest) => digest.digest(Digest.sha256, data)).pipe(
37 Effect.map(Base64Url.encode),
38 );
39
40/** RFC 7638 thumbprint: SHA-256 over the required members in lexicographic order. */
41export const thumbprint = (jwk: DpopPublicJwk) =>
42 sha256Base64Url(utf8(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })));
43
44export const createProof = Effect.fn("DpopProof.createProof")(function* (input: {
45 readonly method: string;
46 readonly url: string;
47 readonly accessToken?: string | undefined;
48 readonly key: DpopProofKey;
49}) {
50 const htu = normalizeDpopHtu(input.url);
51 if (htu === null) return yield* new DpopProofError({ message: "DPoP URL is invalid." });
52 const random = yield* SecureRandom.SecureRandom;
53 const header = encodeSegment({ typ: "dpop+jwt", alg: "ES256", jwk: input.key.publicJwk });
54 const payload = encodeSegment({
55 htm: input.method.toUpperCase(),
56 htu,
57 jti: yield* random.uuidV4(),
58 iat: Math.floor((yield* Clock.currentTimeMillis) / 1_000),
59 ...(input.accessToken ? { ath: yield* sha256Base64Url(utf8(input.accessToken)) } : {}),
60 });
61 const signature = yield* input.key.sign(utf8(`${header}.${payload}`));
62 return `${header}.${payload}.${Base64Url.encode(signature)}`;
63});
64
65/** A relay DPoP signer that loads its key once and keeps it; a failed load is retried. */
66export const layerSigner = <E, R>(options: {
67 readonly keyStore: ManagedRelayDpopKeyLoadError["keyStore"];
68 readonly loadOrCreateKey: Effect.Effect<DpopProofKey, E, R>;
69}) =>
70 Layer.effect(
71 ManagedRelayDpopSigner,
72 Effect.gen(function* () {
73 const random = yield* SecureRandom.SecureRandom;
74 const digest = yield* Digest.Digest;
75 const context = yield* Effect.context<R>();
76 const keyCache = yield* Cache.makeWith(
77 () =>
78 options.loadOrCreateKey.pipe(
79 Effect.flatMap((key) =>
80 thumbprint(key.publicJwk).pipe(Effect.map((value) => ({ key, thumbprint: value }))),
81 ),
82 Effect.provideService(Digest.Digest, digest),
83 Effect.provideContext(context),
84 ),
85 {
86 capacity: 1,
87 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
88 },
89 );
90 const loadKey = Cache.get(keyCache, undefined);
91 return ManagedRelayDpopSigner.of({
92 thumbprint: loadKey.pipe(
93 Effect.map((loaded) => loaded.thumbprint),
94 Effect.mapError(
95 (cause) => new ManagedRelayDpopKeyLoadError({ keyStore: options.keyStore, cause }),
96 ),
97 Effect.withSpan("managedRelayDpopSigner.loadThumbprint"),
98 ),
99 createProof: Effect.fn("managedRelayDpopSigner.createProof")(function* (input) {
100 const creationError = (cause: unknown) =>
101 new ManagedRelayDpopProofCreationError({ method: input.method, url: input.url, cause });
102 const loaded = yield* loadKey.pipe(Effect.mapError(creationError));
103 return yield* createProof({ ...input, key: loaded.key }).pipe(
104 Effect.provideService(SecureRandom.SecureRandom, random),
105 Effect.provideService(Digest.Digest, digest),
106 Effect.mapError(creationError),
107 );
108 }),
109 });
110 }),
111 );