diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index 2b19d4c9f1f..f61c5a27d5b 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayConfiguration from "../Config.ts"; @@ -51,6 +52,9 @@ const state: RelayAgentActivityState = { updatedAt: "2026-05-25T00:00:00.000Z", deepLink: "/threads/env/thread", }; +const isEnvironmentPublishSignatureInvalid = Schema.is( + EnvironmentPublishSignatures.EnvironmentPublishSignatureInvalid, +); function signTestJwt(payload: object, privateKey: string): string { const header = Buffer.from( @@ -145,6 +149,49 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", + }); + } + } + }).pipe(Effect.provide(layer())), + ); + + it.effect("preserves the JWT verification failure", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const segments = request.proof.split("."); + const signature = segments[2]!; + segments[2] = `${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request: { ...request, proof: segments.join(".") }, + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } }).pipe(Effect.provide(layer())), ); @@ -161,6 +208,17 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", + }); + } + } }).pipe(Effect.provide(layer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index ffc8c124b7b..eb9c15a75aa 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -1,5 +1,6 @@ import { RelayAgentActivityPublishProofPayload, + RelayAgentActivityPublishProofInvalidReason, type RelayAgentActivityPublishRequest, } from "@t3tools/contracts/relay"; import { @@ -23,11 +24,13 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentPublishSignatureExpired extends Schema.TaggedErrorClass()( "EnvironmentPublishSignatureExpired", { + environmentId: Schema.String, + threadId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment publish signature expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' expired at ${this.expiresAt}`; } } @@ -35,10 +38,21 @@ export class EnvironmentPublishSignatureInvalid extends Schema.TaggedErrorClass< "EnvironmentPublishSignatureInvalid", { environmentId: Schema.String, + threadId: Schema.String, + reason: RelayAgentActivityPublishProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "verify_proof", + "validate_claims", + "validate_expiration", + "generate_replay_thumbprint", + "consume_nonce", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' publish signature is invalid`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' is invalid during ${this.stage}: ${this.reason}`; } } @@ -102,13 +116,22 @@ const make = Effect.gen(function* () { const now = yield* DateTime.now; const decoded = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + catch: (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "decode_token", + cause, + }), }); if ( typeof decoded.exp === "number" && decoded.exp <= Math.floor(now.epochMilliseconds / 1_000) ) { return yield* new EnvironmentPublishSignatureExpired({ + environmentId: input.environmentId, + threadId: input.threadId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)), }); } @@ -122,7 +145,14 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause, + }), ), ); if ( @@ -136,12 +166,18 @@ const make = Effect.gen(function* () { ) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", }); } const expiresAt = DateTime.make(proof.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_expiration", }); } const thumbprint = yield* crypto @@ -155,7 +191,14 @@ const make = Effect.gen(function* () { .pipe( Effect.map(formatEnvironmentPublishReplayThumbprint), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "generate_replay_thumbprint", + cause, + }), ), ); const consumedNonce = yield* proofReplay.consume({ @@ -167,6 +210,9 @@ const make = Effect.gen(function* () { if (!consumedNonce) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", }); } }),