1import {
2 RelayAgentActivityPublishProofPayload,
3 RelayAgentActivityPublishProofInvalidReason,
4 type RelayAgentActivityPublishRequest,
5} from "@t3tools/contracts/relay";
6import {
7 decodeRelayJwt,
8 normalizeRelayIssuer,
9 RELAY_ACTIVITY_PUBLISH_TYP,
10 verifyRelayJwt,
11} from "@t3tools/shared/relayJwt";
12import { stableStringify } from "@t3tools/shared/relaySigning";
13import * as Context from "effect/Context";
14import * as Crypto from "effect/Crypto";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Base64Url from "effect/encoding/Base64Url";
18import * as Layer from "effect/Layer";
19import * as Schema from "effect/Schema";
20
21import * as DpopProofs from "../auth/DpopProofs.ts";
22import * as RelayConfiguration from "../Config.ts";
23
24export class EnvironmentPublishSignatureExpired extends Schema.TaggedError<EnvironmentPublishSignatureExpired>()(
25 "EnvironmentPublishSignatureExpired",
26 {
27 environmentId: Schema.String,
28 threadId: Schema.String,
29 expiresAt: Schema.String,
30 },
31) {
32 override get message(): string {
33 return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' expired at ${this.expiresAt}`;
34 }
35}
36
37export class EnvironmentPublishSignatureInvalid extends Schema.TaggedError<EnvironmentPublishSignatureInvalid>()(
38 "EnvironmentPublishSignatureInvalid",
39 {
40 environmentId: Schema.String,
41 threadId: Schema.String,
42 reason: RelayAgentActivityPublishProofInvalidReason,
43 stage: Schema.Literals([
44 "decode_token",
45 "verify_proof",
46 "validate_claims",
47 "validate_expiration",
48 "generate_replay_thumbprint",
49 "consume_nonce",
50 ]),
51 cause: Schema.optional(Schema.Defect()),
52 },
53) {
54 override get message(): string {
55 return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' is invalid during ${this.stage}: ${this.reason}`;
56 }
57}
58
59export class EnvironmentPublishPublicKeyMissing extends Schema.TaggedError<EnvironmentPublishPublicKeyMissing>()(
60 "EnvironmentPublishPublicKeyMissing",
61 {
62 environmentId: Schema.String,
63 },
64) {
65 override get message(): string {
66 return `Environment '${this.environmentId}' has no publish public key`;
67 }
68}
69
70export type EnvironmentPublishSignatureError =
71 | EnvironmentPublishSignatureExpired
72 | EnvironmentPublishSignatureInvalid
73 | EnvironmentPublishPublicKeyMissing
74 | DpopProofs.DpopProofReplayPersistenceError;
75
76export class EnvironmentPublishSignatures extends Context.Service<
77 EnvironmentPublishSignatures,
78 {
79 readonly verify: (input: {
80 readonly environmentId: string;
81 readonly environmentPublicKey: string;
82 readonly threadId: string;
83 readonly request: RelayAgentActivityPublishRequest;
84 }) => Effect.Effect<void, EnvironmentPublishSignatureError>;
85 }
86>()("t3code-relay/environments/EnvironmentPublishSignatures") {}
87
88const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload);
89
90function environmentPublishReplayThumbprintData(input: {
91 readonly environmentId: string;
92 readonly environmentPublicKey: string;
93}) {
94 return new TextEncoder().encode(
95 stableStringify({
96 environmentId: input.environmentId,
97 environmentPublicKey: input.environmentPublicKey,
98 }),
99 );
100}
101
102const formatEnvironmentPublishReplayThumbprint = (digest: Uint8Array) =>
103 `env-publish:${Base64Url.encode(digest)}`;
104
105const make = Effect.gen(function* () {
106 const proofReplay = yield* DpopProofs.DpopProofReplay;
107 const config = yield* RelayConfiguration.RelayConfiguration;
108 const crypto = yield* Crypto.Crypto;
109
110 return EnvironmentPublishSignatures.of({
111 verify: Effect.fn("relay.environment_publish_signatures.verify")(function* (input) {
112 yield* Effect.annotateCurrentSpan({
113 "relay.environment_id": input.environmentId,
114 "relay.thread_id": input.threadId,
115 });
116 const now = yield* DateTime.now;
117 const decoded = yield* Effect.try({
118 try: () => decodeRelayJwt(input.request.proof),
119 catch: (cause) =>
120 new EnvironmentPublishSignatureInvalid({
121 environmentId: input.environmentId,
122 threadId: input.threadId,
123 reason: "invalid_signature_or_payload",
124 stage: "decode_token",
125 cause,
126 }),
127 });
128 if (
129 typeof decoded.exp === "number" &&
130 decoded.exp <= Math.floor(now.epochMilliseconds / 1_000)
131 ) {
132 return yield* new EnvironmentPublishSignatureExpired({
133 environmentId: input.environmentId,
134 threadId: input.threadId,
135 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)),
136 });
137 }
138 const proof = yield* verifyRelayJwt({
139 publicKey: input.environmentPublicKey,
140 token: input.request.proof,
141 typ: RELAY_ACTIVITY_PUBLISH_TYP,
142 issuer: `t3-env:${input.environmentId}`,
143 audience: normalizeRelayIssuer(config.relayIssuer),
144 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
145 }).pipe(
146 Effect.flatMap(decodeProof),
147 Effect.mapError(
148 (cause) =>
149 new EnvironmentPublishSignatureInvalid({
150 environmentId: input.environmentId,
151 threadId: input.threadId,
152 reason: "invalid_signature_or_payload",
153 stage: "verify_proof",
154 cause,
155 }),
156 ),
157 );
158 if (
159 proof.environmentId !== input.environmentId ||
160 proof.threadId !== input.threadId ||
161 proof.sub !== input.environmentId ||
162 stableStringify(proof.state) !== stableStringify(input.request.state) ||
163 (input.request.state !== null &&
164 (input.request.state.environmentId !== input.environmentId ||
165 input.request.state.threadId !== input.threadId))
166 ) {
167 return yield* new EnvironmentPublishSignatureInvalid({
168 environmentId: input.environmentId,
169 threadId: input.threadId,
170 reason: "invalid_signature_or_payload",
171 stage: "validate_claims",
172 });
173 }
174 const expiresAt = DateTime.make(proof.exp * 1_000);
175 if (expiresAt._tag === "None") {
176 return yield* new EnvironmentPublishSignatureInvalid({
177 environmentId: input.environmentId,
178 threadId: input.threadId,
179 reason: "invalid_signature_or_payload",
180 stage: "validate_expiration",
181 });
182 }
183 const thumbprint = yield* crypto
184 .digest(
185 "SHA-256",
186 environmentPublishReplayThumbprintData({
187 environmentId: input.environmentId,
188 environmentPublicKey: input.environmentPublicKey,
189 }),
190 )
191 .pipe(
192 Effect.map(formatEnvironmentPublishReplayThumbprint),
193 Effect.mapError(
194 (cause) =>
195 new EnvironmentPublishSignatureInvalid({
196 environmentId: input.environmentId,
197 threadId: input.threadId,
198 reason: "invalid_signature_or_payload",
199 stage: "generate_replay_thumbprint",
200 cause,
201 }),
202 ),
203 );
204 const consumedNonce = yield* proofReplay.consume({
205 thumbprint,
206 jti: proof.jti,
207 iat: proof.iat,
208 expiresAt: expiresAt.value,
209 });
210 if (!consumedNonce) {
211 return yield* new EnvironmentPublishSignatureInvalid({
212 environmentId: input.environmentId,
213 threadId: input.threadId,
214 reason: "replayed_nonce",
215 stage: "consume_nonce",
216 });
217 }
218 }),
219 });
220});
221
222export const layer = Layer.effect(EnvironmentPublishSignatures, make);