velocious 1.0.474 → 1.0.476
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/README.md +10 -2
- package/build/configuration-types.js +39 -1
- package/build/configuration.js +46 -1
- package/build/database/drivers/base.js +67 -3
- package/build/database/drivers/mssql/index.js +13 -0
- package/build/database/drivers/mssql/sql/remove-index.js +6 -0
- package/build/database/drivers/mysql/index.js +87 -7
- package/build/database/drivers/mysql/sql/remove-index.js +6 -0
- package/build/database/drivers/pgsql/index.js +13 -0
- package/build/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/build/database/drivers/sqlite/base.js +13 -0
- package/build/database/drivers/sqlite/connection-sql-js.js +16 -5
- package/build/database/drivers/sqlite/index.web.js +29 -15
- package/build/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/build/database/drivers/sqlite/web-persistence.js +521 -0
- package/build/database/migration/index.js +44 -0
- package/build/database/pool/base-methods-forward.js +1 -0
- package/build/database/query/remove-index-base.js +39 -0
- package/build/database/record/index.js +25 -9
- package/build/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
- package/build/frontend-model-controller.js +401 -4
- package/build/frontend-model-resource/base-resource.js +5 -1
- package/build/frontend-models/base.js +146 -2
- package/build/frontend-models/query.js +3 -3
- package/build/frontend-models/resource-definition.js +304 -2
- package/build/routes/hooks/frontend-model-command-route-hook.js +16 -0
- package/build/src/configuration-types.d.ts +118 -2
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +36 -2
- package/build/src/configuration.d.ts +18 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +42 -2
- package/build/src/database/drivers/base.d.ts +50 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +62 -4
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +12 -1
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mssql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mssql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/mysql/index.d.ts +32 -3
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +76 -7
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/mysql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/mysql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +12 -1
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/pgsql/sql/remove-index.js +5 -0
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +12 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +8 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.js +15 -6
- package/build/src/database/drivers/sqlite/index.web.d.ts +5 -5
- package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.web.js +27 -13
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts +4 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.d.ts.map +1 -0
- package/build/src/database/drivers/sqlite/sql/remove-index.js +5 -0
- package/build/src/database/drivers/sqlite/web-persistence.d.ts +77 -0
- package/build/src/database/drivers/sqlite/web-persistence.d.ts.map +1 -0
- package/build/src/database/drivers/sqlite/web-persistence.js +464 -0
- package/build/src/database/migration/index.d.ts +25 -0
- package/build/src/database/migration/index.d.ts.map +1 -1
- package/build/src/database/migration/index.js +39 -1
- package/build/src/database/pool/base-methods-forward.d.ts.map +1 -1
- package/build/src/database/pool/base-methods-forward.js +2 -1
- package/build/src/database/query/remove-index-base.d.ts +35 -0
- package/build/src/database/query/remove-index-base.d.ts.map +1 -0
- package/build/src/database/query/remove-index-base.js +34 -0
- package/build/src/database/record/index.d.ts +11 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +26 -11
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts +15 -0
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +33 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +30 -0
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +53 -1
- package/build/src/frontend-model-controller.d.ts +96 -0
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +359 -5
- package/build/src/frontend-model-resource/base-resource.d.ts +4 -2
- package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
- package/build/src/frontend-model-resource/base-resource.js +7 -2
- package/build/src/frontend-models/base.d.ts +29 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +131 -3
- package/build/src/frontend-models/query.js +4 -4
- package/build/src/frontend-models/resource-definition.d.ts +6 -0
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +272 -3
- package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
- package/build/src/routes/hooks/frontend-model-command-route-hook.js +15 -1
- package/build/src/sync/device-identity.d.ts +240 -0
- package/build/src/sync/device-identity.d.ts.map +1 -0
- package/build/src/sync/device-identity.js +454 -0
- package/build/src/sync/local-mutation-log.d.ts +212 -0
- package/build/src/sync/local-mutation-log.d.ts.map +1 -0
- package/build/src/sync/local-mutation-log.js +404 -0
- package/build/src/sync/offline-grant.d.ts +157 -0
- package/build/src/sync/offline-grant.d.ts.map +1 -0
- package/build/src/sync/offline-grant.js +292 -0
- package/build/sync/device-identity.js +503 -0
- package/build/sync/local-mutation-log.js +448 -0
- package/build/sync/offline-grant.js +327 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/configuration-types.js +39 -1
- package/src/configuration.js +46 -1
- package/src/database/drivers/base.js +67 -3
- package/src/database/drivers/mssql/index.js +13 -0
- package/src/database/drivers/mssql/sql/remove-index.js +6 -0
- package/src/database/drivers/mysql/index.js +87 -7
- package/src/database/drivers/mysql/sql/remove-index.js +6 -0
- package/src/database/drivers/pgsql/index.js +13 -0
- package/src/database/drivers/pgsql/sql/remove-index.js +6 -0
- package/src/database/drivers/sqlite/base.js +13 -0
- package/src/database/drivers/sqlite/connection-sql-js.js +16 -5
- package/src/database/drivers/sqlite/index.web.js +29 -15
- package/src/database/drivers/sqlite/sql/remove-index.js +6 -0
- package/src/database/drivers/sqlite/web-persistence.js +521 -0
- package/src/database/migration/index.js +44 -0
- package/src/database/pool/base-methods-forward.js +1 -0
- package/src/database/query/remove-index-base.js +39 -0
- package/src/database/record/index.js +25 -9
- package/src/environment-handlers/node/cli/commands/generate/base-models.js +36 -0
- package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +63 -0
- package/src/frontend-model-controller.js +401 -4
- package/src/frontend-model-resource/base-resource.js +5 -1
- package/src/frontend-models/base.js +146 -2
- package/src/frontend-models/query.js +3 -3
- package/src/frontend-models/resource-definition.js +304 -2
- package/src/routes/hooks/frontend-model-command-route-hook.js +16 -0
- package/src/sync/device-identity.js +503 -0
- package/src/sync/local-mutation-log.js +448 -0
- package/src/sync/offline-grant.js +327 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const ECDSA_P256_SHA256_ALGORITHM = "ECDSA-P256-SHA256"
|
|
4
|
+
const ECDSA_P256_SHA256_SIGNATURE_PREFIX = "ecdsa-p256-sha256-"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* JSON Web Key used by the sync signing helpers.
|
|
8
|
+
* @typedef {JsonWebKey} SyncJsonWebKey
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Backend-signed device certificate payload.
|
|
13
|
+
* @typedef {object} DeviceCertificatePayload
|
|
14
|
+
* @property {string} actorDeviceId - Device id allowed to sign mutations.
|
|
15
|
+
* @property {string} actorUserId - User id represented by the device.
|
|
16
|
+
* @property {string} certificateId - Certificate id.
|
|
17
|
+
* @property {SyncJsonWebKey} devicePublicKey - Device public key peers/backends use to verify mutations.
|
|
18
|
+
* @property {string} expiresAt - ISO timestamp after which the certificate is invalid.
|
|
19
|
+
* @property {string} issuedAt - ISO timestamp when the backend issued the certificate.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Backend-signed device certificate envelope.
|
|
24
|
+
* @typedef {object} DeviceCertificate
|
|
25
|
+
* @property {"ECDSA-P256-SHA256"} algorithm - Signature algorithm.
|
|
26
|
+
* @property {DeviceCertificatePayload} certificate - Certificate payload.
|
|
27
|
+
* @property {string} signature - Backend signature over the certificate payload.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sync mutation payload signed by a device.
|
|
32
|
+
* @typedef {object} SyncMutation
|
|
33
|
+
* @property {string} actorDeviceId - Device that signed the mutation.
|
|
34
|
+
* @property {string} actorUserId - User represented by the signing device.
|
|
35
|
+
* @property {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} [attributes] - Model attributes for CRUD mutations.
|
|
36
|
+
* @property {string | number | null} [baseVersion] - Base server/client version.
|
|
37
|
+
* @property {string} clientMutationId - Device-local idempotency id.
|
|
38
|
+
* @property {string} [command] - Domain command name for command mutations.
|
|
39
|
+
* @property {string} model - Sync model/resource name.
|
|
40
|
+
* @property {string} occurredAt - ISO timestamp when the mutation occurred.
|
|
41
|
+
* @property {string} offlineGrantId - Offline grant id authorizing the mutation.
|
|
42
|
+
* @property {string} operation - CRUD operation or command operation.
|
|
43
|
+
* @property {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} [payload] - Domain command payload.
|
|
44
|
+
* @property {string} policyHash - Sync policy hash the mutation was checked against.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Device-signed mutation envelope.
|
|
49
|
+
* @typedef {object} SignedSyncMutation
|
|
50
|
+
* @property {"ECDSA-P256-SHA256"} algorithm - Signature algorithm.
|
|
51
|
+
* @property {DeviceCertificate} deviceCertificate - Backend-signed device certificate.
|
|
52
|
+
* @property {SyncMutation} mutation - Mutation payload.
|
|
53
|
+
* @property {string} signature - Device signature over the mutation envelope.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Generates an ECDSA P-256 keypair exported as JWKs.
|
|
58
|
+
* @returns {Promise<{privateKey: SyncJsonWebKey, publicKey: SyncJsonWebKey}>} - Exported keypair.
|
|
59
|
+
*/
|
|
60
|
+
export async function generateSyncSigningKeyPair() {
|
|
61
|
+
const keyPair = await cryptoSubtle().generateKey(
|
|
62
|
+
{name: "ECDSA", namedCurve: "P-256"},
|
|
63
|
+
true,
|
|
64
|
+
["sign", "verify"]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
privateKey: await cryptoSubtle().exportKey("jwk", keyPair.privateKey),
|
|
69
|
+
publicKey: await cryptoSubtle().exportKey("jwk", keyPair.publicKey)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Creates a backend-signed device certificate.
|
|
75
|
+
* @param {object} args - Arguments.
|
|
76
|
+
* @param {SyncJsonWebKey} args.backendPrivateKey - Backend private signing key.
|
|
77
|
+
* @param {DeviceCertificatePayload} args.certificate - Certificate payload.
|
|
78
|
+
* @returns {Promise<DeviceCertificate>} - Signed certificate.
|
|
79
|
+
*/
|
|
80
|
+
export async function createDeviceCertificate({backendPrivateKey, certificate}) {
|
|
81
|
+
const normalizedCertificate = normalizeDeviceCertificatePayload(certificate)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
algorithm: ECDSA_P256_SHA256_ALGORITHM,
|
|
85
|
+
certificate: normalizedCertificate,
|
|
86
|
+
signature: await signStableJson({
|
|
87
|
+
privateKey: backendPrivateKey,
|
|
88
|
+
value: deviceCertificateSignatureValue(normalizedCertificate)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Verifies a backend-signed device certificate.
|
|
95
|
+
* @param {object} args - Arguments.
|
|
96
|
+
* @param {SyncJsonWebKey} args.backendPublicKey - Backend public key.
|
|
97
|
+
* @param {DeviceCertificate} args.certificate - Signed certificate.
|
|
98
|
+
* @param {Date} [args.now] - Verification time. Defaults to current time.
|
|
99
|
+
* @returns {Promise<DeviceCertificatePayload>} - Verified certificate payload.
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyDeviceCertificate({backendPublicKey, certificate, now = new Date()}) {
|
|
102
|
+
const normalizedCertificate = normalizeDeviceCertificate(certificate)
|
|
103
|
+
const verified = await verifyStableJsonSignature({
|
|
104
|
+
publicKey: backendPublicKey,
|
|
105
|
+
signature: normalizedCertificate.signature,
|
|
106
|
+
value: deviceCertificateSignatureValue(normalizedCertificate.certificate)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (!verified) throw new Error("Device certificate signature did not match")
|
|
110
|
+
assertNotExpired({expiresAt: normalizedCertificate.certificate.expiresAt, label: "Device certificate", now})
|
|
111
|
+
|
|
112
|
+
return normalizedCertificate.certificate
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates a device-signed mutation envelope.
|
|
117
|
+
* @param {object} args - Arguments.
|
|
118
|
+
* @param {DeviceCertificate} args.deviceCertificate - Backend-signed device certificate.
|
|
119
|
+
* @param {SyncJsonWebKey} args.devicePrivateKey - Device private signing key.
|
|
120
|
+
* @param {SyncMutation} args.mutation - Mutation payload.
|
|
121
|
+
* @returns {Promise<SignedSyncMutation>} - Signed mutation.
|
|
122
|
+
*/
|
|
123
|
+
export async function createSignedMutation({deviceCertificate, devicePrivateKey, mutation}) {
|
|
124
|
+
const normalizedDeviceCertificate = normalizeDeviceCertificate(deviceCertificate)
|
|
125
|
+
const normalizedMutation = normalizeSyncMutation(mutation)
|
|
126
|
+
|
|
127
|
+
assertMutationMatchesCertificate({certificate: normalizedDeviceCertificate.certificate, mutation: normalizedMutation})
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
algorithm: ECDSA_P256_SHA256_ALGORITHM,
|
|
131
|
+
deviceCertificate: normalizedDeviceCertificate,
|
|
132
|
+
mutation: normalizedMutation,
|
|
133
|
+
signature: await signStableJson({
|
|
134
|
+
privateKey: devicePrivateKey,
|
|
135
|
+
value: signedMutationSignatureValue({
|
|
136
|
+
deviceCertificate: normalizedDeviceCertificate,
|
|
137
|
+
mutation: normalizedMutation
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Verifies a signed sync mutation and its device certificate.
|
|
145
|
+
* @param {object} args - Arguments.
|
|
146
|
+
* @param {SyncJsonWebKey} args.backendPublicKey - Backend public key used to verify the device certificate.
|
|
147
|
+
* @param {Date} [args.now] - Verification time. Defaults to current time.
|
|
148
|
+
* @param {SignedSyncMutation} args.signedMutation - Signed mutation envelope.
|
|
149
|
+
* @returns {Promise<SyncMutation>} - Verified mutation payload.
|
|
150
|
+
*/
|
|
151
|
+
export async function verifySignedMutation({backendPublicKey, now = new Date(), signedMutation}) {
|
|
152
|
+
const normalizedSignedMutation = normalizeSignedSyncMutation(signedMutation)
|
|
153
|
+
const certificatePayload = await verifyDeviceCertificate({
|
|
154
|
+
backendPublicKey,
|
|
155
|
+
certificate: normalizedSignedMutation.deviceCertificate,
|
|
156
|
+
now
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
assertMutationMatchesCertificate({certificate: certificatePayload, mutation: normalizedSignedMutation.mutation})
|
|
160
|
+
|
|
161
|
+
const verified = await verifyStableJsonSignature({
|
|
162
|
+
publicKey: certificatePayload.devicePublicKey,
|
|
163
|
+
signature: normalizedSignedMutation.signature,
|
|
164
|
+
value: signedMutationSignatureValue({
|
|
165
|
+
deviceCertificate: normalizedSignedMutation.deviceCertificate,
|
|
166
|
+
mutation: normalizedSignedMutation.mutation
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
if (!verified) throw new Error("Sync mutation signature did not match")
|
|
171
|
+
|
|
172
|
+
return normalizedSignedMutation.mutation
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Returns the replay/idempotency key for a signed mutation.
|
|
177
|
+
* @param {{mutation: {actorDeviceId?: unknown, actorUserId?: unknown, clientMutationId?: unknown}}} signedMutation - Signed mutation-like object.
|
|
178
|
+
* @returns {string} - Idempotency key.
|
|
179
|
+
*/
|
|
180
|
+
export function mutationIdempotencyKey(signedMutation) {
|
|
181
|
+
const mutation = signedMutation.mutation
|
|
182
|
+
|
|
183
|
+
return [
|
|
184
|
+
requiredString(mutation.actorUserId, "actorUserId"),
|
|
185
|
+
requiredString(mutation.actorDeviceId, "actorDeviceId"),
|
|
186
|
+
requiredString(mutation.clientMutationId, "clientMutationId")
|
|
187
|
+
].join(":")
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Builds the stable value signed by backend device certificates.
|
|
192
|
+
* @param {DeviceCertificatePayload} certificate - Certificate payload.
|
|
193
|
+
* @returns {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} - Stable signature value.
|
|
194
|
+
*/
|
|
195
|
+
function deviceCertificateSignatureValue(certificate) {
|
|
196
|
+
return {
|
|
197
|
+
algorithm: ECDSA_P256_SHA256_ALGORITHM,
|
|
198
|
+
certificate: /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (deterministicJsonObject({label: "certificate", value: certificate}))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Builds the stable value signed by sync mutations.
|
|
204
|
+
* @param {object} args - Arguments.
|
|
205
|
+
* @param {DeviceCertificate} args.deviceCertificate - Device certificate.
|
|
206
|
+
* @param {SyncMutation} args.mutation - Mutation payload.
|
|
207
|
+
* @returns {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} - Stable signature value.
|
|
208
|
+
*/
|
|
209
|
+
function signedMutationSignatureValue({deviceCertificate, mutation}) {
|
|
210
|
+
return {
|
|
211
|
+
algorithm: ECDSA_P256_SHA256_ALGORITHM,
|
|
212
|
+
deviceCertificate: /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (deterministicJsonObject({label: "deviceCertificate", value: deviceCertificate})),
|
|
213
|
+
mutation: /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (deterministicJsonObject({label: "mutation", value: mutation}))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Signs a deterministic JSON value with ECDSA P-256/SHA-256.
|
|
219
|
+
* @param {object} args - Arguments.
|
|
220
|
+
* @param {SyncJsonWebKey} args.privateKey - Private JWK.
|
|
221
|
+
* @param {unknown} args.value - Value to sign.
|
|
222
|
+
* @returns {Promise<string>} - Prefixed base64url signature.
|
|
223
|
+
*/
|
|
224
|
+
async function signStableJson({privateKey, value}) {
|
|
225
|
+
const key = await cryptoSubtle().importKey("jwk", privateKey, {name: "ECDSA", namedCurve: "P-256"}, false, ["sign"])
|
|
226
|
+
const signature = await cryptoSubtle().sign({hash: "SHA-256", name: "ECDSA"}, key, /** @type {BufferSource} */ (utf8(stableJsonStringify(value))))
|
|
227
|
+
|
|
228
|
+
return `${ECDSA_P256_SHA256_SIGNATURE_PREFIX}${base64UrlEncode(new Uint8Array(signature))}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Verifies a deterministic JSON value with ECDSA P-256/SHA-256.
|
|
233
|
+
* @param {object} args - Arguments.
|
|
234
|
+
* @param {SyncJsonWebKey} args.publicKey - Public JWK.
|
|
235
|
+
* @param {string} args.signature - Prefixed base64url signature.
|
|
236
|
+
* @param {unknown} args.value - Value to verify.
|
|
237
|
+
* @returns {Promise<boolean>} - Whether the signature matches.
|
|
238
|
+
*/
|
|
239
|
+
async function verifyStableJsonSignature({publicKey, signature, value}) {
|
|
240
|
+
const key = await cryptoSubtle().importKey("jwk", publicKey, {name: "ECDSA", namedCurve: "P-256"}, false, ["verify"])
|
|
241
|
+
const signatureBytes = base64UrlDecode(signatureWithoutPrefix(signature))
|
|
242
|
+
|
|
243
|
+
return await cryptoSubtle().verify({hash: "SHA-256", name: "ECDSA"}, key, /** @type {BufferSource} */ (signatureBytes), /** @type {BufferSource} */ (utf8(stableJsonStringify(value))))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Normalizes a signed device certificate envelope.
|
|
248
|
+
* @param {DeviceCertificate} certificate - Signed certificate.
|
|
249
|
+
* @returns {DeviceCertificate} - Normalized certificate.
|
|
250
|
+
*/
|
|
251
|
+
function normalizeDeviceCertificate(certificate) {
|
|
252
|
+
if (!certificate || typeof certificate !== "object" || Array.isArray(certificate)) throw new Error("Expected device certificate object")
|
|
253
|
+
if (certificate.algorithm !== ECDSA_P256_SHA256_ALGORITHM) throw new Error(`Unsupported device certificate algorithm '${String(certificate.algorithm)}'`)
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
algorithm: certificate.algorithm,
|
|
257
|
+
certificate: normalizeDeviceCertificatePayload(certificate.certificate),
|
|
258
|
+
signature: normalizeSignature(certificate.signature, "device certificate signature")
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Normalizes a device certificate payload.
|
|
264
|
+
* @param {DeviceCertificatePayload} certificate - Certificate payload.
|
|
265
|
+
* @returns {DeviceCertificatePayload} - Normalized certificate payload.
|
|
266
|
+
*/
|
|
267
|
+
function normalizeDeviceCertificatePayload(certificate) {
|
|
268
|
+
if (!certificate || typeof certificate !== "object" || Array.isArray(certificate)) throw new Error("Expected device certificate payload object")
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
actorDeviceId: requiredString(certificate.actorDeviceId, "actorDeviceId"),
|
|
272
|
+
actorUserId: requiredString(certificate.actorUserId, "actorUserId"),
|
|
273
|
+
certificateId: requiredString(certificate.certificateId, "certificateId"),
|
|
274
|
+
devicePublicKey: normalizeJwk(certificate.devicePublicKey, "devicePublicKey"),
|
|
275
|
+
expiresAt: isoDateString(new Date(requiredString(certificate.expiresAt, "expiresAt")), "expiresAt"),
|
|
276
|
+
issuedAt: isoDateString(new Date(requiredString(certificate.issuedAt, "issuedAt")), "issuedAt")
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Normalizes a signed sync mutation envelope.
|
|
282
|
+
* @param {SignedSyncMutation} signedMutation - Signed mutation.
|
|
283
|
+
* @returns {SignedSyncMutation} - Normalized signed mutation.
|
|
284
|
+
*/
|
|
285
|
+
function normalizeSignedSyncMutation(signedMutation) {
|
|
286
|
+
if (!signedMutation || typeof signedMutation !== "object" || Array.isArray(signedMutation)) throw new Error("Expected signed sync mutation object")
|
|
287
|
+
if (signedMutation.algorithm !== ECDSA_P256_SHA256_ALGORITHM) throw new Error(`Unsupported sync mutation algorithm '${String(signedMutation.algorithm)}'`)
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
algorithm: signedMutation.algorithm,
|
|
291
|
+
deviceCertificate: normalizeDeviceCertificate(signedMutation.deviceCertificate),
|
|
292
|
+
mutation: normalizeSyncMutation(signedMutation.mutation),
|
|
293
|
+
signature: normalizeSignature(signedMutation.signature, "sync mutation signature")
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Normalizes a sync mutation payload.
|
|
299
|
+
* @param {SyncMutation} mutation - Mutation payload.
|
|
300
|
+
* @returns {SyncMutation} - Normalized mutation payload.
|
|
301
|
+
*/
|
|
302
|
+
function normalizeSyncMutation(mutation) {
|
|
303
|
+
if (!mutation || typeof mutation !== "object" || Array.isArray(mutation)) throw new Error("Expected sync mutation object")
|
|
304
|
+
|
|
305
|
+
/** @type {SyncMutation} */
|
|
306
|
+
const normalized = {
|
|
307
|
+
actorDeviceId: requiredString(mutation.actorDeviceId, "actorDeviceId"),
|
|
308
|
+
actorUserId: requiredString(mutation.actorUserId, "actorUserId"),
|
|
309
|
+
clientMutationId: requiredString(mutation.clientMutationId, "clientMutationId"),
|
|
310
|
+
model: requiredString(mutation.model, "model"),
|
|
311
|
+
occurredAt: isoDateString(new Date(requiredString(mutation.occurredAt, "occurredAt")), "occurredAt"),
|
|
312
|
+
offlineGrantId: requiredString(mutation.offlineGrantId, "offlineGrantId"),
|
|
313
|
+
operation: requiredString(mutation.operation, "operation"),
|
|
314
|
+
policyHash: requiredString(mutation.policyHash, "policyHash")
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (mutation.attributes !== undefined) normalized.attributes = deterministicJsonObject({label: "attributes", value: mutation.attributes})
|
|
318
|
+
if (mutation.baseVersion !== undefined) normalized.baseVersion = /** @type {string | number | null} */ (deterministicJson({label: "baseVersion", value: mutation.baseVersion}))
|
|
319
|
+
if (mutation.command !== undefined) normalized.command = requiredString(mutation.command, "command")
|
|
320
|
+
if (mutation.payload !== undefined) normalized.payload = deterministicJsonObject({label: "payload", value: mutation.payload})
|
|
321
|
+
|
|
322
|
+
return normalized
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Asserts that mutation actor fields match the certificate actor.
|
|
327
|
+
* @param {object} args - Arguments.
|
|
328
|
+
* @param {DeviceCertificatePayload} args.certificate - Device certificate payload.
|
|
329
|
+
* @param {SyncMutation} args.mutation - Mutation payload.
|
|
330
|
+
* @returns {void} - No return value.
|
|
331
|
+
*/
|
|
332
|
+
function assertMutationMatchesCertificate({certificate, mutation}) {
|
|
333
|
+
if (mutation.actorDeviceId !== certificate.actorDeviceId || mutation.actorUserId !== certificate.actorUserId) {
|
|
334
|
+
throw new Error("Sync mutation actor does not match device certificate")
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Asserts a timestamp is not expired.
|
|
340
|
+
* @param {object} args - Arguments.
|
|
341
|
+
* @param {string} args.expiresAt - Expiry timestamp.
|
|
342
|
+
* @param {string} args.label - Error label.
|
|
343
|
+
* @param {Date} args.now - Verification time.
|
|
344
|
+
* @returns {void} - No return value.
|
|
345
|
+
*/
|
|
346
|
+
function assertNotExpired({expiresAt, label, now}) {
|
|
347
|
+
if (Number.isNaN(now.getTime())) throw new Error("Invalid sync verification time")
|
|
348
|
+
if (new Date(expiresAt).getTime() <= now.getTime()) throw new Error(`${label} expired`)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Normalizes a JSON Web Key object.
|
|
353
|
+
* @param {SyncJsonWebKey} key - JWK.
|
|
354
|
+
* @param {string} label - Key label.
|
|
355
|
+
* @returns {SyncJsonWebKey} - Normalized JWK.
|
|
356
|
+
*/
|
|
357
|
+
function normalizeJwk(key, label) {
|
|
358
|
+
const normalized = deterministicJsonObject({label, value: key})
|
|
359
|
+
|
|
360
|
+
return /** @type {SyncJsonWebKey} */ (normalized)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Normalizes a prefixed signature string.
|
|
365
|
+
* @param {unknown} signature - Signature value.
|
|
366
|
+
* @param {string} label - Error label.
|
|
367
|
+
* @returns {string} - Signature string.
|
|
368
|
+
*/
|
|
369
|
+
function normalizeSignature(signature, label) {
|
|
370
|
+
if (typeof signature !== "string" || !signature.startsWith(ECDSA_P256_SHA256_SIGNATURE_PREFIX)) throw new Error(`Expected ${label}`)
|
|
371
|
+
|
|
372
|
+
return signature
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Removes the signature prefix.
|
|
377
|
+
* @param {string} signature - Prefixed signature.
|
|
378
|
+
* @returns {string} - Base64url signature body.
|
|
379
|
+
*/
|
|
380
|
+
function signatureWithoutPrefix(signature) {
|
|
381
|
+
return signature.slice(ECDSA_P256_SHA256_SIGNATURE_PREFIX.length)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Requires a non-empty string field.
|
|
386
|
+
* @param {unknown} value - Value.
|
|
387
|
+
* @param {string} name - Field name.
|
|
388
|
+
* @returns {string} - String value.
|
|
389
|
+
*/
|
|
390
|
+
function requiredString(value, name) {
|
|
391
|
+
if (typeof value !== "string" || value.length < 1) throw new Error(`Expected sync ${name}`)
|
|
392
|
+
|
|
393
|
+
return value
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Normalizes a Date to an ISO timestamp.
|
|
398
|
+
* @param {Date} date - Date value.
|
|
399
|
+
* @param {string} label - Field label.
|
|
400
|
+
* @returns {string} - ISO timestamp.
|
|
401
|
+
*/
|
|
402
|
+
function isoDateString(date, label) {
|
|
403
|
+
if (Number.isNaN(date.getTime())) throw new Error(`Invalid sync ${label}`)
|
|
404
|
+
|
|
405
|
+
return date.toISOString()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Returns encoded UTF-8 bytes for a string.
|
|
410
|
+
* @param {string} value - String value.
|
|
411
|
+
* @returns {Uint8Array} - UTF-8 bytes.
|
|
412
|
+
*/
|
|
413
|
+
function utf8(value) {
|
|
414
|
+
return new TextEncoder().encode(value)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Returns WebCrypto subtle API.
|
|
419
|
+
* @returns {SubtleCrypto} - SubtleCrypto implementation.
|
|
420
|
+
*/
|
|
421
|
+
function cryptoSubtle() {
|
|
422
|
+
if (!globalThis.crypto?.subtle) throw new Error("WebCrypto subtle API is required for sync signing")
|
|
423
|
+
|
|
424
|
+
return globalThis.crypto.subtle
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Base64url-encodes bytes.
|
|
429
|
+
* @param {Uint8Array} bytes - Bytes.
|
|
430
|
+
* @returns {string} - Base64url string.
|
|
431
|
+
*/
|
|
432
|
+
function base64UrlEncode(bytes) {
|
|
433
|
+
let binary = ""
|
|
434
|
+
|
|
435
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
436
|
+
|
|
437
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Base64url-decodes bytes.
|
|
442
|
+
* @param {string} value - Base64url string.
|
|
443
|
+
* @returns {Uint8Array} - Bytes.
|
|
444
|
+
*/
|
|
445
|
+
function base64UrlDecode(value) {
|
|
446
|
+
const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=")
|
|
447
|
+
const binary = atob(base64)
|
|
448
|
+
|
|
449
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Stable JSON stringifier with sorted object keys.
|
|
454
|
+
* @param {unknown} value - Value.
|
|
455
|
+
* @returns {string} - JSON string.
|
|
456
|
+
*/
|
|
457
|
+
function stableJsonStringify(value) {
|
|
458
|
+
return JSON.stringify(deterministicJson({label: "root", value}))
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Normalizes a value as a JSON object.
|
|
463
|
+
* @param {object} args - Arguments.
|
|
464
|
+
* @param {string} args.label - Diagnostic label.
|
|
465
|
+
* @param {unknown} args.value - Value.
|
|
466
|
+
* @returns {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} - JSON object.
|
|
467
|
+
*/
|
|
468
|
+
function deterministicJsonObject({label, value}) {
|
|
469
|
+
const normalized = deterministicJson({label, value})
|
|
470
|
+
|
|
471
|
+
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) throw new Error(`Expected sync ${label} object`)
|
|
472
|
+
|
|
473
|
+
return /** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */ (normalized)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Normalizes deterministic JSON with sorted object keys.
|
|
478
|
+
* @param {object} args - Arguments.
|
|
479
|
+
* @param {string} args.label - Diagnostic label.
|
|
480
|
+
* @param {unknown} args.value - Value.
|
|
481
|
+
* @returns {import("../configuration-types.js").FrontendModelSyncJsonValue} - Normalized JSON value.
|
|
482
|
+
*/
|
|
483
|
+
function deterministicJson({label, value}) {
|
|
484
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value
|
|
485
|
+
|
|
486
|
+
if (Array.isArray(value)) return value.map((entry, index) => deterministicJson({label: `${label}/${index}`, value: entry}))
|
|
487
|
+
|
|
488
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
489
|
+
/** @type {Record<string, import("../configuration-types.js").FrontendModelSyncJsonValue>} */
|
|
490
|
+
const normalized = {}
|
|
491
|
+
|
|
492
|
+
for (const key of Object.keys(value).sort()) {
|
|
493
|
+
const childValue = /** @type {Record<string, unknown>} */ (value)[key]
|
|
494
|
+
|
|
495
|
+
if (childValue === undefined) continue
|
|
496
|
+
normalized[key] = deterministicJson({label: `${label}/${key}`, value: childValue})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return normalized
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
throw new Error(`Sync ${label} must be deterministic JSON`)
|
|
503
|
+
}
|