Repository navigation
fix(server): prune expired replay-protection files from the secrets directory #13695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+293
−1
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
08882a2
fix(server): prune expired replay-protection files from the secrets d…
t3dotgg 893f04d
refactor(server): simplify replay marker pruning
t3dotgg c0fe7cf
refactor(server): derive the replay marker max age from the proof win…
t3dotgg b82fbf6
test(server): prove pruned replay markers cannot reopen a replay window
t3dotgg 96da007
fix(server): keep replay markers for a day before pruning
t3dotgg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| import { assert, it } from "@effect/vitest"; | ||
| import * as DateTime from "effect/DateTime"; | ||
| import * as Duration from "effect/Duration"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Path from "effect/Path"; | ||
| import * as TestClock from "effect/testing/TestClock"; | ||
|
|
||
| import * as ServerConfig from "../config.ts"; | ||
| import { pruneExpiredReplayMarkers, REPLAY_MARKER_MAX_AGE } from "./replayMarkers.ts"; | ||
| import * as ServerSecretStore from "./ServerSecretStore.ts"; | ||
|
|
||
| // Every secret name the server stores today. The last three stand for names | ||
| // built from an id at runtime. | ||
| const REAL_SECRET_NAMES = [ | ||
| "server-signing-key", | ||
| "asset-access-signing-key", | ||
| "cloud-cli-oauth-token", | ||
| "cloud-cli-desired-link", | ||
| "cloud-link-ed25519-key-pair", | ||
| "cloud-link-ed25519-private-key", | ||
| "cloud-link-ed25519-public-key", | ||
| "cloud-mint-ed25519-public-key", | ||
| "cloud-endpoint-runtime-config", | ||
| "cloud-endpoint-confirmed-origin", | ||
| "cloud-linked-user-id", | ||
| "cloud-relay-url", | ||
| "cloud-relay-issuer", | ||
| "cloud-relay-environment-credential", | ||
| "cloud-publish-agent-activity", | ||
| "provider-env-Y29kZXg-T1BFTkFJX0FQSV9LRVk", | ||
| "provider-auth-0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", | ||
| "usage-limit-source-aHVi", | ||
| ]; | ||
|
|
||
| it.layer(NodeServices.layer)("replayMarkers", (it) => { | ||
| it.effect("prunes only replay markers older than the max age", () => | ||
| Effect.gen(function* () { | ||
| const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| const { secretsDir } = yield* ServerConfig.ServerConfig; | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const now = DateTime.makeUnsafe("2026-01-01T00:00:00Z"); | ||
| const setAge = (fileName: string, age: Duration.Duration) => { | ||
| const mtime = DateTime.toDateUtc(DateTime.subtractDuration(now, age)); | ||
| return fileSystem.utimes(path.join(secretsDir, fileName), mtime, mtime); | ||
| }; | ||
| const writeAged = (name: string, age: Duration.Duration) => | ||
| secretStore | ||
| .create(name, Uint8Array.from([1])) | ||
| .pipe(Effect.andThen(setAge(`${name}.bin`, age))); | ||
|
|
||
| const justExpired = Duration.sum(REPLAY_MARKER_MAX_AGE, Duration.seconds(1)); | ||
| const expiredMarkers = [ | ||
| "dpop-proof-old", | ||
| "cloud-mint-jti-old", | ||
| "cloud-mint-nonce-old", | ||
| "cloud-health-jti-old", | ||
| "cloud-health-nonce-old", | ||
| ]; | ||
| for (const name of expiredMarkers) yield* writeAged(name, justExpired); | ||
| yield* writeAged("dpop-proof-at-max-age", REPLAY_MARKER_MAX_AGE); | ||
| for (const name of REAL_SECRET_NAMES) yield* writeAged(name, Duration.days(30)); | ||
| const pendingSetFile = "dpop-proof-pending.bin.0000.tmp"; | ||
| yield* fileSystem.writeFile(path.join(secretsDir, pendingSetFile), Uint8Array.from([1])); | ||
| yield* setAge(pendingSetFile, Duration.days(30)); | ||
| yield* TestClock.setTime(DateTime.toEpochMillis(now)); | ||
|
|
||
| yield* pruneExpiredReplayMarkers(); | ||
|
|
||
| const remaining = yield* fileSystem.readDirectory(secretsDir); | ||
| assert.deepStrictEqual( | ||
| remaining.toSorted(), | ||
| [ | ||
| ...REAL_SECRET_NAMES.map((name) => `${name}.bin`), | ||
| "dpop-proof-at-max-age.bin", | ||
| pendingSetFile, | ||
| ].toSorted(), | ||
| ); | ||
| }).pipe( | ||
| Effect.provide( | ||
| ServerSecretStore.layer.pipe( | ||
| Layer.provideMerge( | ||
| ServerConfig.layerTest(process.cwd(), { prefix: "t3-replay-markers-test-" }), | ||
| ), | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import * as Clock from "effect/Clock"; | ||
| import * as Duration from "effect/Duration"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Path from "effect/Path"; | ||
| import * as Schedule from "effect/Schedule"; | ||
|
|
||
| import { CLOUD_REPLAY_MARKER_PREFIXES } from "../cloud/http.ts"; | ||
| import * as ServerConfig from "../config.ts"; | ||
| import { forkParked } from "../serverActivation.ts"; | ||
| import { DPOP_REPLAY_MARKER_PREFIX } from "./dpop.ts"; | ||
|
|
||
| const REPLAY_MARKER_PREFIXES = [DPOP_REPLAY_MARKER_PREFIX, ...CLOUD_REPLAY_MARKER_PREFIXES]; | ||
|
|
||
| /** | ||
| * How long a replay marker stays on disk. A marker only matters while its proof | ||
| * can pass the time check (about 5 minutes for DPoP, 7 for cloud proofs). After | ||
| * that, the time check rejects a replay by itself. The sweep and the time check | ||
| * both use the wall clock, so a pruned marker can let a replay through only if | ||
| * the clock moves back by almost a day, or if the filesystem stamps mtimes almost | ||
| * a day behind. Markers are files, so a restart does not reset them. | ||
| */ | ||
| export const REPLAY_MARKER_MAX_AGE = Duration.days(1); | ||
|
|
||
| /** | ||
| * Deletes replay markers whose mtime is older than `REPLAY_MARKER_MAX_AGE`. | ||
| * `ServerSecretStore` saves each secret as `<name>.bin`, so only | ||
| * `<marker prefix>*.bin` names match. Other secrets and the `*.bin.<uuid>.tmp` | ||
| * files that `set` writes are never touched. | ||
| */ | ||
| export const pruneExpiredReplayMarkers = Effect.fn("replayMarkers.pruneExpired")(function* () { | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const { secretsDir } = yield* ServerConfig.ServerConfig; | ||
| const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(REPLAY_MARKER_MAX_AGE); | ||
| const markers = (yield* fileSystem.readDirectory(secretsDir)).filter( | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| (name) => | ||
| name.endsWith(".bin") && REPLAY_MARKER_PREFIXES.some((prefix) => name.startsWith(prefix)), | ||
| ); | ||
| // `partition` visits every marker, so one locked file does not stop the sweep. | ||
| const [failures, removed] = yield* Effect.partition(markers, (name) => { | ||
| const markerPath = path.join(secretsDir, name); | ||
| return fileSystem.stat(markerPath).pipe( | ||
| Effect.flatMap((info) => | ||
| Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) | ||
| ? fileSystem.remove(markerPath).pipe(Effect.as(true)) | ||
| : Effect.succeed(false), | ||
| ), | ||
| Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), | ||
| ); | ||
| }); | ||
| yield* Effect.annotateCurrentSpan({ | ||
| "replay_markers.matched": markers.length, | ||
| "replay_markers.removed": removed.filter(Boolean).length, | ||
| "replay_markers.failed": failures.length, | ||
| }); | ||
| if (failures.length > 0) { | ||
| yield* Effect.logWarning("Failed to prune some replay markers", { | ||
| failed: failures.length, | ||
| cause: failures[0], | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| /** Prunes expired replay markers after server activation, then every hour. */ | ||
| export const layer = Layer.effectDiscard( | ||
| forkParked( | ||
| pruneExpiredReplayMarkers().pipe( | ||
| Effect.catch((cause) => | ||
| Effect.logWarning("Failed to prune expired replay markers", { cause }), | ||
| ), | ||
| Effect.repeat(Schedule.spaced(Duration.hours(1))), | ||
| ), | ||
| ), | ||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.