@remit/storage-service 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/backends/filesystem.ts +203 -0
- package/src/backends/s3.test.ts +144 -6
- package/src/backends/s3.ts +214 -0
- package/src/index.ts +26 -0
- package/src/outbox-sweep.test.ts +196 -0
- package/src/outbox-sweep.ts +103 -0
- package/src/outbox-upload-url.test.ts +290 -0
- package/src/outbox-upload-url.ts +78 -0
- package/src/s3.ts +17 -1
- package/src/signed-path.ts +86 -0
- package/src/storage.test.ts +136 -0
- package/src/storage.ts +259 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, test } from "node:test";
|
|
6
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
7
|
+
import { createFilesystemStorageService } from "./backends/filesystem.js";
|
|
8
|
+
import { createS3StorageService } from "./backends/s3.js";
|
|
9
|
+
import {
|
|
10
|
+
authorizeUploadRequest,
|
|
11
|
+
UPLOAD_ROUTE_PREFIX,
|
|
12
|
+
UPLOAD_URL_SIGNING_LABEL,
|
|
13
|
+
} from "./outbox-upload-url.js";
|
|
14
|
+
import { signStoragePath } from "./signed-path.js";
|
|
15
|
+
import {
|
|
16
|
+
buildOutboxAttachmentKey,
|
|
17
|
+
parseOutboxAttachmentKey,
|
|
18
|
+
} from "./storage.js";
|
|
19
|
+
|
|
20
|
+
const SECRET = "a-signing-secret-of-at-least-32-characters";
|
|
21
|
+
const KEY = buildOutboxAttachmentKey("cfg1", "acc1", "draft1", "att1");
|
|
22
|
+
|
|
23
|
+
const sign = (path: string, exp: number, max: number): string =>
|
|
24
|
+
signStoragePath(SECRET, UPLOAD_URL_SIGNING_LABEL, path, [exp, max]);
|
|
25
|
+
|
|
26
|
+
describe("authorizeUploadRequest", () => {
|
|
27
|
+
const now = 1_000_000;
|
|
28
|
+
const exp = now + 900;
|
|
29
|
+
|
|
30
|
+
test("admits a request presenting the signature that was minted for it", () => {
|
|
31
|
+
const result = authorizeUploadRequest({
|
|
32
|
+
secret: SECRET,
|
|
33
|
+
relativePath: KEY,
|
|
34
|
+
exp: String(exp),
|
|
35
|
+
max: "2048",
|
|
36
|
+
sig: sign(KEY, exp, 2048),
|
|
37
|
+
nowSeconds: now,
|
|
38
|
+
});
|
|
39
|
+
assert.deepStrictEqual(result, { authorized: true, maxBytes: 2048 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("refuses a signature minted for another attachment's key", () => {
|
|
43
|
+
const otherKey = buildOutboxAttachmentKey("cfg1", "acc1", "draft1", "att2");
|
|
44
|
+
const result = authorizeUploadRequest({
|
|
45
|
+
secret: SECRET,
|
|
46
|
+
relativePath: otherKey,
|
|
47
|
+
exp: String(exp),
|
|
48
|
+
max: "2048",
|
|
49
|
+
sig: sign(KEY, exp, 2048),
|
|
50
|
+
nowSeconds: now,
|
|
51
|
+
});
|
|
52
|
+
assert.strictEqual(result.authorized, false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("refuses a signature minted for another account's key", () => {
|
|
56
|
+
const otherTenant = buildOutboxAttachmentKey(
|
|
57
|
+
"cfg2",
|
|
58
|
+
"acc1",
|
|
59
|
+
"draft1",
|
|
60
|
+
"att1",
|
|
61
|
+
);
|
|
62
|
+
const result = authorizeUploadRequest({
|
|
63
|
+
secret: SECRET,
|
|
64
|
+
relativePath: otherTenant,
|
|
65
|
+
exp: String(exp),
|
|
66
|
+
max: "2048",
|
|
67
|
+
sig: sign(KEY, exp, 2048),
|
|
68
|
+
nowSeconds: now,
|
|
69
|
+
});
|
|
70
|
+
assert.strictEqual(result.authorized, false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("refuses a raised byte count — the size is part of what was signed", () => {
|
|
74
|
+
const result = authorizeUploadRequest({
|
|
75
|
+
secret: SECRET,
|
|
76
|
+
relativePath: KEY,
|
|
77
|
+
exp: String(exp),
|
|
78
|
+
max: "999999",
|
|
79
|
+
sig: sign(KEY, exp, 2048),
|
|
80
|
+
nowSeconds: now,
|
|
81
|
+
});
|
|
82
|
+
assert.strictEqual(result.authorized, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("refuses an extended expiry", () => {
|
|
86
|
+
const result = authorizeUploadRequest({
|
|
87
|
+
secret: SECRET,
|
|
88
|
+
relativePath: KEY,
|
|
89
|
+
exp: String(exp + 10_000),
|
|
90
|
+
max: "2048",
|
|
91
|
+
sig: sign(KEY, exp, 2048),
|
|
92
|
+
nowSeconds: now,
|
|
93
|
+
});
|
|
94
|
+
assert.strictEqual(result.authorized, false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("refuses a lapsed URL", () => {
|
|
98
|
+
const result = authorizeUploadRequest({
|
|
99
|
+
secret: SECRET,
|
|
100
|
+
relativePath: KEY,
|
|
101
|
+
exp: String(exp),
|
|
102
|
+
max: "2048",
|
|
103
|
+
sig: sign(KEY, exp, 2048),
|
|
104
|
+
nowSeconds: exp + 1,
|
|
105
|
+
});
|
|
106
|
+
assert.strictEqual(result.authorized, false);
|
|
107
|
+
assert.strictEqual(result.authorized === false && result.reason, "expired");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("refuses a request presenting no signature at all", () => {
|
|
111
|
+
const result = authorizeUploadRequest({
|
|
112
|
+
secret: SECRET,
|
|
113
|
+
relativePath: KEY,
|
|
114
|
+
exp: String(exp),
|
|
115
|
+
max: "2048",
|
|
116
|
+
sig: undefined,
|
|
117
|
+
nowSeconds: now,
|
|
118
|
+
});
|
|
119
|
+
assert.strictEqual(result.authorized === false && result.status, 401);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("fails closed when no secret is configured", () => {
|
|
123
|
+
const result = authorizeUploadRequest({
|
|
124
|
+
secret: undefined,
|
|
125
|
+
relativePath: KEY,
|
|
126
|
+
exp: String(exp),
|
|
127
|
+
max: "2048",
|
|
128
|
+
sig: sign(KEY, exp, 2048),
|
|
129
|
+
nowSeconds: now,
|
|
130
|
+
});
|
|
131
|
+
assert.strictEqual(result.authorized === false && result.status, 500);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a read grant for the same path is not a write grant", () => {
|
|
135
|
+
// The content signer covers the path and an expiry under its own label.
|
|
136
|
+
const readSignature = signStoragePath(
|
|
137
|
+
SECRET,
|
|
138
|
+
"remit-content-url-signing-v1",
|
|
139
|
+
KEY,
|
|
140
|
+
[exp],
|
|
141
|
+
);
|
|
142
|
+
const result = authorizeUploadRequest({
|
|
143
|
+
secret: SECRET,
|
|
144
|
+
relativePath: KEY,
|
|
145
|
+
exp: String(exp),
|
|
146
|
+
max: "2048",
|
|
147
|
+
sig: readSignature,
|
|
148
|
+
nowSeconds: now,
|
|
149
|
+
});
|
|
150
|
+
assert.strictEqual(result.authorized, false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("attachment keys", () => {
|
|
155
|
+
test("parseOutboxAttachmentKey recovers the ids the upload route needs", () => {
|
|
156
|
+
assert.deepStrictEqual(parseOutboxAttachmentKey(KEY), {
|
|
157
|
+
accountConfigId: "cfg1",
|
|
158
|
+
accountId: "acc1",
|
|
159
|
+
outboxMessageId: "draft1",
|
|
160
|
+
outboxAttachmentId: "att1",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("parseOutboxAttachmentKey refuses a message content key", () => {
|
|
165
|
+
assert.strictEqual(
|
|
166
|
+
parseOutboxAttachmentKey("accounts/cfg1/acc1/messages/msg1/parts/1.2"),
|
|
167
|
+
null,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("parseOutboxAttachmentKey refuses a traversal, signature or no signature", () => {
|
|
172
|
+
// `..` matches a naive [^/]+ and the write side joins this onto a
|
|
173
|
+
// filesystem root, so the shape has to refuse it before the HMAC is the
|
|
174
|
+
// only thing left standing.
|
|
175
|
+
assert.strictEqual(
|
|
176
|
+
parseOutboxAttachmentKey(
|
|
177
|
+
"accounts/cfg1/acc1/outbox/draft1/attachments/..",
|
|
178
|
+
),
|
|
179
|
+
null,
|
|
180
|
+
);
|
|
181
|
+
assert.strictEqual(
|
|
182
|
+
parseOutboxAttachmentKey("accounts/../../etc/outbox/x/attachments/y"),
|
|
183
|
+
null,
|
|
184
|
+
);
|
|
185
|
+
assert.strictEqual(
|
|
186
|
+
parseOutboxAttachmentKey(
|
|
187
|
+
"accounts/cfg1/acc1/outbox/draft1/attachments/a.b",
|
|
188
|
+
),
|
|
189
|
+
null,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("minting an upload URL", () => {
|
|
195
|
+
test("the filesystem backend mints a URL its own route will admit", async () => {
|
|
196
|
+
const basePath = await mkdtemp(join(tmpdir(), "remit-upload-url-"));
|
|
197
|
+
try {
|
|
198
|
+
const storage = createFilesystemStorageService(basePath, {
|
|
199
|
+
origin: "https://mail.example.test/",
|
|
200
|
+
signingSecret: SECRET,
|
|
201
|
+
});
|
|
202
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 900;
|
|
203
|
+
|
|
204
|
+
const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl({
|
|
205
|
+
accountConfigId: "cfg1",
|
|
206
|
+
accountId: "acc1",
|
|
207
|
+
outboxMessageId: "draft1",
|
|
208
|
+
outboxAttachmentId: "att1",
|
|
209
|
+
sizeBytes: 2048,
|
|
210
|
+
expiresAt,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const url = new URL(uploadUrl);
|
|
214
|
+
assert.strictEqual(url.origin, "https://mail.example.test");
|
|
215
|
+
assert.strictEqual(url.pathname, `${UPLOAD_ROUTE_PREFIX}${KEY}`);
|
|
216
|
+
|
|
217
|
+
const admitted = authorizeUploadRequest({
|
|
218
|
+
secret: SECRET,
|
|
219
|
+
relativePath: url.pathname.slice(UPLOAD_ROUTE_PREFIX.length),
|
|
220
|
+
exp: url.searchParams.get("exp") ?? undefined,
|
|
221
|
+
max: url.searchParams.get("max") ?? undefined,
|
|
222
|
+
sig: url.searchParams.get("sig") ?? undefined,
|
|
223
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
224
|
+
});
|
|
225
|
+
assert.deepStrictEqual(admitted, { authorized: true, maxBytes: 2048 });
|
|
226
|
+
} finally {
|
|
227
|
+
await rm(basePath, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("the filesystem backend refuses to mint without an origin and a secret", async () => {
|
|
232
|
+
const basePath = await mkdtemp(join(tmpdir(), "remit-upload-url-"));
|
|
233
|
+
try {
|
|
234
|
+
const storage = createFilesystemStorageService(basePath);
|
|
235
|
+
await assert.rejects(
|
|
236
|
+
() =>
|
|
237
|
+
storage.createOutboxAttachmentUploadUrl({
|
|
238
|
+
accountConfigId: "cfg1",
|
|
239
|
+
accountId: "acc1",
|
|
240
|
+
outboxMessageId: "draft1",
|
|
241
|
+
outboxAttachmentId: "att1",
|
|
242
|
+
sizeBytes: 1,
|
|
243
|
+
expiresAt: Math.floor(Date.now() / 1000) + 60,
|
|
244
|
+
}),
|
|
245
|
+
/cannot mint an upload URL/,
|
|
246
|
+
);
|
|
247
|
+
} finally {
|
|
248
|
+
await rm(basePath, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("the S3 backend mints a presigned PUT with the size inside the signature", async () => {
|
|
253
|
+
const client = new S3Client({
|
|
254
|
+
region: "eu-west-1",
|
|
255
|
+
credentials: {
|
|
256
|
+
accessKeyId: "AKIATESTTESTTEST",
|
|
257
|
+
secretAccessKey: "test-secret",
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
const storage = createS3StorageService(client, "a-bucket");
|
|
261
|
+
const params = {
|
|
262
|
+
accountConfigId: "cfg1",
|
|
263
|
+
accountId: "acc1",
|
|
264
|
+
outboxMessageId: "draft1",
|
|
265
|
+
outboxAttachmentId: "att1",
|
|
266
|
+
sizeBytes: 2048,
|
|
267
|
+
expiresAt: Math.floor(Date.now() / 1000) + 900,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const { uploadUrl } = await storage.createOutboxAttachmentUploadUrl(params);
|
|
271
|
+
const url = new URL(uploadUrl);
|
|
272
|
+
|
|
273
|
+
assert.ok(url.pathname.endsWith(KEY));
|
|
274
|
+
// content-length inside SignedHeaders is what makes the declared size
|
|
275
|
+
// unforgeable: S3 recomputes the signature over the length it is sent.
|
|
276
|
+
assert.match(
|
|
277
|
+
url.searchParams.get("X-Amz-SignedHeaders") ?? "",
|
|
278
|
+
/content-length/,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const larger = await storage.createOutboxAttachmentUploadUrl({
|
|
282
|
+
...params,
|
|
283
|
+
sizeBytes: 4096,
|
|
284
|
+
});
|
|
285
|
+
assert.notStrictEqual(
|
|
286
|
+
url.searchParams.get("X-Amz-Signature"),
|
|
287
|
+
new URL(larger.uploadUrl).searchParams.get("X-Amz-Signature"),
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { verifyStoragePath } from "./signed-path.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The self-hosted half of the upload seam: what a minted URL looks like when
|
|
5
|
+
* there is no block storage to presign against, and how the route that receives
|
|
6
|
+
* the bytes decides to accept them.
|
|
7
|
+
*
|
|
8
|
+
* A hosted deployment mints a presigned PUT instead and none of this runs — the
|
|
9
|
+
* bytes never reach the deployment at all. Both halves bind the same two
|
|
10
|
+
* things, an expiry and an exact byte count, so a client sees one contract.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Domain separation: a read grant must never be presentable as a write grant. */
|
|
14
|
+
export const UPLOAD_URL_SIGNING_LABEL = "remit-outbox-upload-url-signing-v1";
|
|
15
|
+
|
|
16
|
+
export const UPLOAD_ROUTE_PREFIX = "/outbox-upload/";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long a mint's URL, and the reservation behind it, stay good. Long enough
|
|
20
|
+
* for a slow phone to push 25 MB, short enough that a draft abandoned mid-upload
|
|
21
|
+
* stops holding room within the same session.
|
|
22
|
+
*/
|
|
23
|
+
export const UPLOAD_URL_TTL_SECONDS = 900;
|
|
24
|
+
|
|
25
|
+
export interface AuthorizeUploadInput {
|
|
26
|
+
secret: string | undefined;
|
|
27
|
+
/** Decoded storage key the signature was minted over. */
|
|
28
|
+
relativePath: string;
|
|
29
|
+
exp: string | undefined;
|
|
30
|
+
max: string | undefined;
|
|
31
|
+
sig: string | undefined;
|
|
32
|
+
nowSeconds: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type AuthorizeUploadResult =
|
|
36
|
+
| { authorized: true; maxBytes: number }
|
|
37
|
+
| { authorized: false; status: number; reason: string };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Decide whether an upload request may write to the key it names.
|
|
41
|
+
*
|
|
42
|
+
* Pure, so the decision is testable without a live server. Mirrors
|
|
43
|
+
* `authorizeContentRequest` on the read side, with one addition: the byte count
|
|
44
|
+
* is part of the signed message, so the caller learns from the signature how
|
|
45
|
+
* much this URL is allowed to carry and can cut the stream off there.
|
|
46
|
+
*/
|
|
47
|
+
export const authorizeUploadRequest = (
|
|
48
|
+
input: AuthorizeUploadInput,
|
|
49
|
+
): AuthorizeUploadResult => {
|
|
50
|
+
if (!input.secret || input.secret.length === 0) {
|
|
51
|
+
// No secret means no upload URL could have been signed, so nothing
|
|
52
|
+
// presented here can be trusted. Fail closed.
|
|
53
|
+
return {
|
|
54
|
+
authorized: false,
|
|
55
|
+
status: 500,
|
|
56
|
+
reason: "upload-signing-not-configured",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const exp = Number(input.exp);
|
|
61
|
+
const maxBytes = Number(input.max);
|
|
62
|
+
if (!Number.isInteger(exp) || !Number.isInteger(maxBytes)) {
|
|
63
|
+
return { authorized: false, status: 403, reason: "malformed" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = verifyStoragePath(
|
|
67
|
+
input.secret,
|
|
68
|
+
UPLOAD_URL_SIGNING_LABEL,
|
|
69
|
+
input.relativePath,
|
|
70
|
+
[exp, maxBytes],
|
|
71
|
+
input.sig,
|
|
72
|
+
input.nowSeconds,
|
|
73
|
+
);
|
|
74
|
+
if (result.valid) return { authorized: true, maxBytes };
|
|
75
|
+
|
|
76
|
+
const status = result.reason === "missing" ? 401 : 403;
|
|
77
|
+
return { authorized: false, status, reason: result.reason };
|
|
78
|
+
};
|
package/src/s3.ts
CHANGED
|
@@ -11,10 +11,26 @@ export const createStorageService = (): StorageService => {
|
|
|
11
11
|
if (bucketName) {
|
|
12
12
|
const client = new S3Client({
|
|
13
13
|
endpoint: process.env.S3_ENDPOINT,
|
|
14
|
+
// The SDK default (WHEN_SUPPORTED) hangs a CRC32 of an empty body off
|
|
15
|
+
// every presigned PUT as signed query params. Real S3 tolerates it;
|
|
16
|
+
// MinIO and LocalStack reject the request, and S3_ENDPOINT exists to
|
|
17
|
+
// point at exactly those.
|
|
18
|
+
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
14
19
|
});
|
|
15
20
|
return createS3StorageService(client, bucketName);
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
const basePath = process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
|
|
19
|
-
|
|
24
|
+
// BETTER_AUTH_URL is the deployment's own public base — the value the token
|
|
25
|
+
// issuer and the JWKS discovery document already agree on — so a minted
|
|
26
|
+
// upload URL resolves from wherever the browser reached the API. Both it and
|
|
27
|
+
// the signing secret are required on the self-host stack; without them a
|
|
28
|
+
// mint fails loud inside the backend rather than handing out a URL that
|
|
29
|
+
// cannot be verified.
|
|
30
|
+
const origin = process.env.BETTER_AUTH_URL;
|
|
31
|
+
const signingSecret = process.env.BETTER_AUTH_SECRET;
|
|
32
|
+
return createFilesystemStorageService(
|
|
33
|
+
basePath,
|
|
34
|
+
origin && signingSecret ? { origin, signingSecret } : undefined,
|
|
35
|
+
);
|
|
20
36
|
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The HMAC scheme behind every signed storage URL this stack mints.
|
|
5
|
+
*
|
|
6
|
+
* It started as the read side — `/content/*` is served against a signature
|
|
7
|
+
* because a bearer token cannot ride on an `<img src>` inside email HTML — and
|
|
8
|
+
* the write side needs exactly the same capability: a URL that grants access to
|
|
9
|
+
* one path, for a while, and is unforgeable without the key. Rather than a
|
|
10
|
+
* second scheme, both use this one with a different derivation label, so a
|
|
11
|
+
* signature minted to read content can never be replayed as permission to
|
|
12
|
+
* write, and vice versa.
|
|
13
|
+
*
|
|
14
|
+
* The signed message is the storage path followed by every claim in a fixed
|
|
15
|
+
* order. Because the path is part of the message, a signature minted for one
|
|
16
|
+
* account's object cannot be presented for another's.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const deriveSigningKey = (masterSecret: string, label: string): Buffer =>
|
|
20
|
+
createHmac("sha256", masterSecret).update(label).digest();
|
|
21
|
+
|
|
22
|
+
const canonicalMessage = (
|
|
23
|
+
relativePath: string,
|
|
24
|
+
claims: readonly number[],
|
|
25
|
+
): string => [relativePath, ...claims].join("\n");
|
|
26
|
+
|
|
27
|
+
const computeSignature = (
|
|
28
|
+
key: Buffer,
|
|
29
|
+
relativePath: string,
|
|
30
|
+
claims: readonly number[],
|
|
31
|
+
): string =>
|
|
32
|
+
createHmac("sha256", key)
|
|
33
|
+
.update(canonicalMessage(relativePath, claims))
|
|
34
|
+
.digest("base64url");
|
|
35
|
+
|
|
36
|
+
export const signStoragePath = (
|
|
37
|
+
masterSecret: string,
|
|
38
|
+
label: string,
|
|
39
|
+
relativePath: string,
|
|
40
|
+
claims: readonly number[],
|
|
41
|
+
): string =>
|
|
42
|
+
computeSignature(deriveSigningKey(masterSecret, label), relativePath, claims);
|
|
43
|
+
|
|
44
|
+
export type SignedPathFailure =
|
|
45
|
+
| "missing"
|
|
46
|
+
| "malformed"
|
|
47
|
+
| "expired"
|
|
48
|
+
| "bad-signature";
|
|
49
|
+
|
|
50
|
+
export type SignedPathResult =
|
|
51
|
+
| { valid: true }
|
|
52
|
+
| { valid: false; reason: SignedPathFailure };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Verify a presented signature against the path and claims it must cover. The
|
|
56
|
+
* first claim is always the expiry, in unix seconds. Pure, so the decision can
|
|
57
|
+
* be unit-tested without a live server.
|
|
58
|
+
*/
|
|
59
|
+
export const verifyStoragePath = (
|
|
60
|
+
masterSecret: string,
|
|
61
|
+
label: string,
|
|
62
|
+
relativePath: string,
|
|
63
|
+
claims: readonly number[],
|
|
64
|
+
sig: string | undefined,
|
|
65
|
+
nowSeconds: number,
|
|
66
|
+
): SignedPathResult => {
|
|
67
|
+
if (!sig || claims.length === 0) return { valid: false, reason: "missing" };
|
|
68
|
+
|
|
69
|
+
const [exp] = claims;
|
|
70
|
+
if (!claims.every((claim) => Number.isInteger(claim) && claim > 0)) {
|
|
71
|
+
return { valid: false, reason: "malformed" };
|
|
72
|
+
}
|
|
73
|
+
if (exp < nowSeconds) return { valid: false, reason: "expired" };
|
|
74
|
+
|
|
75
|
+
const expected = Buffer.from(
|
|
76
|
+
signStoragePath(masterSecret, label, relativePath, claims),
|
|
77
|
+
);
|
|
78
|
+
const presented = Buffer.from(sig);
|
|
79
|
+
if (expected.length !== presented.length) {
|
|
80
|
+
return { valid: false, reason: "bad-signature" };
|
|
81
|
+
}
|
|
82
|
+
if (!timingSafeEqual(expected, presented)) {
|
|
83
|
+
return { valid: false, reason: "bad-signature" };
|
|
84
|
+
}
|
|
85
|
+
return { valid: true };
|
|
86
|
+
};
|
package/src/storage.test.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
buildExtractedSkippedKey,
|
|
15
15
|
buildExtractedTextKey,
|
|
16
16
|
buildMessageBodyKey,
|
|
17
|
+
buildOutboxAttachmentKey,
|
|
18
|
+
buildOutboxAttachmentPrefix,
|
|
17
19
|
buildParsedBodyKey,
|
|
18
20
|
computeChecksum,
|
|
19
21
|
createMockStorageService,
|
|
@@ -85,6 +87,26 @@ describe("path builders", () => {
|
|
|
85
87
|
);
|
|
86
88
|
});
|
|
87
89
|
|
|
90
|
+
test("buildOutboxAttachmentKey hangs the file off its own draft, under the same tenant prefix", () => {
|
|
91
|
+
const key = buildOutboxAttachmentKey("cfg1", "acc123", "draft9", "att7");
|
|
92
|
+
assert.strictEqual(
|
|
93
|
+
key,
|
|
94
|
+
"accounts/cfg1/acc123/outbox/draft9/attachments/att7",
|
|
95
|
+
);
|
|
96
|
+
assert.ok(
|
|
97
|
+
key.startsWith(buildOutboxAttachmentPrefix("cfg1", "acc123", "draft9")),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("parseContentStorageKey does not recognise an outbox attachment key", () => {
|
|
102
|
+
assert.strictEqual(
|
|
103
|
+
parseContentStorageKey(
|
|
104
|
+
buildOutboxAttachmentKey("cfg1", "acc123", "draft9", "att7"),
|
|
105
|
+
),
|
|
106
|
+
null,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
88
110
|
test("computeChecksum returns SHA-256 hex", () => {
|
|
89
111
|
const content = Buffer.from("test content");
|
|
90
112
|
const expected = createHash("sha256").update(content).digest("hex");
|
|
@@ -744,6 +766,120 @@ describe("createFilesystemStorageService", () => {
|
|
|
744
766
|
assert.deepStrictEqual(items, []);
|
|
745
767
|
});
|
|
746
768
|
|
|
769
|
+
test("storeOutboxAttachment writes the bytes uncompressed so the size on disk is the size the cap counts", async () => {
|
|
770
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
771
|
+
// Highly compressible: gzip would make the stored size disagree with the
|
|
772
|
+
// decoded size the per-message cap is expressed in.
|
|
773
|
+
const content = Buffer.alloc(4096, 0x61);
|
|
774
|
+
|
|
775
|
+
const ref = await storage.storeOutboxAttachment({
|
|
776
|
+
accountConfigId: "cfg1",
|
|
777
|
+
accountId: "acc123",
|
|
778
|
+
outboxMessageId: "draft1",
|
|
779
|
+
outboxAttachmentId: "att1",
|
|
780
|
+
content,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
assert.strictEqual(ref.contentEncoding, ContentEncoding.None);
|
|
784
|
+
assert.strictEqual(ref.sizeBytes, content.length);
|
|
785
|
+
assert.strictEqual(ref.checksumSha256, computeChecksum(content));
|
|
786
|
+
assert.deepStrictEqual(await storage.retrieve(ref.uri), content);
|
|
787
|
+
|
|
788
|
+
const [item] = await storage.listOutboxAttachments(
|
|
789
|
+
"cfg1",
|
|
790
|
+
"acc123",
|
|
791
|
+
"draft1",
|
|
792
|
+
);
|
|
793
|
+
assert.strictEqual(item.sizeBytes, content.length);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("listOutboxAttachments returns only the named draft, and every object in it", async () => {
|
|
797
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
798
|
+
for (const id of ["a", "b", "c"]) {
|
|
799
|
+
await storage.storeOutboxAttachment({
|
|
800
|
+
accountConfigId: "cfg1",
|
|
801
|
+
accountId: "acc123",
|
|
802
|
+
outboxMessageId: "draft-many",
|
|
803
|
+
outboxAttachmentId: id,
|
|
804
|
+
content: Buffer.from(`file-${id}`),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
await storage.storeOutboxAttachment({
|
|
808
|
+
accountConfigId: "cfg1",
|
|
809
|
+
accountId: "acc123",
|
|
810
|
+
outboxMessageId: "draft-other",
|
|
811
|
+
outboxAttachmentId: "d",
|
|
812
|
+
content: Buffer.from("elsewhere"),
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const all = await storage.listOutboxAttachments(
|
|
816
|
+
"cfg1",
|
|
817
|
+
"acc123",
|
|
818
|
+
"draft-many",
|
|
819
|
+
);
|
|
820
|
+
assert.deepStrictEqual(all.map((item) => item.outboxAttachmentId).sort(), [
|
|
821
|
+
"a",
|
|
822
|
+
"b",
|
|
823
|
+
"c",
|
|
824
|
+
]);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("listOutboxAttachments returns an empty array for a draft that has none", async () => {
|
|
828
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
829
|
+
assert.deepStrictEqual(
|
|
830
|
+
await storage.listOutboxAttachments("cfg1", "acc123", "draft-empty"),
|
|
831
|
+
[],
|
|
832
|
+
);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("deleting a draft's attachments empties the prefix and the ledger with it", async () => {
|
|
836
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
837
|
+
await storage.storeOutboxAttachment({
|
|
838
|
+
accountConfigId: "cfg1",
|
|
839
|
+
accountId: "acc123",
|
|
840
|
+
outboxMessageId: "draft-gone",
|
|
841
|
+
outboxAttachmentId: "att1",
|
|
842
|
+
content: Buffer.alloc(4),
|
|
843
|
+
});
|
|
844
|
+
await storage.deleteOutboxAttachments("cfg1", "acc123", "draft-gone");
|
|
845
|
+
|
|
846
|
+
assert.deepStrictEqual(
|
|
847
|
+
await storage.listOutboxAttachments("cfg1", "acc123", "draft-gone"),
|
|
848
|
+
[],
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("listOutboxDraftsWithAttachments finds prefixes whose row may be long gone", async () => {
|
|
853
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
854
|
+
for (const draft of ["draft-x", "draft-y"]) {
|
|
855
|
+
await storage.storeOutboxAttachment({
|
|
856
|
+
accountConfigId: "cfg-sweep",
|
|
857
|
+
accountId: "acc-sweep",
|
|
858
|
+
outboxMessageId: draft,
|
|
859
|
+
outboxAttachmentId: "att1",
|
|
860
|
+
content: Buffer.alloc(4),
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
assert.deepStrictEqual(
|
|
865
|
+
(
|
|
866
|
+
await storage.listOutboxDraftsWithAttachments("cfg-sweep", "acc-sweep")
|
|
867
|
+
).sort(),
|
|
868
|
+
["draft-x", "draft-y"],
|
|
869
|
+
);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("deleting attachments on a draft that never had any is quiet", async () => {
|
|
873
|
+
const storage = createFilesystemStorageService(testBasePath);
|
|
874
|
+
await storage.deleteOutboxAttachments("cfg1", "acc123", "draft-never");
|
|
875
|
+
await storage.deleteOutboxAttachment(
|
|
876
|
+
"cfg1",
|
|
877
|
+
"acc123",
|
|
878
|
+
"draft-never",
|
|
879
|
+
"att1",
|
|
880
|
+
);
|
|
881
|
+
});
|
|
882
|
+
|
|
747
883
|
test("cleanup test directory", async () => {
|
|
748
884
|
await rm(testBasePath, { recursive: true, force: true });
|
|
749
885
|
});
|