@sovereignfs/sdk 1.25.0
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/LICENSE +661 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-NQLGPAL6.js +62 -0
- package/dist/e2ee-crypto.d.ts +84 -0
- package/dist/e2ee-crypto.js +162 -0
- package/dist/e2ee-device.d.ts +21 -0
- package/dist/e2ee-device.js +13 -0
- package/dist/e2ee-object.d.ts +50 -0
- package/dist/e2ee-object.js +75 -0
- package/dist/e2ee-state-U-GEJ7oL.d.ts +411 -0
- package/dist/e2ee-state.d.ts +1 -0
- package/dist/e2ee-state.js +36 -0
- package/dist/index.d.ts +543 -0
- package/dist/index.js +652 -0
- package/package.json +50 -0
- package/src/__tests__/e2ee-crypto.test.ts +156 -0
- package/src/__tests__/e2ee-object.test.ts +90 -0
- package/src/__tests__/e2ee-state.test.ts +83 -0
- package/src/__tests__/sdk.test.ts +427 -0
- package/src/__tests__/sessions.test.ts +63 -0
- package/src/activity.ts +19 -0
- package/src/auth.ts +134 -0
- package/src/connections.ts +106 -0
- package/src/data.ts +88 -0
- package/src/db.ts +34 -0
- package/src/directory.ts +31 -0
- package/src/e2ee-crypto.ts +260 -0
- package/src/e2ee-device.ts +75 -0
- package/src/e2ee-object.ts +118 -0
- package/src/e2ee-state.ts +72 -0
- package/src/e2ee.ts +65 -0
- package/src/env.ts +37 -0
- package/src/errors.ts +46 -0
- package/src/host.ts +251 -0
- package/src/index.ts +160 -0
- package/src/mailer.ts +7 -0
- package/src/notifications.ts +36 -0
- package/src/platform.ts +10 -0
- package/src/plugins.ts +146 -0
- package/src/portability.ts +176 -0
- package/src/secrets.ts +68 -0
- package/src/sessions.ts +36 -0
- package/src/storage.ts +49 -0
- package/src/types.ts +418 -0
- package/src/unimplemented.ts +48 -0
|
@@ -0,0 +1,106 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-only persistence for this device's local E2EE wrapping key
|
|
3
|
+
* (RFC 0060). The key itself is generated non-extractable
|
|
4
|
+
* (`e2ee-crypto.ts#generateDeviceKey`) so it must be persisted via
|
|
5
|
+
* IndexedDB — the only Web storage API that can hold a live `CryptoKey`
|
|
6
|
+
* across page loads (`localStorage`/cookies cannot serialize one).
|
|
7
|
+
*
|
|
8
|
+
* The device id is a random UUID, not sensitive on its own (it is just a
|
|
9
|
+
* label distinguishing this browser/device's enrollment row from others) and
|
|
10
|
+
* is stored in `localStorage` alongside the IndexedDB key entry.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DB_NAME = 'sovereign-e2ee';
|
|
14
|
+
const DB_VERSION = 1;
|
|
15
|
+
const STORE_NAME = 'device-keys';
|
|
16
|
+
const DEVICE_ID_STORAGE_KEY = 'sovereign:e2ee:deviceId';
|
|
17
|
+
|
|
18
|
+
function openDb(): Promise<IDBDatabase> {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
21
|
+
request.onupgradeneeded = () => {
|
|
22
|
+
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
|
23
|
+
request.result.createObjectStore(STORE_NAME);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
request.onsuccess = () => resolve(request.result);
|
|
27
|
+
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB.'));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** This browser/device's stable id — generated once, persisted in `localStorage`. */
|
|
32
|
+
export function getOrCreateDeviceId(): string {
|
|
33
|
+
const existing = localStorage.getItem(DEVICE_ID_STORAGE_KEY);
|
|
34
|
+
if (existing) return existing;
|
|
35
|
+
const id = crypto.randomUUID();
|
|
36
|
+
localStorage.setItem(DEVICE_ID_STORAGE_KEY, id);
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Persist this device's local wrapping key, keyed by device id. */
|
|
41
|
+
export async function storeDeviceKey(deviceId: string, key: CryptoKey): Promise<void> {
|
|
42
|
+
const db = await openDb();
|
|
43
|
+
await new Promise<void>((resolve, reject) => {
|
|
44
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
45
|
+
tx.objectStore(STORE_NAME).put(key, deviceId);
|
|
46
|
+
tx.oncomplete = () => resolve();
|
|
47
|
+
tx.onerror = () => reject(tx.error ?? new Error('Failed to store device key.'));
|
|
48
|
+
});
|
|
49
|
+
db.close();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read this device's local wrapping key, or `null` if never enrolled on this device/browser. */
|
|
53
|
+
export async function getDeviceKey(deviceId: string): Promise<CryptoKey | null> {
|
|
54
|
+
const db = await openDb();
|
|
55
|
+
const result = await new Promise<CryptoKey | null>((resolve, reject) => {
|
|
56
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
57
|
+
const request = tx.objectStore(STORE_NAME).get(deviceId);
|
|
58
|
+
request.onsuccess = () => resolve((request.result as CryptoKey | undefined) ?? null);
|
|
59
|
+
request.onerror = () => reject(request.error ?? new Error('Failed to read device key.'));
|
|
60
|
+
});
|
|
61
|
+
db.close();
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Remove this device's local wrapping key (e.g. after the user revokes it from another device). */
|
|
66
|
+
export async function forgetDeviceKey(deviceId: string): Promise<void> {
|
|
67
|
+
const db = await openDb();
|
|
68
|
+
await new Promise<void>((resolve, reject) => {
|
|
69
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
70
|
+
tx.objectStore(STORE_NAME).delete(deviceId);
|
|
71
|
+
tx.oncomplete = () => resolve();
|
|
72
|
+
tx.onerror = () => reject(tx.error ?? new Error('Failed to remove device key.'));
|
|
73
|
+
});
|
|
74
|
+
db.close();
|
|
75
|
+
}
|