@mdbase-dev/connect 0.1.0-beta.100
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 +21 -0
- package/README.md +696 -0
- package/dist/advanced.d.ts +9 -0
- package/dist/advanced.js +6 -0
- package/dist/application-contract.d.ts +22 -0
- package/dist/application-contract.js +75 -0
- package/dist/application-identity.d.ts +34 -0
- package/dist/application-identity.js +148 -0
- package/dist/application-session.d.ts +167 -0
- package/dist/application-session.js +722 -0
- package/dist/async.d.ts +1 -0
- package/dist/async.js +19 -0
- package/dist/authority-proof.d.ts +3 -0
- package/dist/authority-proof.js +24 -0
- package/dist/authority-sync-request.d.ts +3 -0
- package/dist/authority-sync-request.js +20 -0
- package/dist/authorization-refresh.d.ts +14 -0
- package/dist/authorization-refresh.js +41 -0
- package/dist/authorization-types.d.ts +7 -0
- package/dist/authorization-types.js +1 -0
- package/dist/authorization-url.d.ts +5 -0
- package/dist/authorization-url.js +34 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +21 -0
- package/dist/browser/integrity.json +4 -0
- package/dist/browser/mdbase-connect.min.js +3 -0
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +5 -0
- package/dist/capabilities.d.ts +24 -0
- package/dist/capabilities.js +91 -0
- package/dist/collection-client.d.ts +66 -0
- package/dist/collection-client.js +686 -0
- package/dist/connect-authorization-helpers.d.ts +4 -0
- package/dist/connect-authorization-helpers.js +25 -0
- package/dist/connect-options.d.ts +35 -0
- package/dist/connect-options.js +1 -0
- package/dist/connect.d.ts +66 -0
- package/dist/connect.js +711 -0
- package/dist/connection-file-transport.d.ts +34 -0
- package/dist/connection-file-transport.js +85 -0
- package/dist/connection-index.d.ts +3 -0
- package/dist/connection-index.js +20 -0
- package/dist/connection-notifications.d.ts +31 -0
- package/dist/connection-notifications.js +197 -0
- package/dist/connection-transport-internals.d.ts +24 -0
- package/dist/connection-transport-internals.js +1 -0
- package/dist/connection-transport.d.ts +64 -0
- package/dist/connection-transport.js +725 -0
- package/dist/connection-types.d.ts +38 -0
- package/dist/connection-types.js +1 -0
- package/dist/connection.d.ts +175 -0
- package/dist/connection.js +606 -0
- package/dist/crypto-entry.d.ts +7 -0
- package/dist/crypto-entry.js +4 -0
- package/dist/crypto.d.ts +63 -0
- package/dist/crypto.js +409 -0
- package/dist/direct-access.d.ts +3 -0
- package/dist/direct-access.js +29 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +142 -0
- package/dist/external-store.d.ts +10 -0
- package/dist/external-store.js +10 -0
- package/dist/file-crypto.d.ts +25 -0
- package/dist/file-crypto.js +132 -0
- package/dist/file-sha256.d.ts +12 -0
- package/dist/file-sha256.js +143 -0
- package/dist/file-stream-source.d.ts +17 -0
- package/dist/file-stream-source.js +157 -0
- package/dist/file-transfer-internals.d.ts +23 -0
- package/dist/file-transfer-internals.js +228 -0
- package/dist/files.d.ts +115 -0
- package/dist/files.js +563 -0
- package/dist/fresh-encrypted-request.d.ts +4 -0
- package/dist/fresh-encrypted-request.js +28 -0
- package/dist/grant-key-leases.d.ts +24 -0
- package/dist/grant-key-leases.js +144 -0
- package/dist/hosted-file-request.d.ts +6 -0
- package/dist/hosted-file-request.js +63 -0
- package/dist/hosted-operation-response.d.ts +5 -0
- package/dist/hosted-operation-response.js +26 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +10 -0
- package/dist/internal-types.d.ts +100 -0
- package/dist/internal-types.js +1 -0
- package/dist/local-file-transport.d.ts +37 -0
- package/dist/local-file-transport.js +457 -0
- package/dist/mdbase-connect.d.ts +28 -0
- package/dist/mdbase-connect.js +53 -0
- package/dist/notifications.d.ts +48 -0
- package/dist/notifications.js +52 -0
- package/dist/operation-helpers.d.ts +27 -0
- package/dist/operation-helpers.js +144 -0
- package/dist/operation-types.d.ts +707 -0
- package/dist/operation-types.js +1 -0
- package/dist/outcomes.d.ts +51 -0
- package/dist/outcomes.js +194 -0
- package/dist/pending-mutation-store.d.ts +13 -0
- package/dist/pending-mutation-store.js +64 -0
- package/dist/pending-recovery-transports.d.ts +5 -0
- package/dist/pending-recovery-transports.js +52 -0
- package/dist/popup-authorization.d.ts +31 -0
- package/dist/popup-authorization.js +100 -0
- package/dist/query-pagination.d.ts +6 -0
- package/dist/query-pagination.js +119 -0
- package/dist/request-budget.d.ts +34 -0
- package/dist/request-budget.js +104 -0
- package/dist/request-coordinator.d.ts +29 -0
- package/dist/request-coordinator.js +213 -0
- package/dist/retired-grant-keys.d.ts +9 -0
- package/dist/retired-grant-keys.js +56 -0
- package/dist/runtime-utils.d.ts +52 -0
- package/dist/runtime-utils.js +368 -0
- package/dist/saved-view-pagination.d.ts +6 -0
- package/dist/saved-view-pagination.js +76 -0
- package/dist/selection.d.ts +48 -0
- package/dist/selection.js +112 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +569 -0
- package/dist/shared-operation.d.ts +7 -0
- package/dist/shared-operation.js +41 -0
- package/dist/stored-token.d.ts +10 -0
- package/dist/stored-token.js +64 -0
- package/dist/structured-readiness.d.ts +77 -0
- package/dist/structured-readiness.js +126 -0
- package/dist/token-response.d.ts +12 -0
- package/dist/token-response.js +81 -0
- package/dist/transient-retry.d.ts +22 -0
- package/dist/transient-retry.js +59 -0
- package/dist/watch-policy.d.ts +8 -0
- package/dist/watch-policy.js +12 -0
- package/package.json +51 -0
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { OPERATION_TRANSPORT_PROTOCOL_VERSION, GRANT_ENCRYPTION_PROTOCOL_VERSION, AUTHORITY_PROOF_DOMAIN, AUTHORITY_PROOF_HEADERS, AUTHORITY_PROOF_VERSION, RELAY_ENCRYPTION_SUITE, isConnectProblem } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
const DATABASE_NAME = "mdbase-connect-keys";
|
|
3
|
+
const STORE_NAME = "grant-keys";
|
|
4
|
+
const MAX_U64 = 18446744073709551615n;
|
|
5
|
+
const REQUEST_INFO = "mdbase-connect relay request key v1";
|
|
6
|
+
const RESPONSE_INFO = "mdbase-connect relay response key v1";
|
|
7
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
/** Origin-scoped storage for non-extractable grant keys and atomic counters. */
|
|
9
|
+
export class IndexedDbGrantKeyStore {
|
|
10
|
+
database = null;
|
|
11
|
+
async create(handle) {
|
|
12
|
+
const record = await generateGrantKey(handle);
|
|
13
|
+
const database = await this.open();
|
|
14
|
+
await idbWrite(database, (store) => store.add({ ...record, counter: "0" }));
|
|
15
|
+
return record;
|
|
16
|
+
}
|
|
17
|
+
async get(handle) {
|
|
18
|
+
const database = await this.open();
|
|
19
|
+
const value = await idbRequest(database.transaction(STORE_NAME).objectStore(STORE_NAME).get(handle));
|
|
20
|
+
return value ? {
|
|
21
|
+
handle: value.handle,
|
|
22
|
+
agreementPrivateKey: value.agreementPrivateKey,
|
|
23
|
+
agreementPublicKey: value.agreementPublicKey,
|
|
24
|
+
signingPrivateKey: value.signingPrivateKey,
|
|
25
|
+
signingPublicKey: value.signingPublicKey
|
|
26
|
+
} : null;
|
|
27
|
+
}
|
|
28
|
+
async nextCounter(handle) {
|
|
29
|
+
const database = await this.open();
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const transaction = database.transaction(STORE_NAME, "readwrite");
|
|
32
|
+
const store = transaction.objectStore(STORE_NAME);
|
|
33
|
+
const request = store.get(handle);
|
|
34
|
+
let next = null;
|
|
35
|
+
request.onerror = () => reject(request.error);
|
|
36
|
+
request.onsuccess = () => {
|
|
37
|
+
const record = request.result;
|
|
38
|
+
if (!record) {
|
|
39
|
+
transaction.abort();
|
|
40
|
+
reject(new RelayCryptoError("missing_grant_key", "The encrypted grant key is unavailable."));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const counter = BigInt(record.counter) + 1n;
|
|
44
|
+
if (counter > MAX_U64) {
|
|
45
|
+
transaction.abort();
|
|
46
|
+
reject(new RelayCryptoError("counter_exhausted", "The encrypted grant must be authorized again."));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
next = counter.toString();
|
|
50
|
+
store.put({ ...record, counter: next });
|
|
51
|
+
};
|
|
52
|
+
transaction.onerror = () => reject(transaction.error);
|
|
53
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Key counter transaction aborted."));
|
|
54
|
+
transaction.oncomplete = () => {
|
|
55
|
+
if (next)
|
|
56
|
+
resolve(next);
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async delete(handle) {
|
|
61
|
+
const database = await this.open();
|
|
62
|
+
await idbWrite(database, (store) => store.delete(handle));
|
|
63
|
+
}
|
|
64
|
+
open() {
|
|
65
|
+
if (typeof indexedDB === "undefined") {
|
|
66
|
+
throw new RelayCryptoError("key_storage_unavailable", "Encrypted relay authorization requires browser IndexedDB or an injected grant key store.");
|
|
67
|
+
}
|
|
68
|
+
if (!this.database) {
|
|
69
|
+
this.database = new Promise((resolve, reject) => {
|
|
70
|
+
const request = indexedDB.open(DATABASE_NAME, 1);
|
|
71
|
+
request.onupgradeneeded = () => {
|
|
72
|
+
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
|
73
|
+
request.result.createObjectStore(STORE_NAME, { keyPath: "handle" });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
request.onerror = () => reject(request.error);
|
|
77
|
+
request.onsuccess = () => resolve(request.result);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return this.database;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Deterministic process-local storage for tests, CLIs, and non-browser adapters. */
|
|
84
|
+
export class MemoryGrantKeyStore {
|
|
85
|
+
records = new Map();
|
|
86
|
+
sequence = Promise.resolve();
|
|
87
|
+
async create(handle) {
|
|
88
|
+
if (this.records.has(handle))
|
|
89
|
+
throw new Error(`Grant key already exists: ${handle}`);
|
|
90
|
+
const record = await generateGrantKey(handle);
|
|
91
|
+
this.records.set(handle, { ...record, counter: "0" });
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
async get(handle) {
|
|
95
|
+
const value = this.records.get(handle);
|
|
96
|
+
return value ? {
|
|
97
|
+
handle: value.handle,
|
|
98
|
+
agreementPrivateKey: value.agreementPrivateKey,
|
|
99
|
+
agreementPublicKey: value.agreementPublicKey,
|
|
100
|
+
signingPrivateKey: value.signingPrivateKey,
|
|
101
|
+
signingPublicKey: value.signingPublicKey
|
|
102
|
+
} : null;
|
|
103
|
+
}
|
|
104
|
+
nextCounter(handle) {
|
|
105
|
+
let resolve;
|
|
106
|
+
let reject;
|
|
107
|
+
const result = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
108
|
+
this.sequence = this.sequence.then(() => {
|
|
109
|
+
const record = this.records.get(handle);
|
|
110
|
+
if (!record)
|
|
111
|
+
throw new RelayCryptoError("missing_grant_key", "The encrypted grant key is unavailable.");
|
|
112
|
+
const counter = BigInt(record.counter) + 1n;
|
|
113
|
+
if (counter > MAX_U64)
|
|
114
|
+
throw new RelayCryptoError("counter_exhausted", "The encrypted grant must be authorized again.");
|
|
115
|
+
record.counter = counter.toString();
|
|
116
|
+
resolve(record.counter);
|
|
117
|
+
}).catch((error) => {
|
|
118
|
+
reject(error);
|
|
119
|
+
});
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
async delete(handle) {
|
|
123
|
+
this.records.delete(handle);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Sign one remote-authority or token-refresh request with the approved grant key. */
|
|
127
|
+
export async function signAuthorityRequest(store, handle, expectedPublicKey, input) {
|
|
128
|
+
const record = await requireSigningKey(store, handle, expectedPublicKey);
|
|
129
|
+
const timestamp = input.timestamp ?? Math.floor(Date.now() / 1_000);
|
|
130
|
+
const nonce = input.nonce ?? crypto.randomUUID();
|
|
131
|
+
const message = await authorityProofMessage({ ...input, timestamp, nonce });
|
|
132
|
+
const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, record.signingPrivateKey, new TextEncoder().encode(message));
|
|
133
|
+
return {
|
|
134
|
+
[AUTHORITY_PROOF_HEADERS.version]: String(AUTHORITY_PROOF_VERSION),
|
|
135
|
+
[AUTHORITY_PROOF_HEADERS.timestamp]: String(timestamp),
|
|
136
|
+
[AUTHORITY_PROOF_HEADERS.nonce]: nonce,
|
|
137
|
+
[AUTHORITY_PROOF_HEADERS.signature]: bytesToBase64Url(new Uint8Array(signature))
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export async function authorityProofMessage(input) {
|
|
141
|
+
const method = input.method.toUpperCase();
|
|
142
|
+
const bodyHash = await sha256Base64Url(input.body ?? "");
|
|
143
|
+
const credentialHash = await sha256Base64Url(input.credential);
|
|
144
|
+
for (const value of [method, input.target, String(input.timestamp), input.nonce]) {
|
|
145
|
+
if (!value || value.includes("\n") || value.includes("\r")) {
|
|
146
|
+
throw new RelayCryptoError("invalid_proof_input", "Authority request proof metadata is invalid.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [
|
|
150
|
+
AUTHORITY_PROOF_DOMAIN,
|
|
151
|
+
AUTHORITY_PROOF_VERSION,
|
|
152
|
+
method,
|
|
153
|
+
input.target,
|
|
154
|
+
bodyHash,
|
|
155
|
+
credentialHash,
|
|
156
|
+
input.timestamp,
|
|
157
|
+
input.nonce
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
|
160
|
+
export async function encryptRelayRequest(store, handle, binding, operation, input, requestId = crypto.randomUUID(), deadlineUnixMs) {
|
|
161
|
+
validateGrantEncryption(binding.encryption);
|
|
162
|
+
if (deadlineUnixMs !== undefined
|
|
163
|
+
&& (!Number.isSafeInteger(deadlineUnixMs) || deadlineUnixMs <= 0)) {
|
|
164
|
+
throw new RelayCryptoError("invalid_deadline", "The operation deadline is invalid.");
|
|
165
|
+
}
|
|
166
|
+
const record = await requireAgreementKey(store, handle, binding.encryption.application_agreement_public_key);
|
|
167
|
+
const counter = await store.nextCounter(handle);
|
|
168
|
+
const metadata = {
|
|
169
|
+
binding,
|
|
170
|
+
protocolVersion: OPERATION_TRANSPORT_PROTOCOL_VERSION,
|
|
171
|
+
requestId,
|
|
172
|
+
operation,
|
|
173
|
+
counter
|
|
174
|
+
};
|
|
175
|
+
const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, metadata.protocolVersion, "request");
|
|
176
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(input ?? {}));
|
|
177
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
178
|
+
name: "AES-GCM",
|
|
179
|
+
iv: nonce(counter),
|
|
180
|
+
additionalData: new TextEncoder().encode(aad(metadata, "request")),
|
|
181
|
+
tagLength: 128
|
|
182
|
+
}, key, plaintext);
|
|
183
|
+
return {
|
|
184
|
+
type: "encrypted_operation_request",
|
|
185
|
+
...envelopeMetadata(metadata),
|
|
186
|
+
...(deadlineUnixMs === undefined ? {} : { deadline_unix_ms: deadlineUnixMs }),
|
|
187
|
+
ciphertext: bytesToBase64Url(new Uint8Array(ciphertext))
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export async function decryptRelayResponse(store, handle, binding, request, response) {
|
|
191
|
+
if (!sameEnvelopeMetadata(request, response)) {
|
|
192
|
+
throw new RelayCryptoError("invalid_encrypted_response", "Encrypted response metadata does not match its request.");
|
|
193
|
+
}
|
|
194
|
+
const record = await requireAgreementKey(store, handle, binding.encryption.application_agreement_public_key);
|
|
195
|
+
const metadata = {
|
|
196
|
+
binding,
|
|
197
|
+
protocolVersion: request.protocol_version,
|
|
198
|
+
requestId: request.request_id,
|
|
199
|
+
operation: request.operation,
|
|
200
|
+
counter: request.counter
|
|
201
|
+
};
|
|
202
|
+
const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, request.protocol_version, "response");
|
|
203
|
+
try {
|
|
204
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
205
|
+
name: "AES-GCM",
|
|
206
|
+
iv: nonce(request.counter),
|
|
207
|
+
additionalData: new TextEncoder().encode(aad(metadata, "response")),
|
|
208
|
+
tagLength: 128
|
|
209
|
+
}, key, toArrayBuffer(base64UrlToBytes(response.ciphertext)));
|
|
210
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
|
|
211
|
+
if (value?.ok === true && "result" in value)
|
|
212
|
+
return value;
|
|
213
|
+
if (value?.ok === false && isConnectProblem(value.problem))
|
|
214
|
+
return value;
|
|
215
|
+
throw new Error("invalid response body");
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error instanceof RelayCryptoError)
|
|
219
|
+
throw error;
|
|
220
|
+
throw new RelayCryptoError("encrypted_response_rejected", "The encrypted connector response could not be authenticated.");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export class RelayCryptoError extends Error {
|
|
224
|
+
code;
|
|
225
|
+
constructor(code, message) {
|
|
226
|
+
super(message);
|
|
227
|
+
this.code = code;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function generateGrantKey(handle) {
|
|
231
|
+
const agreement = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
232
|
+
const agreementPublicKey = bytesToBase64Url(new Uint8Array(await crypto.subtle.exportKey("raw", agreement.publicKey)));
|
|
233
|
+
const agreementPkcs8 = await crypto.subtle.exportKey("pkcs8", agreement.privateKey);
|
|
234
|
+
const agreementPrivateKey = await crypto.subtle.importKey("pkcs8", agreementPkcs8, { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
|
|
235
|
+
const signing = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
|
|
236
|
+
const signingPublicKey = bytesToBase64Url(new Uint8Array(await crypto.subtle.exportKey("raw", signing.publicKey)));
|
|
237
|
+
const signingPkcs8 = await crypto.subtle.exportKey("pkcs8", signing.privateKey);
|
|
238
|
+
const signingPrivateKey = await crypto.subtle.importKey("pkcs8", signingPkcs8, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
239
|
+
return {
|
|
240
|
+
handle,
|
|
241
|
+
agreementPrivateKey,
|
|
242
|
+
agreementPublicKey,
|
|
243
|
+
signingPrivateKey,
|
|
244
|
+
signingPublicKey
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async function requireAgreementKey(store, handle, expectedPublicKey) {
|
|
248
|
+
const record = await store.get(handle);
|
|
249
|
+
if (!record || record.agreementPublicKey !== expectedPublicKey) {
|
|
250
|
+
throw new RelayCryptoError("missing_grant_key", "The encrypted grant key is unavailable or does not match the grant.");
|
|
251
|
+
}
|
|
252
|
+
return record;
|
|
253
|
+
}
|
|
254
|
+
async function requireSigningKey(store, handle, expectedPublicKey) {
|
|
255
|
+
const record = await store.get(handle);
|
|
256
|
+
if (!record || record.signingPublicKey !== expectedPublicKey) {
|
|
257
|
+
throw new RelayCryptoError("missing_grant_key", "The remote authority signing key is unavailable or does not match the grant.");
|
|
258
|
+
}
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
async function deriveDirectionalKey(privateKey, binding, protocolVersion, direction) {
|
|
262
|
+
const shared = await deriveP256SharedSecret(privateKey, binding.encryption.connector_agreement_public_key);
|
|
263
|
+
const material = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
|
|
264
|
+
const contextBytes = new TextEncoder().encode(context(binding, protocolVersion));
|
|
265
|
+
const salt = await crypto.subtle.digest("SHA-256", contextBytes);
|
|
266
|
+
return crypto.subtle.deriveKey({
|
|
267
|
+
name: "HKDF",
|
|
268
|
+
hash: "SHA-256",
|
|
269
|
+
salt,
|
|
270
|
+
info: new TextEncoder().encode(direction === "request" ? REQUEST_INFO : RESPONSE_INFO)
|
|
271
|
+
}, material, { name: "AES-GCM", length: 256 }, false, direction === "request" ? ["encrypt"] : ["decrypt"]);
|
|
272
|
+
}
|
|
273
|
+
/** @internal Shared ECDH primitive used by the independently keyed file data plane. */
|
|
274
|
+
export async function deriveP256SharedSecret(privateKey, peerPublicKey) {
|
|
275
|
+
try {
|
|
276
|
+
const peer = await crypto.subtle.importKey("raw", toArrayBuffer(p256PublicKey(peerPublicKey)), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
|
277
|
+
return await crypto.subtle.deriveBits({ name: "ECDH", public: peer }, privateKey, 256);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw new RelayCryptoError("invalid_public_key", "The connector relay public key is invalid.");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function context(binding, protocolVersion) {
|
|
284
|
+
const value = binding.encryption;
|
|
285
|
+
return [
|
|
286
|
+
"mdbase-connect",
|
|
287
|
+
protocolVersion,
|
|
288
|
+
value.suite,
|
|
289
|
+
binding.grantId,
|
|
290
|
+
binding.applicationId,
|
|
291
|
+
value.connector_id,
|
|
292
|
+
value.collection_id,
|
|
293
|
+
value.scope_epoch,
|
|
294
|
+
value.key_id
|
|
295
|
+
].join("|");
|
|
296
|
+
}
|
|
297
|
+
function aad(metadata, direction) {
|
|
298
|
+
return [
|
|
299
|
+
context(metadata.binding, metadata.protocolVersion),
|
|
300
|
+
metadata.requestId,
|
|
301
|
+
direction,
|
|
302
|
+
metadata.operation,
|
|
303
|
+
metadata.counter
|
|
304
|
+
].join("|");
|
|
305
|
+
}
|
|
306
|
+
function envelopeMetadata(metadata) {
|
|
307
|
+
const encryption = metadata.binding.encryption;
|
|
308
|
+
return {
|
|
309
|
+
protocol_version: metadata.protocolVersion,
|
|
310
|
+
suite: RELAY_ENCRYPTION_SUITE,
|
|
311
|
+
request_id: metadata.requestId,
|
|
312
|
+
grant_id: metadata.binding.grantId,
|
|
313
|
+
application_id: metadata.binding.applicationId,
|
|
314
|
+
connector_id: encryption.connector_id,
|
|
315
|
+
collection_id: encryption.collection_id,
|
|
316
|
+
operation: metadata.operation,
|
|
317
|
+
scope_epoch: encryption.scope_epoch,
|
|
318
|
+
key_id: encryption.key_id,
|
|
319
|
+
counter: metadata.counter
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function sameEnvelopeMetadata(request, response) {
|
|
323
|
+
return response.type === "encrypted_operation_response"
|
|
324
|
+
&& typeof response.ciphertext === "string"
|
|
325
|
+
&& request.protocol_version === response.protocol_version
|
|
326
|
+
&& request.suite === response.suite
|
|
327
|
+
&& request.request_id === response.request_id
|
|
328
|
+
&& request.grant_id === response.grant_id
|
|
329
|
+
&& request.application_id === response.application_id
|
|
330
|
+
&& request.connector_id === response.connector_id
|
|
331
|
+
&& request.collection_id === response.collection_id
|
|
332
|
+
&& request.operation === response.operation
|
|
333
|
+
&& request.scope_epoch === response.scope_epoch
|
|
334
|
+
&& request.key_id === response.key_id
|
|
335
|
+
&& request.counter === response.counter;
|
|
336
|
+
}
|
|
337
|
+
export function validateGrantEncryption(encryption) {
|
|
338
|
+
if (encryption.protocol_version !== GRANT_ENCRYPTION_PROTOCOL_VERSION
|
|
339
|
+
|| encryption.suite !== RELAY_ENCRYPTION_SUITE
|
|
340
|
+
|| !Number.isSafeInteger(encryption.scope_epoch)
|
|
341
|
+
|| encryption.scope_epoch <= 0
|
|
342
|
+
|| typeof encryption.key_id !== "string"
|
|
343
|
+
|| encryption.key_id.length === 0
|
|
344
|
+
|| encryption.key_id.length > 200
|
|
345
|
+
|| encryption.key_id.includes("|")) {
|
|
346
|
+
throw new RelayCryptoError("unsupported_encryption", "The grant uses an unsupported relay encryption profile.");
|
|
347
|
+
}
|
|
348
|
+
if (typeof encryption.connector_id !== "string"
|
|
349
|
+
|| !UUID_PATTERN.test(encryption.connector_id)
|
|
350
|
+
|| typeof encryption.collection_id !== "string"
|
|
351
|
+
|| !UUID_PATTERN.test(encryption.collection_id)) {
|
|
352
|
+
throw new RelayCryptoError("unsupported_encryption", "The grant uses invalid encrypted relay identities.");
|
|
353
|
+
}
|
|
354
|
+
p256PublicKey(encryption.application_agreement_public_key);
|
|
355
|
+
p256PublicKey(encryption.connector_agreement_public_key);
|
|
356
|
+
}
|
|
357
|
+
function nonce(counter) {
|
|
358
|
+
const value = BigInt(counter);
|
|
359
|
+
if (value <= 0n || value > MAX_U64 || value.toString() !== counter) {
|
|
360
|
+
throw new RelayCryptoError("invalid_counter", "The encrypted relay counter is invalid.");
|
|
361
|
+
}
|
|
362
|
+
const result = new Uint8Array(new ArrayBuffer(12));
|
|
363
|
+
const view = new DataView(result.buffer);
|
|
364
|
+
view.setBigUint64(4, value, false);
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
function bytesToBase64Url(bytes) {
|
|
368
|
+
let binary = "";
|
|
369
|
+
for (const byte of bytes)
|
|
370
|
+
binary += String.fromCharCode(byte);
|
|
371
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
372
|
+
}
|
|
373
|
+
async function sha256Base64Url(value) {
|
|
374
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
375
|
+
return bytesToBase64Url(new Uint8Array(digest));
|
|
376
|
+
}
|
|
377
|
+
function base64UrlToBytes(value) {
|
|
378
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
379
|
+
throw new RelayCryptoError("invalid_base64", "Encrypted relay data is malformed.");
|
|
380
|
+
const padding = "=".repeat((4 - value.length % 4) % 4);
|
|
381
|
+
const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding);
|
|
382
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
383
|
+
}
|
|
384
|
+
function p256PublicKey(value) {
|
|
385
|
+
const bytes = base64UrlToBytes(value);
|
|
386
|
+
if (bytes.length !== 65 || bytes[0] !== 4) {
|
|
387
|
+
throw new RelayCryptoError("invalid_public_key", "Encrypted relay public keys must be uncompressed P-256 points.");
|
|
388
|
+
}
|
|
389
|
+
return bytes;
|
|
390
|
+
}
|
|
391
|
+
function toArrayBuffer(value) {
|
|
392
|
+
return Uint8Array.from(value).buffer;
|
|
393
|
+
}
|
|
394
|
+
function idbRequest(request) {
|
|
395
|
+
return new Promise((resolve, reject) => {
|
|
396
|
+
request.onerror = () => reject(request.error);
|
|
397
|
+
request.onsuccess = () => resolve(request.result);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function idbWrite(database, operation) {
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
const transaction = database.transaction(STORE_NAME, "readwrite");
|
|
403
|
+
const request = operation(transaction.objectStore(STORE_NAME));
|
|
404
|
+
request.onerror = () => reject(request.error);
|
|
405
|
+
transaction.onerror = () => reject(transaction.error);
|
|
406
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Grant key transaction aborted."));
|
|
407
|
+
transaction.oncomplete = () => resolve();
|
|
408
|
+
});
|
|
409
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { StoredToken } from "./internal-types.js";
|
|
2
|
+
export declare function tokenSupportsDirectAccess(token: StoredToken | null, mode: "auto" | "disabled"): boolean;
|
|
3
|
+
export declare function probeLoopbackAccess(loopbackUrl: string, expectedOperationProtocol: number, signal?: AbortSignal): Promise<boolean>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { loopbackRequest } from "./operation-helpers.js";
|
|
2
|
+
import { decodeJsonResponse } from "./runtime-utils.js";
|
|
3
|
+
export function tokenSupportsDirectAccess(token, mode) {
|
|
4
|
+
if (!token || token.authority || !token.encryption || !token.grantId || !token.keyHandle) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
if (mode === "disabled")
|
|
8
|
+
return false;
|
|
9
|
+
return typeof location === "undefined"
|
|
10
|
+
|| !token.applicationOrigin
|
|
11
|
+
|| token.applicationOrigin === location.origin;
|
|
12
|
+
}
|
|
13
|
+
export async function probeLoopbackAccess(loopbackUrl, expectedOperationProtocol, signal) {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(`${loopbackUrl}/v1/ready`, loopbackRequest({
|
|
16
|
+
method: "GET",
|
|
17
|
+
cache: "no-store",
|
|
18
|
+
signal
|
|
19
|
+
}));
|
|
20
|
+
const body = await decodeJsonResponse(response, "invalid_operation_response", "The connector returned an invalid readiness response.").catch(() => null);
|
|
21
|
+
return response.ok
|
|
22
|
+
&& body?.service === "mdbase-connect"
|
|
23
|
+
&& body?.loopback_protocol_version === 1
|
|
24
|
+
&& body?.operation_transport_protocol_version === expectedOperationProtocol;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ConnectOperationOutcome, type ConnectProblem, type ConnectProblemCode, type ConnectProblemDetailsByCode, type KnownConnectProblem, type MdbaseOperationEnvelope } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
interface ProblemOptions {
|
|
3
|
+
operationOutcome?: ConnectOperationOutcome;
|
|
4
|
+
traceId?: string;
|
|
5
|
+
status?: number;
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
}
|
|
8
|
+
type ProblemOptionsFor<Code extends ConnectProblemCode> = ConnectProblemDetailsByCode[Code] extends undefined ? ProblemOptions & {
|
|
9
|
+
details?: never;
|
|
10
|
+
} : {} extends ConnectProblemDetailsByCode[Code] ? ProblemOptions & {
|
|
11
|
+
details?: ConnectProblemDetailsByCode[Code];
|
|
12
|
+
} : ProblemOptions & {
|
|
13
|
+
details: ConnectProblemDetailsByCode[Code];
|
|
14
|
+
};
|
|
15
|
+
type ProblemArguments<Code extends ConnectProblemCode> = ConnectProblemDetailsByCode[Code] extends undefined ? [options?: ProblemOptionsFor<Code>] : {} extends ConnectProblemDetailsByCode[Code] ? [options?: ProblemOptionsFor<Code>] : [options: ProblemOptionsFor<Code>];
|
|
16
|
+
export interface ConnectErrorContext {
|
|
17
|
+
status?: number;
|
|
18
|
+
cause?: unknown;
|
|
19
|
+
}
|
|
20
|
+
/** Internal carrier used while adapting throwing transports into public outcomes. */
|
|
21
|
+
export declare class MdbaseConnectError extends Error {
|
|
22
|
+
readonly problem: ConnectProblem;
|
|
23
|
+
readonly code: ConnectProblem["code"];
|
|
24
|
+
readonly status?: number;
|
|
25
|
+
constructor(problem: ConnectProblem, context?: ConnectErrorContext);
|
|
26
|
+
get retryable(): boolean;
|
|
27
|
+
get requiresAuthorization(): boolean;
|
|
28
|
+
get outcomeUnknown(): boolean;
|
|
29
|
+
get recovery(): "choose_collection" | "contact_support" | "fix_request" | "none" | "reauthorize" | "refresh" | "repair_collection" | "resolve_conflict" | "resolve_outcome" | "resume_connector_access" | "retry" | "upgrade_application" | "upgrade_collection" | "upgrade_connector";
|
|
30
|
+
get details(): unknown;
|
|
31
|
+
}
|
|
32
|
+
export declare function connectProblem<Code extends ConnectProblemCode>(code: Code, message: string, ...[options]: ProblemArguments<Code>): KnownConnectProblem<Code>;
|
|
33
|
+
export declare function unknownConnectProblem(serverCode: string, message: string, options?: ProblemOptions & {
|
|
34
|
+
details?: unknown;
|
|
35
|
+
}): ConnectProblem;
|
|
36
|
+
export declare function connectError<Code extends ConnectProblemCode>(code: Code, message: string, ...args: ProblemArguments<Code>): MdbaseConnectError;
|
|
37
|
+
export declare function serverConnectError(serverCode: string, message: string, options?: ProblemOptions & {
|
|
38
|
+
details?: unknown;
|
|
39
|
+
status?: number;
|
|
40
|
+
cause?: unknown;
|
|
41
|
+
}): MdbaseConnectError;
|
|
42
|
+
export declare function operationProblem<Result>(envelope: MdbaseOperationEnvelope<Result>): KnownConnectProblem<"collection_configuration_invalid" | "collection_invalid" | "collection_type_registry_invalid" | "collection_version_unsupported" | "operation_invalid">;
|
|
43
|
+
/** True only when repeating an operation is safe without asking the user. */
|
|
44
|
+
export declare function isRetryableConnectError(error: unknown): boolean;
|
|
45
|
+
export {};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { CONNECT_PROBLEM_CATALOG, CONNECT_PROBLEM_VERSION, normalizeConnectProblem } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
/** Internal carrier used while adapting throwing transports into public outcomes. */
|
|
3
|
+
export class MdbaseConnectError extends Error {
|
|
4
|
+
problem;
|
|
5
|
+
code;
|
|
6
|
+
status;
|
|
7
|
+
constructor(problem, context = {}) {
|
|
8
|
+
super(problem.message, context.cause === undefined ? undefined : { cause: context.cause });
|
|
9
|
+
this.problem = problem;
|
|
10
|
+
this.name = "MdbaseConnectError";
|
|
11
|
+
this.code = problem.code;
|
|
12
|
+
this.status = context.status;
|
|
13
|
+
}
|
|
14
|
+
get retryable() {
|
|
15
|
+
return this.problem.recovery === "retry"
|
|
16
|
+
&& this.problem.operation_outcome !== "unknown";
|
|
17
|
+
}
|
|
18
|
+
get requiresAuthorization() {
|
|
19
|
+
return this.problem.category === "authorization";
|
|
20
|
+
}
|
|
21
|
+
get outcomeUnknown() {
|
|
22
|
+
return this.problem.operation_outcome === "unknown";
|
|
23
|
+
}
|
|
24
|
+
get recovery() {
|
|
25
|
+
return this.problem.recovery;
|
|
26
|
+
}
|
|
27
|
+
get details() {
|
|
28
|
+
return this.problem.details;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function connectProblem(code, message, ...[options = {}]) {
|
|
32
|
+
const definition = CONNECT_PROBLEM_CATALOG[code];
|
|
33
|
+
return {
|
|
34
|
+
problem_version: CONNECT_PROBLEM_VERSION,
|
|
35
|
+
code,
|
|
36
|
+
category: definition.category,
|
|
37
|
+
recovery: definition.recovery,
|
|
38
|
+
message,
|
|
39
|
+
...(options.details === undefined ? {} : { details: options.details }),
|
|
40
|
+
...(options.operationOutcome === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: { operation_outcome: options.operationOutcome }),
|
|
43
|
+
...(options.traceId === undefined ? {} : { trace_id: options.traceId })
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function unknownConnectProblem(serverCode, message, options = {}) {
|
|
47
|
+
return normalizeConnectProblem(serverCode, message, {
|
|
48
|
+
details: options.details,
|
|
49
|
+
operation_outcome: options.operationOutcome,
|
|
50
|
+
trace_id: options.traceId
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function connectError(code, message, ...args) {
|
|
54
|
+
const options = args[0];
|
|
55
|
+
return new MdbaseConnectError(connectProblem(code, message, ...args), {
|
|
56
|
+
status: options?.status,
|
|
57
|
+
cause: options?.cause
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function serverConnectError(serverCode, message, options = {}) {
|
|
61
|
+
const { status, cause, ...problemOptions } = options;
|
|
62
|
+
return new MdbaseConnectError(normalizeConnectProblem(serverCode, message, {
|
|
63
|
+
details: problemOptions.details,
|
|
64
|
+
operation_outcome: problemOptions.operationOutcome,
|
|
65
|
+
trace_id: problemOptions.traceId
|
|
66
|
+
}), { status, cause });
|
|
67
|
+
}
|
|
68
|
+
export function operationProblem(envelope) {
|
|
69
|
+
const code = diagnosticProblemCode(envelope.diagnostics);
|
|
70
|
+
const message = diagnosticMessage(envelope.diagnostics);
|
|
71
|
+
if (code === "collection_version_unsupported") {
|
|
72
|
+
const versions = diagnosticVersions(envelope.diagnostics);
|
|
73
|
+
return connectProblem(code, message, {
|
|
74
|
+
operationOutcome: "rejected",
|
|
75
|
+
details: versions
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (code !== "operation_invalid") {
|
|
79
|
+
return connectProblem(code, message, {
|
|
80
|
+
operationOutcome: "rejected",
|
|
81
|
+
details: { diagnostics: envelope.diagnostics }
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return connectProblem(code, message, {
|
|
85
|
+
operationOutcome: "rejected",
|
|
86
|
+
details: {
|
|
87
|
+
diagnostics: envelope.diagnostics,
|
|
88
|
+
partial_result: envelope.result
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function diagnosticProblemCode(diagnostics) {
|
|
93
|
+
const codes = new Set(diagnostics
|
|
94
|
+
.filter((diagnostic) => diagnostic.severity === "error")
|
|
95
|
+
.map((diagnostic) => diagnostic.code));
|
|
96
|
+
if (["migration_required", "migration_lossy", "unsupported_version"]
|
|
97
|
+
.some((code) => codes.has(code)))
|
|
98
|
+
return "collection_version_unsupported";
|
|
99
|
+
if (["invalid_config", "invalid_configuration", "missing_config", "missing_spec_version"]
|
|
100
|
+
.some((code) => codes.has(code)))
|
|
101
|
+
return "collection_configuration_invalid";
|
|
102
|
+
if ([
|
|
103
|
+
"duplicate_type",
|
|
104
|
+
"invalid_contract_definition",
|
|
105
|
+
"invalid_data_contract",
|
|
106
|
+
"invalid_type_definition",
|
|
107
|
+
"invalid_type_path"
|
|
108
|
+
].some((code) => codes.has(code)))
|
|
109
|
+
return "collection_type_registry_invalid";
|
|
110
|
+
if (["collection_invalid", "collection_open_failed"]
|
|
111
|
+
.some((code) => codes.has(code)))
|
|
112
|
+
return "collection_invalid";
|
|
113
|
+
return "operation_invalid";
|
|
114
|
+
}
|
|
115
|
+
function diagnosticVersions(diagnostics) {
|
|
116
|
+
const source = diagnostics
|
|
117
|
+
.map((diagnostic) => diagnostic.details)
|
|
118
|
+
.find((details) => isRecord(details));
|
|
119
|
+
return {
|
|
120
|
+
current_version: typeof source?.current_version === "string"
|
|
121
|
+
? source.current_version
|
|
122
|
+
: "0.2.x",
|
|
123
|
+
required_version: typeof source?.required_version === "string"
|
|
124
|
+
? source.required_version
|
|
125
|
+
: "0.3.0"
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function isRecord(value) {
|
|
129
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
130
|
+
}
|
|
131
|
+
/** True only when repeating an operation is safe without asking the user. */
|
|
132
|
+
export function isRetryableConnectError(error) {
|
|
133
|
+
return error instanceof MdbaseConnectError && error.retryable;
|
|
134
|
+
}
|
|
135
|
+
function diagnosticMessage(diagnostics) {
|
|
136
|
+
return diagnostics
|
|
137
|
+
.filter((item) => item.severity === "error")
|
|
138
|
+
.map((item) => item.message)
|
|
139
|
+
.join(" ")
|
|
140
|
+
|| diagnostics.map((item) => item.message).join(" ")
|
|
141
|
+
|| "The collection rejected this operation.";
|
|
142
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Minimal framework-neutral shape consumed by React's useSyncExternalStore. */
|
|
2
|
+
export interface MdbaseExternalStore<Snapshot> {
|
|
3
|
+
subscribe(listener: () => void): () => void;
|
|
4
|
+
getSnapshot(): Snapshot;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Adapt an existing SDK session without introducing another cache or state
|
|
8
|
+
* owner. The stable object can be passed directly to useSyncExternalStore.
|
|
9
|
+
*/
|
|
10
|
+
export declare function externalStore<Snapshot>(source: MdbaseExternalStore<Snapshot>): MdbaseExternalStore<Snapshot>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt an existing SDK session without introducing another cache or state
|
|
3
|
+
* owner. The stable object can be passed directly to useSyncExternalStore.
|
|
4
|
+
*/
|
|
5
|
+
export function externalStore(source) {
|
|
6
|
+
return {
|
|
7
|
+
subscribe: (listener) => source.subscribe(listener),
|
|
8
|
+
getSnapshot: () => source.getSnapshot()
|
|
9
|
+
};
|
|
10
|
+
}
|