@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
package/src/auth.ts DELETED
@@ -1,134 +0,0 @@
1
- import { headers } from 'next/headers';
2
- import { NotAuthenticatedError } from './errors';
3
-
4
- // v1 is single-tenant; every session belongs to this tenant id.
5
- const DEFAULT_TENANT_ID = 'default';
6
- import { markCurrentSessions, type RawSession } from './sessions';
7
- import type { ActiveSession, ChangePasswordInput, Session } from './types';
8
-
9
- const AUTH_URL =
10
- process.env.SOVEREIGN_AUTH_URL ?? `http://localhost:${process.env.AUTH_PORT ?? '3001'}`;
11
-
12
- /** Returns the current user session from runtime-injected headers, or null if unauthenticated. */
13
- export async function getSession(): Promise<Session | null> {
14
- const h = await headers();
15
- const id = h.get('x-sovereign-user-id');
16
- if (!id) return null;
17
- const role = h.get('x-sovereign-user-role') ?? 'platform:user';
18
- const capsRaw = h.get('x-sovereign-user-capabilities');
19
- const capabilities: readonly string[] = capsRaw ? (JSON.parse(capsRaw) as string[]) : [];
20
- return {
21
- user: {
22
- id,
23
- // v1 is single-tenant, so every session belongs to the default tenant.
24
- // Multi-tenant deployments will source this from the verified session.
25
- tenantId: DEFAULT_TENANT_ID,
26
- email: h.get('x-sovereign-user-email') ?? '',
27
- name: h.get('x-sovereign-user-name') ?? null,
28
- image: h.get('x-sovereign-user-image') ?? null,
29
- role,
30
- capabilities,
31
- },
32
- expiresAt: Number(h.get('x-sovereign-session-expires-at') ?? 0),
33
- };
34
- }
35
-
36
- /**
37
- * Return true if the given session has the named capability (RFC 0021).
38
- * Plugins should call this instead of comparing `session.user.role` directly
39
- * to avoid coupling to the role-preset details.
40
- */
41
- export function hasCapability(session: Session | null, capability: string): boolean {
42
- return session?.user.capabilities.includes(capability) ?? false;
43
- }
44
-
45
- /** Returns the current user session, throwing `NotAuthenticatedError` if unauthenticated. */
46
- export async function requireSession(): Promise<Session> {
47
- const session = await getSession();
48
- if (!session) throw new NotAuthenticatedError();
49
- return session;
50
- }
51
-
52
- /**
53
- * Call a better-auth endpoint server-side, forwarding the caller's session
54
- * cookie. better-auth enforces a CSRF Origin check on state-changing routes,
55
- * so we send its own base URL as the trusted Origin.
56
- */
57
- async function authFetch(path: string, method: 'GET' | 'POST', body?: unknown): Promise<Response> {
58
- const cookie = (await headers()).get('cookie') ?? '';
59
- // No explicit `cache` option: under Next 15 fetch is uncached by default, so
60
- // these credentialed calls are never cached — and the SDK stays free of
61
- // Next-specific RequestInit fields so it type-checks standalone.
62
- return fetch(`${AUTH_URL}${path}`, {
63
- method,
64
- headers: {
65
- cookie,
66
- origin: AUTH_URL,
67
- ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
68
- },
69
- body: body === undefined ? undefined : JSON.stringify(body),
70
- });
71
- }
72
-
73
- /** Build an error message including better-auth's response body, for diagnosis. */
74
- async function authError(res: Response, action: string): Promise<Error> {
75
- const body = await res.text().catch(() => '');
76
- const detail = body ? ` — ${body}` : '';
77
- return new Error(`${action} (${String(res.status)})${detail}`);
78
- }
79
-
80
- /**
81
- * Change the current user's password (SRS ACC-04). Requires the current
82
- * password; the current session is preserved (other sessions are kept too).
83
- * Throws with better-auth's message on failure (e.g. wrong current password).
84
- */
85
- export async function changePassword(input: ChangePasswordInput): Promise<void> {
86
- const res = await authFetch('/api/auth/change-password', 'POST', {
87
- currentPassword: input.currentPassword,
88
- newPassword: input.newPassword,
89
- revokeOtherSessions: false,
90
- });
91
- if (!res.ok) {
92
- const message = ((await res.json().catch(() => null)) as { message?: string } | null)?.message;
93
- throw new Error(message ?? `Failed to change password (${String(res.status)}).`);
94
- }
95
- }
96
-
97
- /** List the current user's active sessions, current one first (SRS ACC-05). */
98
- export async function listSessions(): Promise<ActiveSession[]> {
99
- const [listRes, sessionRes] = await Promise.all([
100
- authFetch('/api/auth/list-sessions', 'GET'),
101
- authFetch('/api/auth/get-session', 'GET'),
102
- ]);
103
- if (!listRes.ok) {
104
- throw await authError(listRes, 'Failed to list sessions');
105
- }
106
- const raw = (await listRes.json()) as RawSession[];
107
- const currentToken = sessionRes.ok
108
- ? (((await sessionRes.json()) as { session?: { token?: string } } | null)?.session?.token ??
109
- null)
110
- : null;
111
- return markCurrentSessions(raw, currentToken);
112
- }
113
-
114
- /** Revoke a session by its token (SRS ACC-06). */
115
- export async function revokeSession(token: string): Promise<void> {
116
- const res = await authFetch('/api/auth/revoke-session', 'POST', { token });
117
- if (!res.ok) {
118
- throw await authError(res, 'Failed to revoke session');
119
- }
120
- }
121
-
122
- /**
123
- * Sign the current user out (SRS AUTH-02): ends the active session on the auth
124
- * server. The caller is responsible for clearing the runtime's `session_data`
125
- * cache cookies and redirecting — the platform's logout route does both.
126
- */
127
- export async function signOut(): Promise<void> {
128
- // Pass an empty body so `authFetch` sends `Content-Type: application/json` —
129
- // better-auth's sign-out rejects requests without it (415).
130
- const res = await authFetch('/api/auth/sign-out', 'POST', {});
131
- if (!res.ok) {
132
- throw await authError(res, 'Failed to sign out');
133
- }
134
- }
@@ -1,106 +0,0 @@
1
- import { headers } from 'next/headers';
2
- import { NotAuthenticatedError } from './errors';
3
- import { requireHost } from './host';
4
- import type {
5
- ConnectionContext,
6
- ConnectionListFilter,
7
- ConnectionOAuthState,
8
- ConnectionRef,
9
- ConnectionScope,
10
- CreateConnectionInput,
11
- MarkConnectionErrorInput,
12
- OAuthStateInput,
13
- ProviderConfig,
14
- UpdateConnectionInput,
15
- } from './types';
16
-
17
- const DEFAULT_TENANT_ID = 'default';
18
-
19
- function parseCapabilities(raw: string | null): readonly string[] {
20
- if (!raw) return [];
21
- try {
22
- const parsed = JSON.parse(raw) as unknown;
23
- return Array.isArray(parsed)
24
- ? parsed.filter((cap): cap is string => typeof cap === 'string')
25
- : [];
26
- } catch {
27
- return [];
28
- }
29
- }
30
-
31
- async function connectionContext(): Promise<ConnectionContext> {
32
- const h = await headers();
33
- const pluginId = h.get('x-sovereign-plugin-id');
34
- if (!pluginId) {
35
- throw new Error(
36
- 'sdk.connections requires a plugin route context (x-sovereign-plugin-id header missing).',
37
- );
38
- }
39
- return {
40
- tenantId: DEFAULT_TENANT_ID,
41
- pluginId,
42
- userId: h.get('x-sovereign-user-id'),
43
- capabilities: parseCapabilities(h.get('x-sovereign-user-capabilities')),
44
- };
45
- }
46
-
47
- function requireUserForUserScope(scope: ConnectionScope | undefined, userId: string | null): void {
48
- if ((scope === undefined || scope === 'user') && !userId) throw new NotAuthenticatedError();
49
- }
50
-
51
- /** Experimental platform-managed external connection metadata API (RFC 0049). */
52
- export const connections = {
53
- async create(input: CreateConnectionInput): Promise<ConnectionRef> {
54
- const context = await connectionContext();
55
- requireUserForUserScope(input.scope, context.userId);
56
- return requireHost().connections.create(input, context);
57
- },
58
-
59
- async list(filter?: ConnectionListFilter): Promise<ConnectionRef[]> {
60
- const context = await connectionContext();
61
- requireUserForUserScope(filter?.scope, context.userId);
62
- return requireHost().connections.list(filter, context);
63
- },
64
-
65
- async get(id: string): Promise<ConnectionRef | null> {
66
- const context = await connectionContext();
67
- return requireHost().connections.get(id, context);
68
- },
69
-
70
- async update(id: string, input: UpdateConnectionInput): Promise<ConnectionRef> {
71
- const context = await connectionContext();
72
- return requireHost().connections.update(id, input, context);
73
- },
74
-
75
- async disconnect(id: string): Promise<void> {
76
- const context = await connectionContext();
77
- return requireHost().connections.disconnect(id, context);
78
- },
79
-
80
- async markUsed(id: string): Promise<void> {
81
- const context = await connectionContext();
82
- return requireHost().connections.markUsed(id, context);
83
- },
84
-
85
- async markError(id: string, input: MarkConnectionErrorInput): Promise<ConnectionRef> {
86
- const context = await connectionContext();
87
- return requireHost().connections.markError(id, input, context);
88
- },
89
-
90
- async createOAuthState(input: OAuthStateInput): Promise<string> {
91
- const context = await connectionContext();
92
- requireUserForUserScope('user', context.userId);
93
- return requireHost().connections.createOAuthState(input, context);
94
- },
95
-
96
- async verifyOAuthState(state: string): Promise<ConnectionOAuthState> {
97
- const context = await connectionContext();
98
- requireUserForUserScope('user', context.userId);
99
- return requireHost().connections.verifyOAuthState(state, context);
100
- },
101
-
102
- async getProviderConfig(provider: string): Promise<ProviderConfig> {
103
- const context = await connectionContext();
104
- return requireHost().connections.getProviderConfig(provider, context);
105
- },
106
- };
package/src/data.ts DELETED
@@ -1,88 +0,0 @@
1
- import { headers } from 'next/headers';
2
- import { ConsentRequiredError } from './errors';
3
- import { requireHost } from './host';
4
-
5
- // v1 is single-tenant.
6
- const DEFAULT_TENANT_ID = 'default';
7
-
8
- /**
9
- * A reference to a versioned data contract exposed by a *provider* plugin, used
10
- * by a *consumer* plugin to request it.
11
- */
12
- export interface DataContractRef {
13
- /** The provider plugin's manifest `id`. */
14
- providerId: string;
15
- /** The named, read-only contract the provider exposes (e.g. `"expenses"`). */
16
- contract: string;
17
- /** Contract major version the consumer was built against. */
18
- version: number;
19
- }
20
-
21
- /**
22
- * A resolver a provider registers to answer read requests for one of its
23
- * contracts. The runtime invokes it only for consumers that hold an active
24
- * user consent grant, already scoped to the requesting user and tenant.
25
- */
26
- export type DataContractResolver<TParams = unknown, TRow = unknown> = (
27
- params: TParams,
28
- ) => Promise<TRow[]>;
29
-
30
- /**
31
- * Cross-plugin data sharing (RFC 0002) — consent-gated, pull-based, read-only
32
- * channel between plugins.
33
- *
34
- * **Provider** — register a resolver for each contract your plugin exposes
35
- * (declare it in the manifest `data.provides` array and add `data:provide` to
36
- * `permissions`):
37
- *
38
- * ```ts
39
- * sdk.data.provide('expenses', async ({ since }) => getExpenses({ since }));
40
- * ```
41
- *
42
- * **Consumer** — query a provider's contract (declare the contract in the
43
- * manifest `data.consumes` array and add `data:consume` to `permissions`).
44
- * Throws `ConsentRequiredError` when the current user has not yet granted
45
- * consent for this (consumer, provider, contract) triple:
46
- *
47
- * ```ts
48
- * const rows = await sdk.data.query(
49
- * { providerId: 'com.example.finance', contract: 'expenses', version: 1 },
50
- * { since: '2025-01-01' },
51
- * );
52
- * ```
53
- *
54
- * Consent is managed by the user in the Account → Data tab.
55
- */
56
- export const data = {
57
- /**
58
- * Provider: register a resolver for one of the contracts this plugin exposes.
59
- * Call this before any consumer can query the contract — typically at the top
60
- * of a route handler or server component that runs when the plugin is loaded.
61
- * Registrations are in-process and reset on server restart.
62
- */
63
- provide<TParams = unknown, TRow = unknown>(
64
- contract: string,
65
- resolver: DataContractResolver<TParams, TRow>,
66
- ): void {
67
- requireHost().data.provide(contract, resolver as DataContractResolver);
68
- },
69
-
70
- /** Consumer: read a provider plugin's contract for the current user (consent-gated). */
71
- async query<TParams = unknown, TRow = unknown>(
72
- ref: DataContractRef,
73
- params?: TParams,
74
- ): Promise<TRow[]> {
75
- const h = await headers();
76
- const consumerId = h.get('x-sovereign-plugin-id');
77
- const userId = h.get('x-sovereign-user-id');
78
- if (!userId) throw new ConsentRequiredError();
79
- const result = await requireHost().data.query(
80
- ref,
81
- consumerId,
82
- userId,
83
- DEFAULT_TENANT_ID,
84
- params,
85
- );
86
- return result as TRow[];
87
- },
88
- };
package/src/db.ts DELETED
@@ -1,34 +0,0 @@
1
- import { headers } from 'next/headers';
2
- import { requireHost } from './host';
3
- import type { DrizzleClient } from './types';
4
-
5
- /**
6
- * Returns the Drizzle client for this plugin's database.
7
- *
8
- * **Shared plugins** (the default, `database` omitted or `"shared"`) receive
9
- * the platform DB. Tables must be slug-prefixed (e.g. `tasks_lists`) and carry
10
- * a `tenant_id` column. `sdk.db.getClient()` is unchanged for these plugins —
11
- * isolation is transparent to the caller.
12
- *
13
- * **Isolated plugins** (`database: "isolated"` in the manifest) receive a
14
- * dedicated Drizzle instance backed by their own SQLite file or Postgres
15
- * schema. No slug prefix is required inside an isolated store. Tables should
16
- * still carry `tenant_id` for multi-tenancy readiness.
17
- *
18
- * The plugin ID is read from the `x-sovereign-plugin-id` request header
19
- * injected by the runtime middleware. Outside a plugin route context (e.g.
20
- * instrumentation startup code) the platform DB is returned.
21
- *
22
- * Async by contract: the platform DB is dialect-agnostic and Postgres has no
23
- * synchronous query (SRS §3.6).
24
- */
25
- export async function getClient(): Promise<DrizzleClient> {
26
- let pluginId: string | null = null;
27
- try {
28
- const h = await headers();
29
- pluginId = h.get('x-sovereign-plugin-id');
30
- } catch {
31
- // Outside a Next.js request context — no plugin ID available.
32
- }
33
- return requireHost().db.getClient(pluginId);
34
- }
package/src/directory.ts DELETED
@@ -1,31 +0,0 @@
1
- import { headers } from 'next/headers';
2
- import { NotAuthenticatedError } from './errors';
3
- import { requireHost } from './host';
4
- import type { DirectoryUser, ResolveUsersInput, SearchUsersInput } from './types';
5
-
6
- const DEFAULT_TENANT_ID = 'default';
7
-
8
- /** Privacy-preserving user directory for member selection and sharing flows. */
9
- export const directory = {
10
- /**
11
- * Search active users in the current tenant by display name or email.
12
- * Results include only display-safe profile fields.
13
- */
14
- async searchUsers(input: SearchUsersInput): Promise<DirectoryUser[]> {
15
- const h = await headers();
16
- const userId = h.get('x-sovereign-user-id');
17
- if (!userId) throw new NotAuthenticatedError();
18
- return requireHost().directory.searchUsers(input, userId, DEFAULT_TENANT_ID);
19
- },
20
-
21
- /**
22
- * Resolve explicit user IDs already stored by a plugin into display-safe
23
- * profile rows for active users.
24
- */
25
- async resolveUsers(input: ResolveUsersInput): Promise<DirectoryUser[]> {
26
- const h = await headers();
27
- const userId = h.get('x-sovereign-user-id');
28
- if (!userId) throw new NotAuthenticatedError();
29
- return requireHost().directory.resolveUsers(input, userId, DEFAULT_TENANT_ID);
30
- },
31
- };
@@ -1,260 +0,0 @@
1
- /**
2
- * Client-side Client Master Key (CMK) crypto (RFC 0060). Pure WebCrypto —
3
- * runs in the browser (or any environment with a global `crypto.subtle`,
4
- * which includes Node for testing). No plaintext key material or wrapped
5
- * bytes ever need to leave this module's caller before being sent to the
6
- * server as opaque ciphertext.
7
- *
8
- * Resolves RFC 0060 open question 2 (browser KDF choice): PBKDF2-HMAC-SHA256
9
- * via native WebCrypto, not Argon2id — Argon2id has no native WebCrypto
10
- * primitive, and pulling in a WASM implementation is out of scope for this
11
- * pass. `kdfAlgorithm`/`kdfParams` are stored per-wrapper specifically so a
12
- * future Argon2id upgrade doesn't require a schema change, only a new
13
- * `kdfAlgorithm` value and a migration path for existing wrappers.
14
- *
15
- * Scope: CMK generation, master-key wrap/unwrap (recovery secret, device
16
- * key), and per-object Data Encryption Key (DEK) generation/wrap/unwrap.
17
- * Generic Blob/JSON encrypt/decrypt helpers that use a DEK live in
18
- * `e2ee-object.ts`; this module only handles key material.
19
- */
20
-
21
- const AES_GCM_KEY_LENGTH = 256;
22
- const AES_GCM_IV_BYTES = 12;
23
- const PBKDF2_SALT_BYTES = 16;
24
- const PBKDF2_ITERATIONS = 600_000; // OWASP 2023 guidance for PBKDF2-HMAC-SHA256
25
- const RECOVERY_SECRET_ENTROPY_BYTES = 20; // 160 bits
26
-
27
- export const CMK_ALGORITHM = 'AES-GCM-256';
28
- export const KDF_ALGORITHM = 'PBKDF2-SHA256';
29
- export const WRAP_ALGORITHM_VERSION = 'v1';
30
-
31
- /** The CMK both encrypts/decrypts content directly and wraps/unwraps per-object DEKs. */
32
- const CMK_KEY_USAGES: KeyUsage[] = ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'];
33
- /** A DEK only ever encrypts/decrypts object content — it never wraps another key. */
34
- const DEK_KEY_USAGES: KeyUsage[] = ['encrypt', 'decrypt'];
35
-
36
- /** Base64url `iv || ciphertext` for a wrapped key, plus the algorithm version it was wrapped under. */
37
- interface WrappedKey {
38
- value: string;
39
- algorithmVersion: string;
40
- }
41
-
42
- /** Ciphertext + metadata needed to unwrap a CMK later. Safe to store server-side as-is. */
43
- export interface WrappedCmk {
44
- /** Base64url `iv || ciphertext`. */
45
- wrappedCmk: string;
46
- algorithmVersion: string;
47
- }
48
-
49
- /** A `WrappedCmk` plus the KDF metadata needed to re-derive the wrapping key from a secret. */
50
- export interface RecoveryWrappedCmk extends WrappedCmk {
51
- kdfAlgorithm: string;
52
- /** JSON-encoded KDF parameters. */
53
- kdfParams: string;
54
- kdfSalt: string;
55
- }
56
-
57
- /** Ciphertext + metadata needed to unwrap a per-object DEK later. Safe to store server-side as-is. */
58
- export interface WrappedDek {
59
- /** Base64url `iv || ciphertext`. */
60
- wrappedDek: string;
61
- algorithmVersion: string;
62
- }
63
-
64
- function toBase64Url(bytes: ArrayBuffer | Uint8Array): string {
65
- const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
66
- let binary = '';
67
- for (const byte of arr) binary += String.fromCharCode(byte);
68
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
69
- }
70
-
71
- function fromBase64Url(value: string): Uint8Array {
72
- const padded = value.replace(/-/g, '+').replace(/_/g, '/');
73
- const withPadding = padded + '='.repeat((4 - (padded.length % 4)) % 4);
74
- const binary = atob(withPadding);
75
- const bytes = new Uint8Array(binary.length);
76
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
77
- return bytes;
78
- }
79
-
80
- function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
81
- const out = new Uint8Array(a.length + b.length);
82
- out.set(a, 0);
83
- out.set(b, a.length);
84
- return out;
85
- }
86
-
87
- /**
88
- * A high-entropy, human-recordable recovery secret. Grouped base32
89
- * (Crockford-ish alphabet, ambiguous characters removed) rather than a
90
- * BIP39 word list — no wordlist dependency, ~160 bits of entropy in 32
91
- * displayed characters across 6 groups (e.g. `AB3DE-FGH2J-...`).
92
- */
93
- export function generateRecoverySecret(): string {
94
- const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I/L
95
- const bytes = crypto.getRandomValues(new Uint8Array(RECOVERY_SECRET_ENTROPY_BYTES));
96
- let out = '';
97
- for (const byte of bytes) out += alphabet[byte % alphabet.length];
98
- return out.match(/.{1,5}/g)?.join('-') ?? out;
99
- }
100
-
101
- /**
102
- * Generate a new, extractable Client Master Key. Includes `wrapKey`/
103
- * `unwrapKey` usages (not just `encrypt`/`decrypt`) since the CMK wraps
104
- * per-object DEKs (`wrapDekWithCmk`/`unwrapDekWithCmk` below) in addition to
105
- * being itself wrapped by a recovery secret or device key.
106
- */
107
- export function generateCmk(): Promise<CryptoKey> {
108
- return crypto.subtle.generateKey(
109
- { name: 'AES-GCM', length: AES_GCM_KEY_LENGTH },
110
- true,
111
- CMK_KEY_USAGES,
112
- );
113
- }
114
-
115
- async function deriveWrappingKeyFromSecret(secret: string, salt: Uint8Array): Promise<CryptoKey> {
116
- const keyMaterial = await crypto.subtle.importKey(
117
- 'raw',
118
- new TextEncoder().encode(secret.normalize('NFKC')),
119
- 'PBKDF2',
120
- false,
121
- ['deriveKey'],
122
- );
123
- return crypto.subtle.deriveKey(
124
- { name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
125
- keyMaterial,
126
- { name: 'AES-GCM', length: AES_GCM_KEY_LENGTH },
127
- false,
128
- ['wrapKey', 'unwrapKey'],
129
- );
130
- }
131
-
132
- /** Wrap the CMK with a key derived from the user's recovery secret. */
133
- export async function wrapCmkWithRecoverySecret(
134
- cmk: CryptoKey,
135
- recoverySecret: string,
136
- ): Promise<RecoveryWrappedCmk> {
137
- const salt = crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_BYTES));
138
- const wrappingKey = await deriveWrappingKeyFromSecret(recoverySecret, salt);
139
- const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
140
- const wrapped = await crypto.subtle.wrapKey('raw', cmk, wrappingKey, {
141
- name: 'AES-GCM',
142
- iv: iv as BufferSource,
143
- });
144
- return {
145
- wrappedCmk: toBase64Url(concatBytes(iv, new Uint8Array(wrapped))),
146
- kdfAlgorithm: KDF_ALGORITHM,
147
- kdfParams: JSON.stringify({ iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }),
148
- kdfSalt: toBase64Url(salt),
149
- algorithmVersion: WRAP_ALGORITHM_VERSION,
150
- };
151
- }
152
-
153
- /** Unwrap the CMK given the recovery secret. Throws if the secret is wrong. */
154
- export async function unwrapCmkWithRecoverySecret(
155
- wrapped: RecoveryWrappedCmk,
156
- recoverySecret: string,
157
- ): Promise<CryptoKey> {
158
- const salt = fromBase64Url(wrapped.kdfSalt);
159
- const wrappingKey = await deriveWrappingKeyFromSecret(recoverySecret, salt);
160
- const combined = fromBase64Url(wrapped.wrappedCmk);
161
- const iv = combined.slice(0, AES_GCM_IV_BYTES);
162
- const ciphertext = combined.slice(AES_GCM_IV_BYTES);
163
- return crypto.subtle.unwrapKey(
164
- 'raw',
165
- ciphertext as BufferSource,
166
- wrappingKey,
167
- { name: 'AES-GCM', iv: iv as BufferSource },
168
- { name: 'AES-GCM', length: AES_GCM_KEY_LENGTH },
169
- true,
170
- CMK_KEY_USAGES,
171
- );
172
- }
173
-
174
- /**
175
- * Generate a device-local wrapping key. Non-extractable — once created, no
176
- * script (including a compromised page on this origin) can ever read its raw
177
- * bytes back out via `exportKey`; it can only be used, via its `CryptoKey`
178
- * handle, for wrap/unwrap. Must be persisted with IndexedDB (the only Web
179
- * storage API that can hold a live `CryptoKey`) — see `e2ee-device.ts`.
180
- */
181
- export function generateDeviceKey(): Promise<CryptoKey> {
182
- return crypto.subtle.generateKey({ name: 'AES-GCM', length: AES_GCM_KEY_LENGTH }, false, [
183
- 'wrapKey',
184
- 'unwrapKey',
185
- ]);
186
- }
187
-
188
- async function wrapKeyWith(key: CryptoKey, wrappingKey: CryptoKey): Promise<WrappedKey> {
189
- const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
190
- const wrapped = await crypto.subtle.wrapKey('raw', key, wrappingKey, {
191
- name: 'AES-GCM',
192
- iv: iv as BufferSource,
193
- });
194
- return {
195
- value: toBase64Url(concatBytes(iv, new Uint8Array(wrapped))),
196
- algorithmVersion: WRAP_ALGORITHM_VERSION,
197
- };
198
- }
199
-
200
- async function unwrapKeyWith(
201
- wrappedValue: string,
202
- wrappingKey: CryptoKey,
203
- extractable: boolean,
204
- usages: KeyUsage[],
205
- ): Promise<CryptoKey> {
206
- const combined = fromBase64Url(wrappedValue);
207
- const iv = combined.slice(0, AES_GCM_IV_BYTES);
208
- const ciphertext = combined.slice(AES_GCM_IV_BYTES);
209
- return crypto.subtle.unwrapKey(
210
- 'raw',
211
- ciphertext as BufferSource,
212
- wrappingKey,
213
- { name: 'AES-GCM', iv: iv as BufferSource },
214
- { name: 'AES-GCM', length: AES_GCM_KEY_LENGTH },
215
- extractable,
216
- usages,
217
- );
218
- }
219
-
220
- /** Wrap the CMK with this device's own key. */
221
- export async function wrapCmkWithDeviceKey(
222
- cmk: CryptoKey,
223
- deviceKey: CryptoKey,
224
- ): Promise<WrappedCmk> {
225
- const { value, algorithmVersion } = await wrapKeyWith(cmk, deviceKey);
226
- return { wrappedCmk: value, algorithmVersion };
227
- }
228
-
229
- /** Unwrap the CMK using this device's own key. */
230
- export function unwrapCmkWithDeviceKey(
231
- wrapped: WrappedCmk,
232
- deviceKey: CryptoKey,
233
- ): Promise<CryptoKey> {
234
- return unwrapKeyWith(wrapped.wrappedCmk, deviceKey, true, CMK_KEY_USAGES);
235
- }
236
-
237
- /**
238
- * Generate a new, extractable per-object Data Encryption Key (DEK). One DEK
239
- * per encrypted object/document — never reused across objects — so
240
- * compromising a single object's key never exposes any other object (RFC
241
- * 0060 key hierarchy: CMK wraps DEKs, DEKs encrypt object content).
242
- */
243
- export function generateDek(): Promise<CryptoKey> {
244
- return crypto.subtle.generateKey(
245
- { name: 'AES-GCM', length: AES_GCM_KEY_LENGTH },
246
- true,
247
- DEK_KEY_USAGES,
248
- );
249
- }
250
-
251
- /** Wrap a per-object DEK with the (unlocked) CMK. Safe to store server-side as-is. */
252
- export async function wrapDekWithCmk(dek: CryptoKey, cmk: CryptoKey): Promise<WrappedDek> {
253
- const { value, algorithmVersion } = await wrapKeyWith(dek, cmk);
254
- return { wrappedDek: value, algorithmVersion };
255
- }
256
-
257
- /** Unwrap a per-object DEK using the (unlocked) CMK. Throws if the CMK doesn't match. */
258
- export function unwrapDekWithCmk(wrapped: WrappedDek, cmk: CryptoKey): Promise<CryptoKey> {
259
- return unwrapKeyWith(wrapped.wrappedDek, cmk, true, DEK_KEY_USAGES);
260
- }