@keyneom/sync-kit 0.1.0 → 0.2.0-rc.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.
Files changed (77) hide show
  1. package/README.md +137 -11
  2. package/dist/auth/google-web/identity.d.ts +49 -0
  3. package/dist/auth/google-web/identity.d.ts.map +1 -0
  4. package/dist/auth/google-web/identity.js +102 -0
  5. package/dist/auth/google-web/identity.js.map +1 -0
  6. package/dist/auth/google-web/index.d.ts +1 -0
  7. package/dist/auth/google-web/index.d.ts.map +1 -1
  8. package/dist/auth/google-web/index.js +1 -0
  9. package/dist/auth/google-web/index.js.map +1 -1
  10. package/dist/core/errors.d.ts +1 -1
  11. package/dist/core/errors.d.ts.map +1 -1
  12. package/dist/core/errors.js +22 -1
  13. package/dist/core/errors.js.map +1 -1
  14. package/dist/crypto/canonical.d.ts +5 -0
  15. package/dist/crypto/canonical.d.ts.map +1 -1
  16. package/dist/crypto/canonical.js +8 -1
  17. package/dist/crypto/canonical.js.map +1 -1
  18. package/dist/crypto/envelope.d.ts.map +1 -1
  19. package/dist/crypto/envelope.js +1 -0
  20. package/dist/crypto/envelope.js.map +1 -1
  21. package/dist/crypto/profiles.d.ts +6 -46
  22. package/dist/crypto/profiles.d.ts.map +1 -1
  23. package/dist/crypto/profiles.js +33 -46
  24. package/dist/crypto/profiles.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/keys/web-passkey/index.d.ts +8 -2
  30. package/dist/keys/web-passkey/index.d.ts.map +1 -1
  31. package/dist/keys/web-passkey/index.js +32 -10
  32. package/dist/keys/web-passkey/index.js.map +1 -1
  33. package/dist/sharing/account-binding.d.ts +57 -0
  34. package/dist/sharing/account-binding.d.ts.map +1 -0
  35. package/dist/sharing/account-binding.js +305 -0
  36. package/dist/sharing/account-binding.js.map +1 -0
  37. package/dist/sharing/controller.d.ts +139 -0
  38. package/dist/sharing/controller.d.ts.map +1 -0
  39. package/dist/sharing/controller.js +549 -0
  40. package/dist/sharing/controller.js.map +1 -0
  41. package/dist/sharing/index.d.ts +119 -0
  42. package/dist/sharing/index.d.ts.map +1 -0
  43. package/dist/sharing/index.js +360 -0
  44. package/dist/sharing/index.js.map +1 -0
  45. package/dist/sharing/transport.d.ts +65 -0
  46. package/dist/sharing/transport.d.ts.map +1 -0
  47. package/dist/sharing/transport.js +2 -0
  48. package/dist/sharing/transport.js.map +1 -0
  49. package/dist/sharing/web-crypto.d.ts +68 -0
  50. package/dist/sharing/web-crypto.d.ts.map +1 -0
  51. package/dist/sharing/web-crypto.js +645 -0
  52. package/dist/sharing/web-crypto.js.map +1 -0
  53. package/dist/sharing/web-passkey.d.ts +68 -0
  54. package/dist/sharing/web-passkey.d.ts.map +1 -0
  55. package/dist/sharing/web-passkey.js +303 -0
  56. package/dist/sharing/web-passkey.js.map +1 -0
  57. package/dist/snapshot/controller.d.ts.map +1 -1
  58. package/dist/snapshot/controller.js +4 -8
  59. package/dist/snapshot/controller.js.map +1 -1
  60. package/dist/stores/google-drive/index.d.ts +147 -1
  61. package/dist/stores/google-drive/index.d.ts.map +1 -1
  62. package/dist/stores/google-drive/index.js +397 -0
  63. package/dist/stores/google-drive/index.js.map +1 -1
  64. package/dist/stores/google-drive/picker.d.ts +89 -0
  65. package/dist/stores/google-drive/picker.d.ts.map +1 -0
  66. package/dist/stores/google-drive/picker.js +179 -0
  67. package/dist/stores/google-drive/picker.js.map +1 -0
  68. package/dist/stores/google-drive/sharing.d.ts +55 -0
  69. package/dist/stores/google-drive/sharing.d.ts.map +1 -0
  70. package/dist/stores/google-drive/sharing.js +295 -0
  71. package/dist/stores/google-drive/sharing.js.map +1 -0
  72. package/package.json +67 -12
  73. package/fixtures/v1/easybc-web-android-gzip.json +0 -41
  74. package/fixtures/v1/easybc-web-uncompressed.json +0 -35
  75. package/fixtures/v1/failures.json +0 -42
  76. package/fixtures/v1/family-chores-web-uncompressed.json +0 -73
  77. package/fixtures/v1/profiles.json +0 -51
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-crypto.d.ts","sourceRoot":"","sources":["../../src/sharing/web-crypto.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,645 @@
1
+ import { SyncKitError, asSyncKitError } from "../core/errors.js";
2
+ import { base64UrlToBytes, bytesToBase64Url } from "../crypto/base64url.js";
3
+ import { canonicalAad, canonicalJson, compareUtf16CodeUnits, } from "../crypto/canonical.js";
4
+ import { copyBuffer } from "../crypto/runtime.js";
5
+ import { SHARED_BACKUP_KIND, SHARED_BACKUP_MAX_REVISION_ANCESTORS, SHARING_CONTENT_ALGORITHM, SHARING_ENCRYPTION_ALGORITHM, SHARING_INVITATION_KIND, SHARING_KEY_KIND, SHARING_SIGNATURE_ALGORITHM, canAdministerSharedBackup, canWriteSharedBackup, parseSharedBackupEnvelopeV1, parseSharingInvitationV1, parseSharingPublicKeyResponseV1, sharedBackupParticipant, sharedBackupParticipants, } from "./index.js";
6
+ const encoder = new TextEncoder();
7
+ const decoder = new TextDecoder();
8
+ export async function createWebCryptoSharingIdentity(cryptoImplementation = globalThis.crypto) {
9
+ assertWebCrypto(cryptoImplementation);
10
+ const encryption = await cryptoImplementation.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
11
+ const signing = await cryptoImplementation.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
12
+ const encryptionPublicKey = bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.exportKey("raw", encryption.publicKey)));
13
+ const signingPublicKey = bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.exportKey("raw", signing.publicKey)));
14
+ const publicKey = await createSharingPublicKeyV1(encryptionPublicKey, signingPublicKey, cryptoImplementation);
15
+ return {
16
+ publicKey,
17
+ encryptionPrivateKey: encryption.privateKey,
18
+ signingPrivateKey: signing.privateKey,
19
+ };
20
+ }
21
+ export async function createSharingPublicKeyResponseV1(identity, input, options = {}) {
22
+ requireNonEmpty(input.appId, "appId");
23
+ requireNonEmpty(input.exchangeId, "exchangeId");
24
+ const cryptoImplementation = options.crypto ?? globalThis.crypto;
25
+ assertWebCrypto(cryptoImplementation);
26
+ await assertIdentity(identity, cryptoImplementation);
27
+ const unsigned = {
28
+ schemaVersion: 1,
29
+ kind: SHARING_KEY_KIND,
30
+ appId: input.appId,
31
+ exchangeId: input.exchangeId,
32
+ createdAt: input.createdAt ?? now(options).toISOString(),
33
+ ...(input.accountBinding ? { accountBinding: input.accountBinding } : {}),
34
+ ...identity.publicKey,
35
+ };
36
+ return parseSharingPublicKeyResponseV1({
37
+ ...unsigned,
38
+ proof: bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identity.signingPrivateKey, copyBuffer(canonicalAad(unsigned))))),
39
+ });
40
+ }
41
+ export async function createSharingInvitationV1(identity, input, options = {}) {
42
+ for (const [name, value] of Object.entries({
43
+ appId: input.appId,
44
+ appFolderId: input.appFolderId,
45
+ recipientDrivePermissionId: input.recipientDrivePermissionId,
46
+ })) {
47
+ requireNonEmpty(value, name);
48
+ }
49
+ const requestedGrants = normalizedRequestedGrants(input.requestedGrants);
50
+ const cryptoImplementation = options.crypto ?? globalThis.crypto;
51
+ assertWebCrypto(cryptoImplementation);
52
+ await assertIdentity(identity, cryptoImplementation);
53
+ const unsigned = {
54
+ schemaVersion: 1,
55
+ kind: SHARING_INVITATION_KIND,
56
+ appId: input.appId,
57
+ appFolderId: input.appFolderId,
58
+ exchangeId: input.exchangeId ?? randomUUID(options),
59
+ recipientDrivePermissionId: input.recipientDrivePermissionId,
60
+ requestedGrants,
61
+ trustedOwnerKeyId: input.trustedOwnerKeyId ?? identity.publicKey.keyId,
62
+ createdAt: input.createdAt ?? now(options).toISOString(),
63
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
64
+ owner: identity.publicKey,
65
+ };
66
+ return parseSharingInvitationV1({
67
+ ...unsigned,
68
+ signature: bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identity.signingPrivateKey, copyBuffer(canonicalAad(unsigned))))),
69
+ });
70
+ }
71
+ export async function acceptSharingPublicKeyResponseV1(invitationInput, responseInput, input, options = {}) {
72
+ requireNonEmpty(input.acceptedByKeyId, "acceptedByKeyId");
73
+ requireNonEmpty(input.drivePermissionId, "drivePermissionId");
74
+ const cryptoImplementation = options.crypto ?? globalThis.crypto;
75
+ const invitation = await verifySharingInvitationV1(invitationInput, {
76
+ crypto: cryptoImplementation,
77
+ ...(options.now ? { now: options.now } : {}),
78
+ });
79
+ const response = await verifySharingPublicKeyResponseV1(responseInput, cryptoImplementation);
80
+ if (response.appId !== invitation.appId ||
81
+ response.exchangeId !== invitation.exchangeId) {
82
+ throw new SyncKitError("authorization", "The public-key response does not match this invitation.");
83
+ }
84
+ if (input.drivePermissionId !== invitation.recipientDrivePermissionId) {
85
+ throw new SyncKitError("authorization", "The response Drive account does not match the invited account.");
86
+ }
87
+ const accepted = {
88
+ exchangeId: invitation.exchangeId,
89
+ drivePermissionId: input.drivePermissionId,
90
+ acceptedAt: input.acceptedAt ?? now(options).toISOString(),
91
+ acceptedByKeyId: input.acceptedByKeyId,
92
+ ...(input.googleSubject ? { googleSubject: input.googleSubject } : {}),
93
+ };
94
+ return invitation.requestedGrants.map((grant) => ({
95
+ datasetId: grant.datasetId,
96
+ participant: {
97
+ publicKey: publicKeyFromResponse(response),
98
+ role: grant.role,
99
+ accepted,
100
+ },
101
+ }));
102
+ }
103
+ export async function verifySharingInvitationV1(input, options = {}) {
104
+ const cryptoImplementation = options.crypto ?? globalThis.crypto;
105
+ assertWebCrypto(cryptoImplementation);
106
+ const invitation = parseSharingInvitationV1(input);
107
+ const expectedOwner = await createSharingPublicKeyV1(invitation.owner.encryptionPublicKey, invitation.owner.signingPublicKey, cryptoImplementation);
108
+ if (expectedOwner.keyId !== invitation.owner.keyId) {
109
+ throw new SyncKitError("key", "The invitation owner fingerprint does not match its keys.");
110
+ }
111
+ const { signature, ...unsigned } = invitation;
112
+ const valid = await cryptoImplementation.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, await importSigningPublicKey(invitation.owner, cryptoImplementation), copyBuffer(base64UrlToBytes(signature)), copyBuffer(canonicalAad(unsigned)));
113
+ if (!valid) {
114
+ throw new SyncKitError("crypto", "The sharing invitation signature is invalid.");
115
+ }
116
+ if (invitation.expiresAt &&
117
+ Date.parse(invitation.expiresAt) <=
118
+ (options.now?.() ?? new Date()).getTime()) {
119
+ throw new SyncKitError("authorization", "The sharing invitation has expired.");
120
+ }
121
+ return invitation;
122
+ }
123
+ export async function verifySharingPublicKeyResponseV1(input, cryptoImplementation = globalThis.crypto) {
124
+ assertWebCrypto(cryptoImplementation);
125
+ const response = parseSharingPublicKeyResponseV1(input);
126
+ const expectedKey = await createSharingPublicKeyV1(response.encryptionPublicKey, response.signingPublicKey, cryptoImplementation);
127
+ if (expectedKey.keyId !== response.keyId) {
128
+ throw new SyncKitError("key", "The public-key response fingerprint does not match its keys.");
129
+ }
130
+ const { proof, ...unsigned } = response;
131
+ const valid = await cryptoImplementation.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, await importSigningPublicKey(response, cryptoImplementation), copyBuffer(base64UrlToBytes(proof)), copyBuffer(canonicalAad(unsigned)));
132
+ if (!valid) {
133
+ throw new SyncKitError("key", "The public-key response does not prove possession of its signing key.");
134
+ }
135
+ return response;
136
+ }
137
+ export async function createSharedBackupEnvelopeV1(value, codec, identity, input, options = {}) {
138
+ requireNonEmpty(input.appId, "appId");
139
+ requireNonEmpty(input.backupId, "backupId");
140
+ const cryptoImplementation = options.crypto ?? globalThis.crypto;
141
+ assertWebCrypto(cryptoImplementation);
142
+ await assertIdentity(identity, cryptoImplementation);
143
+ if (input.keyRotation) {
144
+ await assertIdentity(input.keyRotation.previousIdentity, cryptoImplementation);
145
+ }
146
+ const participants = normalizedParticipants(input.participants);
147
+ const author = participants.find((participant) => participant.keyId === identity.publicKey.keyId);
148
+ if (!author || !canWriteSharedBackup(author.role)) {
149
+ throw new SyncKitError("authorization", "The author is not allowed to write this shared backup.");
150
+ }
151
+ const previous = input.previous
152
+ ? await verifySharedBackupEnvelopeV1(input.previous, cryptoImplementation)
153
+ : undefined;
154
+ await assertParticipantKeys(participants, cryptoImplementation);
155
+ assertRevisionAuthority(input.appId, input.backupId, participants, identity.publicKey.keyId, previous, input.keyRotation?.previousIdentity.publicKey.keyId);
156
+ const accessControl = await createAccessControl(input.appId, input.backupId, participants, identity, previous, cryptoImplementation, input.keyRotation?.previousIdentity);
157
+ const revisionId = input.revisionId ?? randomUUID(options);
158
+ const createdAt = input.createdAt ?? now(options).toISOString();
159
+ requireNonEmpty(revisionId, "revisionId");
160
+ const header = {
161
+ schemaVersion: 1,
162
+ kind: SHARED_BACKUP_KIND,
163
+ algorithm: SHARING_CONTENT_ALGORITHM,
164
+ appId: input.appId,
165
+ backupId: input.backupId,
166
+ revisionId,
167
+ ...(previous ? { parentRevisionId: previous.revisionId } : {}),
168
+ ...(previous
169
+ ? {
170
+ revisionAncestors: [
171
+ ...(previous.revisionAncestors ?? []),
172
+ previous.revisionId,
173
+ ].slice(-SHARED_BACKUP_MAX_REVISION_ANCESTORS),
174
+ }
175
+ : {}),
176
+ createdAt,
177
+ authorKeyId: identity.publicKey.keyId,
178
+ };
179
+ const rawContentKey = randomBytes(32, cryptoImplementation);
180
+ try {
181
+ const contentKey = await cryptoImplementation.subtle.importKey("raw", copyBuffer(rawContentKey), "AES-GCM", false, ["encrypt", "decrypt"]);
182
+ const payloadNonce = randomBytes(12, cryptoImplementation);
183
+ let serialized;
184
+ try {
185
+ serialized = codec.serialize(value);
186
+ }
187
+ catch (error) {
188
+ throw new SyncKitError("serialization", "Shared-backup serialization failed.", { cause: error });
189
+ }
190
+ const ciphertext = new Uint8Array(await cryptoImplementation.subtle.encrypt(aesGcm(payloadNonce, canonicalAad(header)), contentKey, copyBuffer(encoder.encode(JSON.stringify(serialized)))));
191
+ const keyGrants = await Promise.all(participants.map((participant) => createKeyGrant(rawContentKey, header, participant, cryptoImplementation)));
192
+ const unsigned = {
193
+ ...header,
194
+ accessControl,
195
+ keyGrants,
196
+ payloadNonce: bytesToBase64Url(payloadNonce),
197
+ ciphertext: bytesToBase64Url(ciphertext),
198
+ };
199
+ const signature = new Uint8Array(await cryptoImplementation.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identity.signingPrivateKey, copyBuffer(canonicalAad(unsigned))));
200
+ return parseSharedBackupEnvelopeV1({
201
+ ...unsigned,
202
+ signature: bytesToBase64Url(signature),
203
+ });
204
+ }
205
+ catch (error) {
206
+ throw asSyncKitError(error, "crypto", "The shared backup could not be encrypted.");
207
+ }
208
+ finally {
209
+ rawContentKey.fill(0);
210
+ }
211
+ }
212
+ export async function verifySharedBackupEnvelopeV1(input, cryptoImplementation = globalThis.crypto, options = {}) {
213
+ assertWebCrypto(cryptoImplementation);
214
+ const envelope = parseSharedBackupEnvelopeV1(input);
215
+ const participants = await verifyAccessControl(envelope.accessControl, cryptoImplementation, options.trustedOwnerKeyId, envelope.appId, envelope.backupId);
216
+ const author = sharedBackupParticipant(envelope, envelope.authorKeyId);
217
+ if (!author || !canWriteSharedBackup(author.role)) {
218
+ throw new SyncKitError("authorization", "The shared-backup author is not an authorized writer.");
219
+ }
220
+ await assertParticipantKeys(participants, cryptoImplementation);
221
+ const { signature, ...unsigned } = envelope;
222
+ const valid = await cryptoImplementation.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, await importSigningPublicKey(author, cryptoImplementation), copyBuffer(base64UrlToBytes(signature)), copyBuffer(canonicalAad(unsigned)));
223
+ if (!valid) {
224
+ throw new SyncKitError("crypto", "The shared-backup signature is invalid.");
225
+ }
226
+ return envelope;
227
+ }
228
+ export async function decryptSharedBackupEnvelopeV1(input, codec, identity, cryptoImplementation = globalThis.crypto, options = {}) {
229
+ assertWebCrypto(cryptoImplementation);
230
+ await assertIdentity(identity, cryptoImplementation);
231
+ const envelope = await verifySharedBackupEnvelopeV1(input, cryptoImplementation, options);
232
+ const participant = sharedBackupParticipant(envelope, identity.publicKey.keyId);
233
+ if (!participant) {
234
+ throw new SyncKitError("authorization", "This identity is not a participant in the shared backup.");
235
+ }
236
+ const grant = envelope.keyGrants.find((candidate) => candidate.recipientKeyId === identity.publicKey.keyId);
237
+ if (!grant) {
238
+ throw new SyncKitError("key", "No content-key grant exists for this identity.");
239
+ }
240
+ const header = sharedBackupHeader(envelope);
241
+ let rawContentKey;
242
+ try {
243
+ rawContentKey = await unwrapContentKey(grant, header, identity.encryptionPrivateKey, cryptoImplementation);
244
+ }
245
+ catch (error) {
246
+ throw asSyncKitError(error, "key", "This identity could not unwrap the shared-backup content key.");
247
+ }
248
+ try {
249
+ const contentKey = await cryptoImplementation.subtle.importKey("raw", copyBuffer(rawContentKey), "AES-GCM", false, ["decrypt"]);
250
+ const plaintext = await cryptoImplementation.subtle.decrypt(aesGcm(base64UrlToBytes(envelope.payloadNonce), canonicalAad(header)), contentKey, copyBuffer(base64UrlToBytes(envelope.ciphertext)));
251
+ try {
252
+ return codec.parse(JSON.parse(decoder.decode(plaintext)));
253
+ }
254
+ catch (error) {
255
+ throw new SyncKitError("serialization", "The decrypted shared-backup payload is invalid.", { cause: error });
256
+ }
257
+ }
258
+ catch (error) {
259
+ throw asSyncKitError(error, "crypto", "This identity could not decrypt the shared backup.");
260
+ }
261
+ finally {
262
+ rawContentKey.fill(0);
263
+ }
264
+ }
265
+ export function sharingKeyFingerprint(keyId) {
266
+ const bytes = base64UrlToBytes(keyId);
267
+ return Array.from(bytes.slice(0, 12), (byte) => byte.toString(16).padStart(2, "0"))
268
+ .join("")
269
+ .match(/.{1,4}/gu)
270
+ ?.join("-") ?? "";
271
+ }
272
+ async function createKeyGrant(rawContentKey, header, participant, cryptoImplementation) {
273
+ const ephemeral = await cryptoImplementation.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
274
+ const ephemeralPublicKey = bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.exportKey("raw", ephemeral.publicKey)));
275
+ const kdfSalt = randomBytes(32, cryptoImplementation);
276
+ const nonce = randomBytes(12, cryptoImplementation);
277
+ const grantHeader = {
278
+ appId: header.appId,
279
+ backupId: header.backupId,
280
+ revisionId: header.revisionId,
281
+ recipientKeyId: participant.keyId,
282
+ ephemeralPublicKey,
283
+ kdfSalt: bytesToBase64Url(kdfSalt),
284
+ nonce: bytesToBase64Url(nonce),
285
+ };
286
+ const wrappingKey = await deriveWrappingKey(ephemeral.privateKey, await importEncryptionPublicKey(participant, cryptoImplementation), kdfSalt, grantHeader, cryptoImplementation);
287
+ const wrappedContentKey = await cryptoImplementation.subtle.encrypt(aesGcm(nonce, canonicalAad(grantHeader)), wrappingKey, copyBuffer(rawContentKey));
288
+ return {
289
+ recipientKeyId: participant.keyId,
290
+ ephemeralPublicKey,
291
+ kdfSalt: bytesToBase64Url(kdfSalt),
292
+ nonce: bytesToBase64Url(nonce),
293
+ wrappedContentKey: bytesToBase64Url(new Uint8Array(wrappedContentKey)),
294
+ };
295
+ }
296
+ async function unwrapContentKey(grant, header, privateKey, cryptoImplementation) {
297
+ const grantHeader = {
298
+ appId: header.appId,
299
+ backupId: header.backupId,
300
+ revisionId: header.revisionId,
301
+ recipientKeyId: grant.recipientKeyId,
302
+ ephemeralPublicKey: grant.ephemeralPublicKey,
303
+ kdfSalt: grant.kdfSalt,
304
+ nonce: grant.nonce,
305
+ };
306
+ const wrappingKey = await deriveWrappingKey(privateKey, await importEcdhPublicKey(grant.ephemeralPublicKey, cryptoImplementation), base64UrlToBytes(grant.kdfSalt), grantHeader, cryptoImplementation);
307
+ return new Uint8Array(await cryptoImplementation.subtle.decrypt(aesGcm(base64UrlToBytes(grant.nonce), canonicalAad(grantHeader)), wrappingKey, copyBuffer(base64UrlToBytes(grant.wrappedContentKey))));
308
+ }
309
+ async function deriveWrappingKey(privateKey, publicKey, salt, context, cryptoImplementation) {
310
+ const secret = new Uint8Array(await cryptoImplementation.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256));
311
+ try {
312
+ const material = await cryptoImplementation.subtle.importKey("raw", copyBuffer(secret), "HKDF", false, ["deriveKey"]);
313
+ return await cryptoImplementation.subtle.deriveKey({
314
+ name: "HKDF",
315
+ hash: "SHA-256",
316
+ salt: copyBuffer(salt),
317
+ info: copyBuffer(encoder.encode(`sync-kit-sharing-v1:${canonicalJson(context)}`)),
318
+ }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
319
+ }
320
+ finally {
321
+ secret.fill(0);
322
+ }
323
+ }
324
+ export async function createSharingPublicKeyV1(encryptionPublicKey, signingPublicKey, cryptoImplementation) {
325
+ const digest = await cryptoImplementation.subtle.digest("SHA-256", copyBuffer(canonicalAad({
326
+ encryptionAlgorithm: SHARING_ENCRYPTION_ALGORITHM,
327
+ encryptionPublicKey,
328
+ signatureAlgorithm: SHARING_SIGNATURE_ALGORITHM,
329
+ signingPublicKey,
330
+ })));
331
+ return {
332
+ keyId: bytesToBase64Url(new Uint8Array(digest)),
333
+ encryptionAlgorithm: SHARING_ENCRYPTION_ALGORITHM,
334
+ encryptionPublicKey,
335
+ signatureAlgorithm: SHARING_SIGNATURE_ALGORITHM,
336
+ signingPublicKey,
337
+ };
338
+ }
339
+ async function assertIdentity(identity, cryptoImplementation) {
340
+ const expected = await createSharingPublicKeyV1(identity.publicKey.encryptionPublicKey, identity.publicKey.signingPublicKey, cryptoImplementation);
341
+ if (expected.keyId !== identity.publicKey.keyId) {
342
+ throw new SyncKitError("key", "The sharing identity fingerprint is invalid.");
343
+ }
344
+ }
345
+ async function assertParticipantKeys(participants, cryptoImplementation) {
346
+ for (const participant of participants) {
347
+ const expected = await createSharingPublicKeyV1(participant.encryptionPublicKey, participant.signingPublicKey, cryptoImplementation);
348
+ if (expected.keyId !== participant.keyId) {
349
+ throw new SyncKitError("key", `Participant fingerprint ${participant.keyId} is invalid.`);
350
+ }
351
+ }
352
+ }
353
+ async function createAccessControl(appId, backupId, participants, identity, previous, cryptoImplementation, previousIdentity) {
354
+ if (previous &&
355
+ canonicalJson(sharedBackupParticipants(previous)) ===
356
+ canonicalJson(participants)) {
357
+ return previous.accessControl;
358
+ }
359
+ const priorEntry = previous?.accessControl.at(-1);
360
+ const sequence = previous?.accessControl.length ?? 0;
361
+ const previousHash = priorEntry
362
+ ? await accessControlHash(priorEntry, cryptoImplementation)
363
+ : undefined;
364
+ const rotationUnsigned = previousIdentity
365
+ ? {
366
+ appId,
367
+ backupId,
368
+ sequence,
369
+ ...(previousHash ? { previousHash } : {}),
370
+ fromKeyId: previousIdentity.publicKey.keyId,
371
+ toKeyId: identity.publicKey.keyId,
372
+ participants,
373
+ }
374
+ : undefined;
375
+ const keyRotation = previousIdentity && rotationUnsigned
376
+ ? {
377
+ fromKeyId: previousIdentity.publicKey.keyId,
378
+ toKeyId: identity.publicKey.keyId,
379
+ newKeyProof: bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identity.signingPrivateKey, copyBuffer(canonicalAad(rotationUnsigned))))),
380
+ }
381
+ : undefined;
382
+ const accessSigner = previousIdentity ?? identity;
383
+ const unsigned = {
384
+ appId,
385
+ backupId,
386
+ sequence,
387
+ ...(previousHash ? { previousHash } : {}),
388
+ authorKeyId: accessSigner.publicKey.keyId,
389
+ participants,
390
+ ...(keyRotation ? { keyRotation } : {}),
391
+ };
392
+ const entry = {
393
+ ...unsigned,
394
+ signature: bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, accessSigner.signingPrivateKey, copyBuffer(canonicalAad(unsigned))))),
395
+ };
396
+ return [...(previous?.accessControl ?? []), entry];
397
+ }
398
+ async function verifyAccessControl(accessControl, cryptoImplementation, trustedOwnerKeyId, appId, backupId) {
399
+ let previous;
400
+ let ownerKeyId;
401
+ for (const entry of accessControl) {
402
+ if ((entry.appId !== undefined || entry.backupId !== undefined) &&
403
+ (entry.appId !== appId || entry.backupId !== backupId)) {
404
+ throw new SyncKitError("authorization", "An access-control entry belongs to another dataset.");
405
+ }
406
+ await assertParticipantKeys(entry.participants, cryptoImplementation);
407
+ const owner = entry.participants.find((participant) => participant.role === "owner");
408
+ if (!owner) {
409
+ throw new SyncKitError("authorization", "An access-control entry has no owner.");
410
+ }
411
+ if (ownerKeyId === undefined) {
412
+ ownerKeyId = owner.keyId;
413
+ if (trustedOwnerKeyId && trustedOwnerKeyId !== ownerKeyId) {
414
+ throw new SyncKitError("authorization", "The shared backup does not match the trusted owner key.");
415
+ }
416
+ }
417
+ let author;
418
+ let validRotation = false;
419
+ if (previous) {
420
+ const expectedHash = await accessControlHash(previous, cryptoImplementation);
421
+ if (entry.previousHash !== expectedHash) {
422
+ throw new SyncKitError("crypto", "The access-control history hash is invalid.");
423
+ }
424
+ author = previous.participants.find((participant) => participant.keyId === entry.authorKeyId);
425
+ validRotation = entry.keyRotation
426
+ ? await verifyAccessKeyRotation(entry, previous, cryptoImplementation)
427
+ : false;
428
+ if (!author ||
429
+ (!canAdministerSharedBackup(author.role) && !validRotation)) {
430
+ throw new SyncKitError("authorization", "An access-control change was not signed by a prior owner or admin.");
431
+ }
432
+ if (owner.keyId !== ownerKeyId) {
433
+ if (!validRotation ||
434
+ entry.keyRotation?.fromKeyId !== ownerKeyId ||
435
+ entry.keyRotation.toKeyId !== owner.keyId) {
436
+ throw new SyncKitError("authorization", "Owner transfer is not supported by sharing v1.");
437
+ }
438
+ ownerKeyId = owner.keyId;
439
+ }
440
+ }
441
+ else {
442
+ if (entry.keyRotation) {
443
+ throw new SyncKitError("authorization", "A genesis access entry cannot rotate a key.");
444
+ }
445
+ author = entry.participants.find((participant) => participant.keyId === entry.authorKeyId);
446
+ if (author?.role !== "owner") {
447
+ throw new SyncKitError("authorization", "The first access-control entry was not signed by its owner.");
448
+ }
449
+ }
450
+ const { signature, ...unsigned } = entry;
451
+ const valid = await cryptoImplementation.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, await importSigningPublicKey(author, cryptoImplementation), copyBuffer(base64UrlToBytes(signature)), copyBuffer(canonicalAad(unsigned)));
452
+ if (!valid) {
453
+ throw new SyncKitError("crypto", "An access-control signature is invalid.");
454
+ }
455
+ previous = entry;
456
+ }
457
+ const participants = previous?.participants;
458
+ if (!participants) {
459
+ throw new SyncKitError("compatibility", "Access-control history is empty.");
460
+ }
461
+ return participants;
462
+ }
463
+ async function verifyAccessKeyRotation(entry, previous, cryptoImplementation) {
464
+ const rotation = entry.keyRotation;
465
+ if (!rotation ||
466
+ !entry.appId ||
467
+ !entry.backupId ||
468
+ entry.authorKeyId !== rotation.fromKeyId) {
469
+ return false;
470
+ }
471
+ const from = previous.participants.find((participant) => participant.keyId === rotation.fromKeyId);
472
+ const to = entry.participants.find((participant) => participant.keyId === rotation.toKeyId);
473
+ if (!from ||
474
+ !to ||
475
+ !canWriteSharedBackup(from.role) ||
476
+ from.role !== to.role ||
477
+ canonicalJson(from.accepted ?? null) !== canonicalJson(to.accepted ?? null)) {
478
+ return false;
479
+ }
480
+ const expectedParticipants = previous.participants
481
+ .map((participant) => (participant.keyId === from.keyId ? to : participant))
482
+ .sort((left, right) => compareUtf16CodeUnits(left.keyId, right.keyId));
483
+ if (canonicalJson(expectedParticipants) !== canonicalJson(entry.participants)) {
484
+ return false;
485
+ }
486
+ const proof = {
487
+ appId: entry.appId,
488
+ backupId: entry.backupId,
489
+ sequence: entry.sequence,
490
+ ...(entry.previousHash ? { previousHash: entry.previousHash } : {}),
491
+ fromKeyId: rotation.fromKeyId,
492
+ toKeyId: rotation.toKeyId,
493
+ participants: entry.participants,
494
+ };
495
+ return cryptoImplementation.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, await importSigningPublicKey(to, cryptoImplementation), copyBuffer(base64UrlToBytes(rotation.newKeyProof)), copyBuffer(canonicalAad(proof)));
496
+ }
497
+ async function accessControlHash(entry, cryptoImplementation) {
498
+ return bytesToBase64Url(new Uint8Array(await cryptoImplementation.subtle.digest("SHA-256", copyBuffer(canonicalAad(entry)))));
499
+ }
500
+ function normalizedParticipants(input) {
501
+ if (input.length === 0) {
502
+ throw new SyncKitError("configuration", "participants must not be empty.");
503
+ }
504
+ const participants = input
505
+ .map(({ publicKey, role, accepted }) => ({
506
+ ...publicKey,
507
+ role,
508
+ ...(accepted ? { accepted } : {}),
509
+ }))
510
+ .sort((left, right) => compareUtf16CodeUnits(left.keyId, right.keyId));
511
+ const duplicate = participants.find((participant, index) => index > 0 && participants[index - 1]?.keyId === participant.keyId);
512
+ if (duplicate) {
513
+ throw new SyncKitError("configuration", `Duplicate participant ${duplicate.keyId}.`);
514
+ }
515
+ if (participants.filter((participant) => participant.role === "owner").length !== 1) {
516
+ throw new SyncKitError("configuration", "A shared backup must have exactly one owner.");
517
+ }
518
+ return participants;
519
+ }
520
+ function normalizedRequestedGrants(input) {
521
+ if (input.length === 0) {
522
+ throw new SyncKitError("configuration", "requestedGrants must not be empty.");
523
+ }
524
+ const grants = [...input].sort((left, right) => compareUtf16CodeUnits(left.datasetId, right.datasetId));
525
+ for (const [index, grant] of grants.entries()) {
526
+ requireNonEmpty(grant.datasetId, "datasetId");
527
+ if (index > 0 && grants[index - 1]?.datasetId === grant.datasetId) {
528
+ throw new SyncKitError("configuration", `Duplicate requested dataset ${grant.datasetId}.`);
529
+ }
530
+ }
531
+ return grants;
532
+ }
533
+ function publicKeyFromResponse(response) {
534
+ return {
535
+ keyId: response.keyId,
536
+ encryptionAlgorithm: response.encryptionAlgorithm,
537
+ encryptionPublicKey: response.encryptionPublicKey,
538
+ signatureAlgorithm: response.signatureAlgorithm,
539
+ signingPublicKey: response.signingPublicKey,
540
+ };
541
+ }
542
+ function assertRevisionAuthority(appId, backupId, participants, authorKeyId, previous, rotationFromKeyId) {
543
+ if (!previous) {
544
+ const owner = participants.find((participant) => participant.role === "owner");
545
+ if (owner?.keyId !== authorKeyId) {
546
+ throw new SyncKitError("authorization", "Only the owner can create the first shared-backup revision.");
547
+ }
548
+ return;
549
+ }
550
+ if (previous.appId !== appId || previous.backupId !== backupId) {
551
+ throw new SyncKitError("compatibility", "The previous revision belongs to a different shared backup.");
552
+ }
553
+ const priorAuthor = sharedBackupParticipant(previous, rotationFromKeyId ?? authorKeyId);
554
+ if (!priorAuthor || !canWriteSharedBackup(priorAuthor.role)) {
555
+ throw new SyncKitError("authorization", "The previous revision does not authorize this writer.");
556
+ }
557
+ const participantsChanged = canonicalJson(sharedBackupParticipants(previous)) !==
558
+ canonicalJson(participants);
559
+ if (participantsChanged &&
560
+ !rotationFromKeyId &&
561
+ !canAdministerSharedBackup(priorAuthor.role)) {
562
+ throw new SyncKitError("authorization", "Only an owner or admin can change shared-backup participants.");
563
+ }
564
+ const priorOwner = sharedBackupParticipants(previous).find((participant) => participant.role === "owner");
565
+ const nextOwner = participants.find((participant) => participant.role === "owner");
566
+ if (priorOwner?.keyId !== nextOwner?.keyId &&
567
+ (rotationFromKeyId !== priorOwner?.keyId ||
568
+ authorKeyId !== nextOwner?.keyId)) {
569
+ throw new SyncKitError("authorization", "Owner transfer is not supported by sharing v1.");
570
+ }
571
+ if (rotationFromKeyId) {
572
+ const replacement = participants.find((participant) => participant.keyId === authorKeyId);
573
+ const expected = sharedBackupParticipants(previous)
574
+ .map((participant) => participant.keyId === rotationFromKeyId
575
+ ? replacement
576
+ : participant)
577
+ .filter((participant) => participant !== undefined)
578
+ .sort((left, right) => compareUtf16CodeUnits(left.keyId, right.keyId));
579
+ if (replacement?.role !== priorAuthor.role ||
580
+ canonicalJson(replacement?.accepted ?? null) !==
581
+ canonicalJson(priorAuthor.accepted ?? null) ||
582
+ canonicalJson(expected) !== canonicalJson(participants)) {
583
+ throw new SyncKitError("authorization", "A key rotation may only replace the current writer's key.");
584
+ }
585
+ }
586
+ }
587
+ function sharedBackupHeader(envelope) {
588
+ return {
589
+ schemaVersion: envelope.schemaVersion,
590
+ kind: envelope.kind,
591
+ algorithm: envelope.algorithm,
592
+ appId: envelope.appId,
593
+ backupId: envelope.backupId,
594
+ revisionId: envelope.revisionId,
595
+ ...(envelope.parentRevisionId
596
+ ? { parentRevisionId: envelope.parentRevisionId }
597
+ : {}),
598
+ ...(envelope.revisionAncestors
599
+ ? { revisionAncestors: envelope.revisionAncestors }
600
+ : {}),
601
+ createdAt: envelope.createdAt,
602
+ authorKeyId: envelope.authorKeyId,
603
+ };
604
+ }
605
+ function importEncryptionPublicKey(publicKey, cryptoImplementation) {
606
+ return importEcdhPublicKey(publicKey.encryptionPublicKey, cryptoImplementation);
607
+ }
608
+ function importEcdhPublicKey(value, cryptoImplementation) {
609
+ return cryptoImplementation.subtle.importKey("raw", copyBuffer(base64UrlToBytes(value)), { name: "ECDH", namedCurve: "P-256" }, false, []);
610
+ }
611
+ function importSigningPublicKey(publicKey, cryptoImplementation) {
612
+ return cryptoImplementation.subtle.importKey("raw", copyBuffer(base64UrlToBytes(publicKey.signingPublicKey)), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
613
+ }
614
+ function aesGcm(nonce, additionalData) {
615
+ return {
616
+ name: "AES-GCM",
617
+ iv: copyBuffer(nonce),
618
+ additionalData: copyBuffer(additionalData),
619
+ tagLength: 128,
620
+ };
621
+ }
622
+ function randomBytes(length, cryptoImplementation) {
623
+ return cryptoImplementation.getRandomValues(new Uint8Array(length));
624
+ }
625
+ function randomUUID(options) {
626
+ const implementation = options.randomUUID ??
627
+ (options.crypto ?? globalThis.crypto).randomUUID?.bind(options.crypto ?? globalThis.crypto);
628
+ if (!implementation) {
629
+ throw new SyncKitError("configuration", "Secure UUID generation is unavailable.");
630
+ }
631
+ return implementation();
632
+ }
633
+ function now(options) {
634
+ return options.now?.() ?? new Date();
635
+ }
636
+ function requireNonEmpty(value, name) {
637
+ if (!value.trim())
638
+ throw new TypeError(`${name} must not be empty.`);
639
+ }
640
+ function assertWebCrypto(value) {
641
+ if (!value?.subtle || !value.getRandomValues) {
642
+ throw new SyncKitError("configuration", "A WebCrypto implementation with P-256 support is required.");
643
+ }
644
+ }
645
+ //# sourceMappingURL=web-crypto.js.map