Skip to content
Source
Sign inSign up
Open History to see the last commit

EnvironmentPublishSignatures.ts

210 lines7.2 KB

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