@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,156 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { E2eeDeviceEnrollment, E2eeProfile } from '../types';
|
|
3
|
+
import { classifyE2eeLocalState } from '../e2ee-state';
|
|
4
|
+
|
|
5
|
+
const profile: E2eeProfile = {
|
|
6
|
+
id: 'profile-1',
|
|
7
|
+
userId: 'user-1',
|
|
8
|
+
status: 'active',
|
|
9
|
+
cmkAlgorithm: 'AES-GCM-256',
|
|
10
|
+
createdAt: 0,
|
|
11
|
+
updatedAt: 0,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function enrollment(overrides: Partial<E2eeDeviceEnrollment> = {}): E2eeDeviceEnrollment {
|
|
15
|
+
return {
|
|
16
|
+
id: 'enrollment-1',
|
|
17
|
+
userId: 'user-1',
|
|
18
|
+
deviceId: 'device-1',
|
|
19
|
+
deviceLabel: 'Chrome on macOS',
|
|
20
|
+
wrappedCmk: 'opaque',
|
|
21
|
+
algorithmVersion: 'v1',
|
|
22
|
+
createdAt: 0,
|
|
23
|
+
lastUsedAt: null,
|
|
24
|
+
revokedAt: null,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('classifyE2eeLocalState', () => {
|
|
30
|
+
it('returns not-set-up when there is no profile', () => {
|
|
31
|
+
const result = classifyE2eeLocalState(null, [], 'device-1', null);
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
state: 'not-set-up',
|
|
34
|
+
deviceId: null,
|
|
35
|
+
deviceKey: null,
|
|
36
|
+
activeEnrollment: null,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns locked when a profile exists but this device has no local key', () => {
|
|
41
|
+
const result = classifyE2eeLocalState(profile, [enrollment()], 'device-1', null);
|
|
42
|
+
expect(result.state).toBe('locked');
|
|
43
|
+
expect(result.deviceKey).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns locked when this device has a local key but no active enrollment record', () => {
|
|
47
|
+
const fakeKey = {} as CryptoKey;
|
|
48
|
+
const result = classifyE2eeLocalState(profile, [], 'device-1', fakeKey);
|
|
49
|
+
expect(result.state).toBe('locked');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns locked when this device has a local key but its enrollment was revoked', () => {
|
|
53
|
+
const fakeKey = {} as CryptoKey;
|
|
54
|
+
const result = classifyE2eeLocalState(
|
|
55
|
+
profile,
|
|
56
|
+
[enrollment({ revokedAt: 12345 })],
|
|
57
|
+
'device-1',
|
|
58
|
+
fakeKey,
|
|
59
|
+
);
|
|
60
|
+
expect(result.state).toBe('locked');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns unlocked when this device has both a local key and an active enrollment', () => {
|
|
64
|
+
const fakeKey = {} as CryptoKey;
|
|
65
|
+
const activeEnrollment = enrollment();
|
|
66
|
+
const result = classifyE2eeLocalState(profile, [activeEnrollment], 'device-1', fakeKey);
|
|
67
|
+
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
state: 'unlocked',
|
|
70
|
+
deviceId: 'device-1',
|
|
71
|
+
deviceKey: fakeKey,
|
|
72
|
+
activeEnrollment,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('only matches the enrollment for this specific device id', () => {
|
|
77
|
+
const fakeKey = {} as CryptoKey;
|
|
78
|
+
const otherDeviceEnrollment = enrollment({ deviceId: 'device-2' });
|
|
79
|
+
const result = classifyE2eeLocalState(profile, [otherDeviceEnrollment], 'device-1', fakeKey);
|
|
80
|
+
|
|
81
|
+
expect(result.state).toBe('locked');
|
|
82
|
+
});
|
|
83
|
+
});
|