1import * as Context from "effect/Context";
2import * as SecureRandom from "effect/crypto/SecureRandom";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Predicate from "effect/Predicate";
9import * as PlatformError from "effect/PlatformError";
10import * as Schema from "effect/Schema";
11
12import * as ServerConfig from "../config.ts";
13
14const secretStoreErrorContext = {
15 resource: Schema.String,
16 cause: Schema.Defect(),
17};
18
19export class SecretStoreSecureError extends Schema.TaggedError<SecretStoreSecureError>()(
20 "SecretStoreSecureError",
21 {
22 ...secretStoreErrorContext,
23 },
24) {
25 override get message(): string {
26 return `Failed to secure ${this.resource}.`;
27 }
28}
29
30export class SecretStoreReadError extends Schema.TaggedError<SecretStoreReadError>()(
31 "SecretStoreReadError",
32 {
33 ...secretStoreErrorContext,
34 },
35) {
36 override get message(): string {
37 return `Failed to read ${this.resource}.`;
38 }
39}
40
41export class SecretStoreTemporaryPathError extends Schema.TaggedError<SecretStoreTemporaryPathError>()(
42 "SecretStoreTemporaryPathError",
43 {
44 ...secretStoreErrorContext,
45 },
46) {
47 override get message(): string {
48 return `Failed to create temporary path for ${this.resource}.`;
49 }
50}
51
52export class SecretStorePersistError extends Schema.TaggedError<SecretStorePersistError>()(
53 "SecretStorePersistError",
54 {
55 ...secretStoreErrorContext,
56 },
57) {
58 override get message(): string {
59 return `Failed to persist ${this.resource}.`;
60 }
61}
62
63export class SecretStoreRandomGenerationError extends Schema.TaggedError<SecretStoreRandomGenerationError>()(
64 "SecretStoreRandomGenerationError",
65 {
66 ...secretStoreErrorContext,
67 },
68) {
69 override get message(): string {
70 return `Failed to generate random bytes for ${this.resource}.`;
71 }
72}
73
74export class SecretStoreConcurrentReadError extends Schema.TaggedError<SecretStoreConcurrentReadError>()(
75 "SecretStoreConcurrentReadError",
76 {
77 resource: Schema.String,
78 },
79) {
80 override get message(): string {
81 return `Failed to read ${this.resource} after concurrent creation.`;
82 }
83}
84
85export class SecretStoreRemoveError extends Schema.TaggedError<SecretStoreRemoveError>()(
86 "SecretStoreRemoveError",
87 {
88 ...secretStoreErrorContext,
89 },
90) {
91 override get message(): string {
92 return `Failed to remove ${this.resource}.`;
93 }
94}
95
96export class SecretStoreDecodeError extends Schema.TaggedError<SecretStoreDecodeError>()(
97 "SecretStoreDecodeError",
98 {
99 ...secretStoreErrorContext,
100 },
101) {
102 override get message(): string {
103 return `Failed to decode ${this.resource}.`;
104 }
105}
106
107export class SecretStoreEncodeError extends Schema.TaggedError<SecretStoreEncodeError>()(
108 "SecretStoreEncodeError",
109 {
110 ...secretStoreErrorContext,
111 },
112) {
113 override get message(): string {
114 return `Failed to encode ${this.resource}.`;
115 }
116}
117
118export const SecretStoreError = Schema.Union([
119 SecretStoreSecureError,
120 SecretStoreReadError,
121 SecretStoreTemporaryPathError,
122 SecretStorePersistError,
123 SecretStoreRandomGenerationError,
124 SecretStoreConcurrentReadError,
125 SecretStoreRemoveError,
126 SecretStoreDecodeError,
127 SecretStoreEncodeError,
128]);
129export type SecretStoreError = typeof SecretStoreError.Type;
130export const isSecretStoreError = Schema.is(SecretStoreError);
131
132const isPlatformError = (value: unknown): value is PlatformError.PlatformError =>
133 Predicate.isTagged(value, "PlatformError");
134
135export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
136 "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
137
138export class ServerSecretStore extends Context.Service<
139 ServerSecretStore,
140 {
141 readonly directory?: string;
142 readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;
143 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
144 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
145 readonly getOrCreateRandom: (
146 name: string,
147 bytes: number,
148 ) => Effect.Effect<Uint8Array, SecretStoreError>;
149 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
150 }
151>()("t3/auth/ServerSecretStore") {}
152
153/** @public */
154export const make = Effect.gen(function* () {
155 const crypto = yield* SecureRandom.SecureRandom;
156 const fileSystem = yield* FileSystem.FileSystem;
157 const path = yield* Path.Path;
158 const serverConfig = yield* ServerConfig.ServerConfig;
159
160 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
161 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
162 Effect.mapError(
163 (cause) =>
164 new SecretStoreSecureError({
165 resource: `secrets directory ${serverConfig.secretsDir}`,
166 cause,
167 }),
168 ),
169 );
170
171 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
172
173 const get: ServerSecretStore["Service"]["get"] = (name) =>
174 fileSystem.readFile(resolveSecretPath(name)).pipe(
175 Effect.map((bytes) => Option.some(Uint8Array.from(bytes))),
176 Effect.catch((cause) =>
177 cause.reason._tag === "NotFound"
178 ? Effect.succeedNone
179 : Effect.fail(
180 new SecretStoreReadError({
181 resource: `secret ${name}`,
182 cause,
183 }),
184 ),
185 ),
186 Effect.withSpan("ServerSecretStore.get"),
187 );
188
189 const set: ServerSecretStore["Service"]["set"] = (name, value) => {
190 const secretPath = resolveSecretPath(name);
191 return crypto.uuidV4().pipe(
192 Effect.mapError(
193 (cause) =>
194 new SecretStoreTemporaryPathError({
195 resource: `secret ${name}`,
196 cause,
197 }),
198 ),
199 Effect.flatMap((uuid) => {
200 const tempPath = `${secretPath}.${uuid}.tmp`;
201 return Effect.gen(function* () {
202 yield* fileSystem.writeFile(tempPath, value);
203 yield* fileSystem.chmod(tempPath, 0o600);
204 yield* fileSystem.rename(tempPath, secretPath);
205 yield* fileSystem.chmod(secretPath, 0o600);
206 }).pipe(
207 Effect.catch((cause) =>
208 fileSystem.remove(tempPath).pipe(
209 Effect.ignore,
210 Effect.flatMap(() =>
211 Effect.fail(
212 new SecretStorePersistError({
213 resource: `secret ${name}`,
214 cause,
215 }),
216 ),
217 ),
218 ),
219 ),
220 );
221 }),
222 Effect.withSpan("ServerSecretStore.set"),
223 );
224 };
225
226 // `link` publishes the fully written temp file under the final name, or fails with
227 // AlreadyExists, so a concurrent reader never observes a partially written secret.
228 const create: ServerSecretStore["Service"]["create"] = (name, value) => {
229 const secretPath = resolveSecretPath(name);
230 return crypto.uuidV4().pipe(
231 Effect.mapError(
232 (cause) =>
233 new SecretStoreTemporaryPathError({
234 resource: `secret ${name}`,
235 cause,
236 }),
237 ),
238 Effect.flatMap((uuid) => {
239 const tempPath = `${secretPath}.${uuid}.tmp`;
240 return Effect.gen(function* () {
241 yield* Effect.scoped(
242 Effect.gen(function* () {
243 const file = yield* fileSystem.open(tempPath, { flag: "wx", mode: 0o600 });
244 yield* file.writeAll(value);
245 yield* file.sync;
246 }),
247 );
248 yield* fileSystem.chmod(tempPath, 0o600);
249 yield* fileSystem.link(tempPath, secretPath);
250 }).pipe(
251 Effect.ensuring(fileSystem.remove(tempPath).pipe(Effect.ignore)),
252 Effect.mapError(
253 (cause) =>
254 new SecretStorePersistError({
255 resource: `secret ${name}`,
256 cause,
257 }),
258 ),
259 );
260 }),
261 );
262 };
263
264 const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) =>
265 get(name).pipe(
266 Effect.flatMap(
267 Option.match({
268 onSome: Effect.succeed,
269 onNone: () =>
270 crypto.randomBytes(bytes).pipe(
271 Effect.mapError(
272 (cause) =>
273 new SecretStoreRandomGenerationError({
274 resource: `secret ${name}`,
275 cause,
276 }),
277 ),
278 Effect.flatMap((generated) =>
279 create(name, generated).pipe(
280 Effect.as(Uint8Array.from(generated)),
281 Effect.catchIf(isSecretStoreError, (error) =>
282 isSecretAlreadyExistsError(error)
283 ? get(name).pipe(
284 Effect.flatMap(
285 Option.match({
286 onSome: Effect.succeed,
287 onNone: () =>
288 Effect.fail(
289 new SecretStoreConcurrentReadError({
290 resource: `secret ${name}`,
291 }),
292 ),
293 }),
294 ),
295 )
296 : Effect.fail(error),
297 ),
298 ),
299 ),
300 ),
301 }),
302 ),
303 Effect.withSpan("ServerSecretStore.getOrCreateRandom"),
304 );
305
306 const remove: ServerSecretStore["Service"]["remove"] = (name) =>
307 fileSystem.remove(resolveSecretPath(name)).pipe(
308 Effect.catch((cause) =>
309 cause.reason._tag === "NotFound"
310 ? Effect.void
311 : Effect.fail(
312 new SecretStoreRemoveError({
313 resource: `secret ${name}`,
314 cause,
315 }),
316 ),
317 ),
318 Effect.withSpan("ServerSecretStore.remove"),
319 );
320
321 return ServerSecretStore.of({
322 directory: serverConfig.secretsDir,
323 get,
324 set,
325 create,
326 getOrCreateRandom,
327 remove,
328 });
329});
330
331export const layer = Layer.effect(ServerSecretStore, make);