@sovereignfs/sdk 1.25.0 → 2.0.1

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.
Files changed (55) hide show
  1. package/LICENSE +373 -661
  2. package/README.md +103 -0
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +4 -0
  5. package/dist/chunk-NYL4LLXF.js +65 -0
  6. package/dist/index.d.ts +57 -529
  7. package/dist/index.js +1283 -572
  8. package/dist/next.d.ts +37 -0
  9. package/dist/next.js +68 -0
  10. package/dist/session-DJl2XYMc.d.ts +44 -0
  11. package/dist/sovereign-CazGKEVc.d.ts +517 -0
  12. package/dist/testing.d.ts +181 -0
  13. package/dist/testing.js +560 -0
  14. package/package.json +53 -27
  15. package/dist/chunk-MLKGABMK.js +0 -9
  16. package/dist/chunk-NQLGPAL6.js +0 -62
  17. package/dist/e2ee-crypto.d.ts +0 -84
  18. package/dist/e2ee-crypto.js +0 -162
  19. package/dist/e2ee-device.d.ts +0 -21
  20. package/dist/e2ee-device.js +0 -13
  21. package/dist/e2ee-object.d.ts +0 -50
  22. package/dist/e2ee-object.js +0 -75
  23. package/dist/e2ee-state-U-GEJ7oL.d.ts +0 -411
  24. package/dist/e2ee-state.d.ts +0 -1
  25. package/dist/e2ee-state.js +0 -36
  26. package/src/__tests__/e2ee-crypto.test.ts +0 -156
  27. package/src/__tests__/e2ee-object.test.ts +0 -90
  28. package/src/__tests__/e2ee-state.test.ts +0 -83
  29. package/src/__tests__/sdk.test.ts +0 -427
  30. package/src/__tests__/sessions.test.ts +0 -63
  31. package/src/activity.ts +0 -19
  32. package/src/auth.ts +0 -134
  33. package/src/connections.ts +0 -106
  34. package/src/data.ts +0 -88
  35. package/src/db.ts +0 -34
  36. package/src/directory.ts +0 -31
  37. package/src/e2ee-crypto.ts +0 -260
  38. package/src/e2ee-device.ts +0 -75
  39. package/src/e2ee-object.ts +0 -118
  40. package/src/e2ee-state.ts +0 -72
  41. package/src/e2ee.ts +0 -65
  42. package/src/env.ts +0 -37
  43. package/src/errors.ts +0 -46
  44. package/src/host.ts +0 -251
  45. package/src/index.ts +0 -160
  46. package/src/mailer.ts +0 -7
  47. package/src/notifications.ts +0 -36
  48. package/src/platform.ts +0 -10
  49. package/src/plugins.ts +0 -146
  50. package/src/portability.ts +0 -176
  51. package/src/secrets.ts +0 -68
  52. package/src/sessions.ts +0 -36
  53. package/src/storage.ts +0 -49
  54. package/src/types.ts +0 -418
  55. package/src/unimplemented.ts +0 -48
@@ -1,411 +0,0 @@
1
- interface SessionUser {
2
- id: string;
3
- tenantId: string;
4
- email: string;
5
- name: string | null;
6
- image: string | null;
7
- role: string;
8
- /** Effective capability strings derived from the role preset (RFC 0021). */
9
- capabilities: readonly string[];
10
- }
11
- interface Session {
12
- user: SessionUser;
13
- /** Session expiry as a Unix timestamp (seconds). */
14
- expiresAt: number;
15
- }
16
- /** An authenticated session for the current user (SRS ACC-05). */
17
- interface ActiveSession {
18
- /** Opaque session token — pass to `sdk.auth.revokeSession` to end it. */
19
- token: string;
20
- /** Whether this is the session making the current request. */
21
- current: boolean;
22
- /** Raw User-Agent string of the device that created the session, if known. */
23
- userAgent: string | null;
24
- ipAddress: string | null;
25
- /** Creation, last-active, and expiry as ISO 8601 strings. */
26
- createdAt: string;
27
- updatedAt: string;
28
- expiresAt: string;
29
- }
30
- interface ChangePasswordInput {
31
- currentPassword: string;
32
- newPassword: string;
33
- }
34
- /** Display-safe user shape returned by `sdk.directory` (RFC 0041). */
35
- interface DirectoryUser {
36
- id: string;
37
- email: string;
38
- name: string | null;
39
- image: string | null;
40
- }
41
- interface SearchUsersInput {
42
- query: string;
43
- limit?: number;
44
- }
45
- interface ResolveUsersInput {
46
- ids: string[];
47
- }
48
- interface MailOptions {
49
- to: string | string[];
50
- subject: string;
51
- html?: string;
52
- text?: string;
53
- from?: string;
54
- }
55
- interface PlatformConfig {
56
- /**
57
- * Name of the default tenant row — set in Console › Settings › Instance name.
58
- * In v1 (single-tenant) this is the platform-wide workspace name.
59
- * Multi-tenant deployments (post-v1) will surface per-tenant names separately.
60
- */
61
- tenantName: string;
62
- inviteOnly: boolean;
63
- version: string;
64
- /** Instance name; falls back to tenantName when no white-label name is configured. */
65
- instanceName: string;
66
- /** Validated 6-digit hex colour overriding --sv-color-accent, or undefined when unset. */
67
- instancePrimaryColor?: string;
68
- /**
69
- * Stable UUID that uniquely identifies this Sovereign installation.
70
- * Generated once at bootstrap and never changes. Use this to identify the
71
- * instance in federated contexts, license checks, or analytics.
72
- */
73
- instanceId: string;
74
- }
75
- /**
76
- * An activity event a plugin records via `sdk.activity.log()` (RFC 0005 —
77
- * reserved surface, not yet implemented). The plugin supplies only the event
78
- * shape; the runtime injects the actor, tenant, and emitting plugin and forces
79
- * the event to be user-scoped, so a plugin cannot forge actor identity.
80
- */
81
- interface ActivityLogEntry {
82
- /** Dotted action verb, e.g. `"list.created"`. Namespaced by the runtime per plugin. */
83
- action: string;
84
- /** The user this event is about, if any — drives the user's personal-feed visibility. */
85
- subjectUserId?: string;
86
- /** Generic target kind, e.g. `"list"`. */
87
- targetType?: string;
88
- /** Generic target id, e.g. the list id. */
89
- targetId?: string;
90
- /** Human-readable one-line summary. */
91
- summary?: string;
92
- /** Structured detail (before/after, etc.). Avoid PII beyond what is necessary. */
93
- metadata?: Record<string, unknown>;
94
- }
95
- /**
96
- * The Drizzle client returned by `sdk.db.getClient()` — the live platform
97
- * Drizzle instance. Kept opaque (`unknown`) at the contract level so the
98
- * published SDK takes no dependency on a specific dialect's Drizzle types;
99
- * plugins type their own queries through their schema.
100
- */
101
- type DrizzleClient = unknown;
102
- /**
103
- * Input to `sdk.notifications.send()` (RFC 0015).
104
- * The runtime auto-populates `source`, `sourceType`, and `tenantId` from
105
- * request context — plugins never set those.
106
- */
107
- interface SendNotificationInput {
108
- /** User ID of the intended recipient. */
109
- recipientUserId: string;
110
- /** Short notification headline. */
111
- title: string;
112
- /** Optional longer body text. */
113
- body?: string;
114
- /** In-app route to navigate to when the notification is clicked. */
115
- url?: string;
116
- /**
117
- * Category tag — drives mute preferences.
118
- * Built-in categories: `'info'` (default), `'announcement'`, `'security'`.
119
- * Custom plugin categories are allowed; `'security'` cannot be muted.
120
- */
121
- category?: string;
122
- /**
123
- * Optional URL to an image shown in the OS push notification (the Push
124
- * API's `icon` option — not an `@sovereignfs/ui` `<Icon>` name; nothing
125
- * in-app reads this field today). Defaults to the sending plugin's own
126
- * `/plugin-icons/<pluginId>.svg` when unset — only set this to override
127
- * with a notification-specific image instead of the plugin's own icon.
128
- */
129
- icon?: string;
130
- }
131
- /** Plugin-scoped file storage object metadata (RFC 0044). */
132
- interface StorageObject {
133
- id: string;
134
- pluginId: string;
135
- ownerUserId: string | null;
136
- key: string;
137
- contentType: string;
138
- size: number;
139
- checksum: string;
140
- /** The `metadata` passed to `put()`, round-tripped back on `get()`/`list()`. */
141
- metadata: Record<string, unknown> | null;
142
- createdAt: number;
143
- updatedAt: number;
144
- }
145
- interface StoragePutInput {
146
- key: string;
147
- body: Blob | ArrayBuffer | Uint8Array;
148
- contentType: string;
149
- /** Owner of the object. Omit for a plugin-scoped (not per-user) object. */
150
- ownerUserId?: string;
151
- metadata?: Record<string, unknown> | null;
152
- }
153
- interface StorageContext {
154
- tenantId: string;
155
- pluginId: string;
156
- userId: string | null;
157
- }
158
- /**
159
- * Client-side encryption profile shapes (RFC 0060, epic task 8.9). Persisted
160
- * via `sdk.e2ee`; the browser-only crypto that produces/consumes the
161
- * ciphertext these methods store lives in `e2ee-crypto`/`e2ee-device`/
162
- * `e2ee-object`/`e2ee-state`. Distinct from server-side field crypto
163
- * (`sdk.crypto.encryptField()`, RFC 0008): the runtime can decrypt a
164
- * server-side field; it can never decrypt a client-side encrypted object.
165
- *
166
- * The Client Master Key (CMK) itself never exists server-side, plaintext or
167
- * otherwise — only wrapped copies (`E2eeRecoveryWrapper`/
168
- * `E2eeDeviceEnrollment`) and non-sensitive KDF/algorithm metadata do.
169
- */
170
- type E2eeProfileStatus = 'active' | 'disabled';
171
- /** Whether the current user has set up client-side encryption at all. */
172
- interface E2eeProfile {
173
- id: string;
174
- userId: string;
175
- status: E2eeProfileStatus;
176
- /** e.g. `'AES-GCM-256'` — the algorithm used to encrypt objects under the CMK. */
177
- cmkAlgorithm: string;
178
- createdAt: number;
179
- updatedAt: number;
180
- }
181
- /** The CMK wrapped by a key derived from the user's recovery secret. One per user. */
182
- interface E2eeRecoveryWrapper {
183
- id: string;
184
- userId: string;
185
- wrappedCmk: string;
186
- kdfAlgorithm: string;
187
- /** Opaque JSON-encoded KDF parameters (iterations, memory cost, etc.). */
188
- kdfParams: string;
189
- kdfSalt: string;
190
- algorithmVersion: string;
191
- createdAt: number;
192
- updatedAt: number;
193
- }
194
- /** The CMK wrapped by one enrolled device's own key. Many per user. */
195
- interface E2eeDeviceEnrollment {
196
- id: string;
197
- userId: string;
198
- deviceId: string;
199
- deviceLabel: string | null;
200
- wrappedCmk: string;
201
- algorithmVersion: string;
202
- createdAt: number;
203
- lastUsedAt: number | null;
204
- /** `null` while the device is still enrolled. */
205
- revokedAt: number | null;
206
- }
207
- /**
208
- * Normalized state a plugin checks before touching client-side encrypted
209
- * data — the SDK never hides the recovery model (RFC 0060 "SDK
210
- * responsibilities"). `unsupported` means the browser lacks required
211
- * WebCrypto primitives.
212
- */
213
- type E2eeState = 'not-set-up' | 'locked' | 'unlocked' | 'unsupported';
214
- /** No plugin scoping — one profile per user, regardless of caller. */
215
- interface E2eeContext {
216
- tenantId: string;
217
- userId: string;
218
- }
219
- interface CreateE2eeProfileInput {
220
- cmkAlgorithm: string;
221
- }
222
- interface SetE2eeRecoveryWrapperInput {
223
- wrappedCmk: string;
224
- kdfAlgorithm: string;
225
- kdfParams: string;
226
- kdfSalt: string;
227
- algorithmVersion: string;
228
- }
229
- interface EnrollE2eeDeviceInput {
230
- deviceId: string;
231
- deviceLabel: string | null;
232
- wrappedCmk: string;
233
- algorithmVersion: string;
234
- }
235
- /** Runtime-created secret scope for the experimental plugin vault (RFC 0043). */
236
- type SecretScope = 'user' | 'plugin' | 'instance';
237
- /** Metadata returned for a stored secret. Plaintext values are never included. */
238
- interface SecretRef {
239
- id: string;
240
- scope: SecretScope;
241
- label: string;
242
- metadata: Record<string, unknown> | null;
243
- createdAt: number;
244
- updatedAt: number;
245
- lastUsedAt: number | null;
246
- }
247
- interface CreateSecretInput {
248
- scope: SecretScope;
249
- label: string;
250
- value: string;
251
- metadata?: Record<string, unknown> | null;
252
- }
253
- interface UpdateSecretInput {
254
- id: string;
255
- value: string;
256
- }
257
- interface SecretContext {
258
- tenantId: string;
259
- pluginId: string;
260
- userId: string | null;
261
- capabilities: readonly string[];
262
- }
263
- /** Runtime-created external provider connection scope (RFC 0049). */
264
- type ConnectionScope = 'user' | 'plugin' | 'instance';
265
- /** Metadata status for a plugin-owned external provider connection. */
266
- type ConnectionStatus = 'connected' | 'needs_reauth' | 'paused' | 'disconnected' | 'error';
267
- /** Metadata returned for an external connection. Secret values are never included. */
268
- interface ConnectionRef {
269
- id: string;
270
- scope: ConnectionScope;
271
- provider: string;
272
- label: string;
273
- status: ConnectionStatus;
274
- secretRef: string | null;
275
- metadata: Record<string, unknown> | null;
276
- lastCheckedAt: number | null;
277
- lastUsedAt: number | null;
278
- lastError: SanitizedConnectionError | null;
279
- createdAt: number;
280
- updatedAt: number;
281
- disconnectedAt: number | null;
282
- }
283
- interface CreateConnectionInput {
284
- scope: ConnectionScope;
285
- provider: string;
286
- label: string;
287
- secretRef?: string | null;
288
- metadata?: Record<string, unknown> | null;
289
- }
290
- interface ConnectionListFilter {
291
- provider?: string;
292
- scope?: ConnectionScope;
293
- includeDisconnected?: boolean;
294
- }
295
- interface UpdateConnectionInput {
296
- label?: string;
297
- status?: ConnectionStatus;
298
- metadata?: Record<string, unknown> | null;
299
- secretRef?: string | null;
300
- lastCheckedAt?: number | null;
301
- }
302
- interface SanitizedConnectionError {
303
- code?: string;
304
- message: string;
305
- retryable?: boolean;
306
- status?: number;
307
- }
308
- interface MarkConnectionErrorInput {
309
- error: SanitizedConnectionError;
310
- status?: Extract<ConnectionStatus, 'error' | 'needs_reauth'>;
311
- }
312
- interface OAuthStateInput {
313
- provider: string;
314
- callbackPath: string;
315
- nonce?: string;
316
- metadata?: Record<string, unknown> | null;
317
- expiresInSeconds?: number;
318
- }
319
- interface ConnectionOAuthState {
320
- pluginId: string;
321
- provider: string;
322
- userId: string;
323
- callbackPath: string;
324
- nonce: string;
325
- metadata: Record<string, unknown> | null;
326
- expiresAt: number;
327
- }
328
- /** Source summary for effective external provider configuration (Task 3.27). */
329
- type ProviderConfigSource = 'env' | 'console' | 'mixed' | 'missing';
330
- /** Server-side effective external provider config for the calling plugin. */
331
- interface ProviderConfig {
332
- provider: string;
333
- label: string;
334
- configured: boolean;
335
- source: ProviderConfigSource;
336
- publicValues: Record<string, string>;
337
- secretValues: Record<string, string>;
338
- callbackUrl: string | null;
339
- scopes: readonly string[];
340
- missingRequired: readonly string[];
341
- }
342
- interface ConnectionContext {
343
- tenantId: string;
344
- pluginId: string;
345
- userId: string | null;
346
- capabilities: readonly string[];
347
- }
348
- /**
349
- * Context passed to a plugin's schedule handler (RFC 0046, Phase 1 subset).
350
- * There is no originating HTTP request — the platform's in-process scheduler
351
- * invokes handlers directly — so the runtime supplies synthetic `headers`
352
- * pre-stamped with the calling plugin's identity. Pass them to SDK methods
353
- * that attribute by request headers (e.g.
354
- * `sdk.notifications.send(input, ctx.headers)`).
355
- */
356
- interface ScheduleContext {
357
- /** The invoking plugin's manifest id (e.g. `fs.sovereign.tasks`). */
358
- pluginId: string;
359
- /** The schedule's manifest-declared id (e.g. `due-reminders`). */
360
- scheduleId: string;
361
- /** Synthetic headers carrying `x-sovereign-plugin-id` for SDK attribution. */
362
- headers: Headers;
363
- }
364
- /**
365
- * A plugin's schedule handler — the **default export** of a manifest-declared
366
- * `schedules[].entry` module. Invoked server-side every `intervalMinutes`
367
- * while the plugin is installed and enabled.
368
- *
369
- * Handlers must be idempotent: the interval is a floor, not an exact cadence,
370
- * and restarts or multiple replicas can invoke a handler again sooner than
371
- * the interval — claim work with conditional updates before acting on it.
372
- * Thrown errors are caught and logged by the platform; there are no retries
373
- * in Phase 1.
374
- */
375
- type ScheduleHandler = (ctx: ScheduleContext) => Promise<void>;
376
-
377
- /**
378
- * Locked/unlocked state detection for client-side encryption (RFC 0060 SDK
379
- * responsibility: "returning normalized unsupported/locked/error states").
380
- * Consolidates the checks every consumer of `sdk.e2ee` needs before touching
381
- * encrypted data — browser support, this device's local wrapping key, and
382
- * whether this device has an active (non-revoked) enrollment — into one
383
- * reusable helper instead of each plugin (Account today, Wallet later)
384
- * reimplementing the same logic inline.
385
- */
386
-
387
- interface E2eeLocalState {
388
- state: E2eeState;
389
- /** Null only when `state` is `'unsupported'` or `'not-set-up'`. */
390
- deviceId: string | null;
391
- /** This device's local wrapping key — present only when `state` is `'unlocked'`. */
392
- deviceKey: CryptoKey | null;
393
- /** This device's active enrollment record — present only when `state` is `'unlocked'`. */
394
- activeEnrollment: E2eeDeviceEnrollment | null;
395
- }
396
- /** True when the current environment has the WebCrypto and IndexedDB primitives E2EE needs. */
397
- declare function browserSupportsE2ee(): boolean;
398
- /**
399
- * Pure classification given already-resolved inputs — no IndexedDB access,
400
- * so it's trivially unit-testable. `getE2eeLocalState` below is the
401
- * convenience wrapper that actually reads this device's local key.
402
- */
403
- declare function classifyE2eeLocalState(profile: E2eeProfile | null, devices: E2eeDeviceEnrollment[], deviceId: string, deviceKey: CryptoKey | null): E2eeLocalState;
404
- /**
405
- * Resolve this device's current E2EE state against the user's profile and
406
- * enrollment list (typically loaded server-side and passed in by the
407
- * caller). Reads this device's local wrapping key from IndexedDB.
408
- */
409
- declare function getE2eeLocalState(profile: E2eeProfile | null, devices: E2eeDeviceEnrollment[]): Promise<E2eeLocalState>;
410
-
411
- export { type ActiveSession as A, type E2eeLocalState as B, type ChangePasswordInput as C, type DrizzleClient as D, type E2eeContext as E, type E2eeProfileStatus as F, type E2eeState as G, type ProviderConfigSource as H, type SanitizedConnectionError as I, type ScheduleContext as J, type ScheduleHandler as K, type SessionUser as L, type MailOptions as M, type UpdateSecretInput as N, type OAuthStateInput as O, type PlatformConfig as P, browserSupportsE2ee as Q, type ResolveUsersInput as R, type Session as S, classifyE2eeLocalState as T, type UpdateConnectionInput as U, getE2eeLocalState as V, type SearchUsersInput as a, type DirectoryUser as b, type ActivityLogEntry as c, type SendNotificationInput as d, type StoragePutInput as e, type StorageContext as f, type StorageObject as g, type E2eeProfile as h, type CreateE2eeProfileInput as i, type E2eeRecoveryWrapper as j, type SetE2eeRecoveryWrapperInput as k, type EnrollE2eeDeviceInput as l, type E2eeDeviceEnrollment as m, type CreateSecretInput as n, type SecretContext as o, type SecretRef as p, type SecretScope as q, type CreateConnectionInput as r, type ConnectionContext as s, type ConnectionRef as t, type ConnectionListFilter as u, type MarkConnectionErrorInput as v, type ConnectionOAuthState as w, type ProviderConfig as x, type ConnectionScope as y, type ConnectionStatus as z };
@@ -1 +0,0 @@
1
- export { B as E2eeLocalState, Q as browserSupportsE2ee, T as classifyE2eeLocalState, V as getE2eeLocalState } from './e2ee-state-U-GEJ7oL.js';
@@ -1,36 +0,0 @@
1
- import {
2
- getDeviceKey,
3
- getOrCreateDeviceId
4
- } from "./chunk-NQLGPAL6.js";
5
- import "./chunk-MLKGABMK.js";
6
-
7
- // src/e2ee-state.ts
8
- function browserSupportsE2ee() {
9
- return typeof indexedDB !== "undefined" && typeof crypto !== "undefined" && typeof crypto.subtle !== "undefined";
10
- }
11
- function classifyE2eeLocalState(profile, devices, deviceId, deviceKey) {
12
- if (!profile) {
13
- return { state: "not-set-up", deviceId: null, deviceKey: null, activeEnrollment: null };
14
- }
15
- const activeEnrollment = devices.find((d) => d.deviceId === deviceId && d.revokedAt === null) ?? null;
16
- if (deviceKey && activeEnrollment) {
17
- return { state: "unlocked", deviceId, deviceKey, activeEnrollment };
18
- }
19
- return { state: "locked", deviceId, deviceKey: null, activeEnrollment: null };
20
- }
21
- async function getE2eeLocalState(profile, devices) {
22
- if (!browserSupportsE2ee()) {
23
- return { state: "unsupported", deviceId: null, deviceKey: null, activeEnrollment: null };
24
- }
25
- if (!profile) {
26
- return { state: "not-set-up", deviceId: null, deviceKey: null, activeEnrollment: null };
27
- }
28
- const deviceId = getOrCreateDeviceId();
29
- const deviceKey = await getDeviceKey(deviceId);
30
- return classifyE2eeLocalState(profile, devices, deviceId, deviceKey);
31
- }
32
- export {
33
- browserSupportsE2ee,
34
- classifyE2eeLocalState,
35
- getE2eeLocalState
36
- };
@@ -1,156 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- generateCmk,
4
- generateDek,
5
- generateDeviceKey,
6
- generateRecoverySecret,
7
- unwrapCmkWithDeviceKey,
8
- unwrapCmkWithRecoverySecret,
9
- unwrapDekWithCmk,
10
- wrapCmkWithDeviceKey,
11
- wrapCmkWithRecoverySecret,
12
- wrapDekWithCmk,
13
- } from '../e2ee-crypto';
14
-
15
- /** Encrypt/decrypt a probe value with a CMK to prove two `CryptoKey` handles are the same key. */
16
- async function roundTripsThrough(cmk: CryptoKey): Promise<string> {
17
- const iv = crypto.getRandomValues(new Uint8Array(12));
18
- const plaintext = new TextEncoder().encode('probe-value');
19
- const ciphertext = await crypto.subtle.encrypt(
20
- { name: 'AES-GCM', iv: iv as BufferSource },
21
- cmk,
22
- plaintext,
23
- );
24
- return `${Buffer.from(iv).toString('base64')}:${Buffer.from(ciphertext).toString('base64')}`;
25
- }
26
-
27
- async function decrypts(cmk: CryptoKey, token: string): Promise<string> {
28
- const [ivB64, ctB64] = token.split(':');
29
- const iv = new Uint8Array(Buffer.from(ivB64 ?? '', 'base64'));
30
- const ciphertext = new Uint8Array(Buffer.from(ctB64 ?? '', 'base64'));
31
- const plaintext = await crypto.subtle.decrypt(
32
- { name: 'AES-GCM', iv: iv as BufferSource },
33
- cmk,
34
- ciphertext as BufferSource,
35
- );
36
- return new TextDecoder().decode(plaintext);
37
- }
38
-
39
- describe('generateRecoverySecret', () => {
40
- it('produces a grouped, high-entropy, non-ambiguous secret', () => {
41
- const secret = generateRecoverySecret();
42
- expect(secret).toMatch(/^[A-Z2-9]{5}(-[A-Z2-9]{5}){3}$/);
43
- expect(secret).not.toMatch(/[01IOL]/);
44
- expect(generateRecoverySecret()).not.toBe(generateRecoverySecret());
45
- });
46
- });
47
-
48
- describe('recovery-secret CMK wrap/unwrap', () => {
49
- it('round-trips: unwrapping with the correct secret recovers the same CMK', async () => {
50
- const cmk = await generateCmk();
51
- const secret = generateRecoverySecret();
52
- const wrapped = await wrapCmkWithRecoverySecret(cmk, secret);
53
-
54
- const unwrapped = await unwrapCmkWithRecoverySecret(wrapped, secret);
55
-
56
- const token = await roundTripsThrough(cmk);
57
- expect(await decrypts(unwrapped, token)).toBe('probe-value');
58
- });
59
-
60
- it('rejects the wrong recovery secret', async () => {
61
- const cmk = await generateCmk();
62
- const wrapped = await wrapCmkWithRecoverySecret(cmk, generateRecoverySecret());
63
-
64
- await expect(unwrapCmkWithRecoverySecret(wrapped, generateRecoverySecret())).rejects.toThrow();
65
- });
66
-
67
- it('never puts the CMK or secret in the wrapped output', async () => {
68
- const cmk = await generateCmk();
69
- const secret = generateRecoverySecret();
70
- const wrapped = await wrapCmkWithRecoverySecret(cmk, secret);
71
-
72
- const serialized = JSON.stringify(wrapped);
73
- expect(serialized).not.toContain(secret);
74
- // The wrapped ciphertext is base64url — a plaintext raw AES key would
75
- // never appear as a readable substring of it in any case, but assert the
76
- // shape is opaque ciphertext, not something structured/inspectable.
77
- expect(wrapped.wrappedCmk).toMatch(/^[A-Za-z0-9_-]+$/);
78
- });
79
-
80
- it('produces a different salt/ciphertext on every call (fresh IV and salt)', async () => {
81
- const cmk = await generateCmk();
82
- const secret = generateRecoverySecret();
83
- const first = await wrapCmkWithRecoverySecret(cmk, secret);
84
- const second = await wrapCmkWithRecoverySecret(cmk, secret);
85
-
86
- expect(first.kdfSalt).not.toBe(second.kdfSalt);
87
- expect(first.wrappedCmk).not.toBe(second.wrappedCmk);
88
- });
89
- });
90
-
91
- describe('device-key CMK wrap/unwrap', () => {
92
- it('round-trips: unwrapping with the same device key recovers the same CMK', async () => {
93
- const cmk = await generateCmk();
94
- const deviceKey = await generateDeviceKey();
95
- const wrapped = await wrapCmkWithDeviceKey(cmk, deviceKey);
96
-
97
- const unwrapped = await unwrapCmkWithDeviceKey(wrapped, deviceKey);
98
-
99
- const token = await roundTripsThrough(cmk);
100
- expect(await decrypts(unwrapped, token)).toBe('probe-value');
101
- });
102
-
103
- it('rejects unwrapping with a different device key', async () => {
104
- const cmk = await generateCmk();
105
- const deviceKey = await generateDeviceKey();
106
- const otherDeviceKey = await generateDeviceKey();
107
- const wrapped = await wrapCmkWithDeviceKey(cmk, deviceKey);
108
-
109
- await expect(unwrapCmkWithDeviceKey(wrapped, otherDeviceKey)).rejects.toThrow();
110
- });
111
-
112
- it('generates a non-extractable device key', async () => {
113
- const deviceKey = await generateDeviceKey();
114
- expect(deviceKey.extractable).toBe(false);
115
- });
116
- });
117
-
118
- describe('per-object DEK wrap/unwrap', () => {
119
- it('round-trips: unwrapping with the same CMK recovers the same DEK', async () => {
120
- const cmk = await generateCmk();
121
- const dek = await generateDek();
122
- const wrapped = await wrapDekWithCmk(dek, cmk);
123
-
124
- const unwrapped = await unwrapDekWithCmk(wrapped, cmk);
125
-
126
- const token = await roundTripsThrough(dek);
127
- expect(await decrypts(unwrapped, token)).toBe('probe-value');
128
- });
129
-
130
- it('rejects unwrapping with a different CMK', async () => {
131
- const cmk = await generateCmk();
132
- const otherCmk = await generateCmk();
133
- const dek = await generateDek();
134
- const wrapped = await wrapDekWithCmk(dek, cmk);
135
-
136
- await expect(unwrapDekWithCmk(wrapped, otherCmk)).rejects.toThrow();
137
- });
138
-
139
- it('produces a `wrappedDek` field, distinct from a wrapped CMK', async () => {
140
- const cmk = await generateCmk();
141
- const dek = await generateDek();
142
- const wrapped = await wrapDekWithCmk(dek, cmk);
143
-
144
- expect(wrapped).toHaveProperty('wrappedDek');
145
- expect(wrapped.wrappedDek).toMatch(/^[A-Za-z0-9_-]+$/);
146
- });
147
-
148
- it('produces a different ciphertext on every call (fresh IV)', async () => {
149
- const cmk = await generateCmk();
150
- const dek = await generateDek();
151
- const first = await wrapDekWithCmk(dek, cmk);
152
- const second = await wrapDekWithCmk(dek, cmk);
153
-
154
- expect(first.wrappedDek).not.toBe(second.wrappedDek);
155
- });
156
- });
@@ -1,90 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { generateDek } from '../e2ee-crypto';
3
- import { decryptBlob, decryptJson, encryptBlob, encryptJson } from '../e2ee-object';
4
-
5
- describe('encryptBlob/decryptBlob', () => {
6
- it('round-trips: decrypting with the same DEK recovers the original bytes and content type', async () => {
7
- const dek = await generateDek();
8
- const original = new Blob(['hello encrypted world'], { type: 'text/plain' });
9
-
10
- const encrypted = await encryptBlob(dek, original);
11
- const decrypted = await decryptBlob(dek, encrypted);
12
-
13
- expect(decrypted.type).toBe('text/plain');
14
- expect(await decrypted.text()).toBe('hello encrypted world');
15
- });
16
-
17
- it('rejects decrypting with a different DEK', async () => {
18
- const dek = await generateDek();
19
- const otherDek = await generateDek();
20
- const encrypted = await encryptBlob(dek, new Blob(['secret']));
21
-
22
- await expect(decryptBlob(otherDek, encrypted)).rejects.toThrow();
23
- });
24
-
25
- it('never puts the plaintext in the ciphertext Blob', async () => {
26
- const dek = await generateDek();
27
- const encrypted = await encryptBlob(dek, new Blob(['a very secret document']));
28
-
29
- const ciphertextText = await encrypted.ciphertext.text();
30
- expect(ciphertextText).not.toContain('a very secret document');
31
- });
32
-
33
- it('produces a different ciphertext on every call (fresh IV)', async () => {
34
- const dek = await generateDek();
35
- const blob = new Blob(['same content']);
36
-
37
- const first = await encryptBlob(dek, blob);
38
- const second = await encryptBlob(dek, blob);
39
-
40
- expect(first.iv).not.toBe(second.iv);
41
- });
42
- });
43
-
44
- describe('encryptJson/decryptJson', () => {
45
- interface CardMetadata {
46
- title: string;
47
- issuer: string;
48
- notes?: string;
49
- }
50
-
51
- it('round-trips: decrypting with the same DEK recovers the original value', async () => {
52
- const dek = await generateDek();
53
- const value: CardMetadata = {
54
- title: 'Costco',
55
- issuer: 'Costco Wholesale',
56
- notes: 'expires 2027',
57
- };
58
-
59
- const encrypted = await encryptJson(dek, value);
60
- const decrypted = await decryptJson<CardMetadata>(dek, encrypted);
61
-
62
- expect(decrypted).toEqual(value);
63
- });
64
-
65
- it('rejects decrypting with a different DEK', async () => {
66
- const dek = await generateDek();
67
- const otherDek = await generateDek();
68
- const encrypted = await encryptJson(dek, { title: 'secret' });
69
-
70
- await expect(decryptJson(otherDek, encrypted)).rejects.toThrow();
71
- });
72
-
73
- it('never puts the plaintext value in the ciphertext', async () => {
74
- const dek = await generateDek();
75
- const encrypted = await encryptJson(dek, { documentNumber: 'A1234567' });
76
-
77
- expect(encrypted.ciphertext).not.toContain('A1234567');
78
- });
79
-
80
- it('produces a different ciphertext on every call (fresh IV)', async () => {
81
- const dek = await generateDek();
82
- const value = { title: 'same value' };
83
-
84
- const first = await encryptJson(dek, value);
85
- const second = await encryptJson(dek, value);
86
-
87
- expect(first.iv).not.toBe(second.iv);
88
- expect(first.ciphertext).not.toBe(second.ciphertext);
89
- });
90
- });