@calimero-network/mero-js 22.6.0 → 22.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account/account.d.ts +99 -4
- package/dist/account/account.js +0 -0
- package/dist/account/index.d.ts +6 -2
- package/dist/account/index.js +3 -1
- package/dist/admin-api/admin-client.d.ts +138 -2
- package/dist/admin-api/admin-client.js +237 -1
- package/dist/admin-api/admin-types.d.ts +159 -5
- package/dist/capabilities.d.ts +42 -0
- package/dist/capabilities.js +64 -0
- package/dist/cloud/cloud-client.d.ts +38 -8
- package/dist/cloud/cloud-client.js +38 -21
- package/dist/cloud/connect.d.ts +101 -2
- package/dist/cloud/connect.js +84 -8
- package/dist/cloud/enrol-redirect.d.ts +195 -0
- package/dist/cloud/enrol-redirect.js +236 -0
- package/dist/cloud/index.d.ts +6 -2
- package/dist/cloud/index.js +7 -1
- package/dist/cloud/link-redirect.d.ts +85 -0
- package/dist/cloud/link-redirect.js +105 -0
- package/dist/crypto/internal.d.ts +15 -0
- package/dist/crypto/internal.js +18 -0
- package/dist/device-cert/device-cert.d.ts +85 -2
- package/dist/device-cert/device-cert.js +100 -9
- package/dist/device-cert/index.d.ts +2 -2
- package/dist/device-cert/index.js +1 -1
- package/dist/events/sse.d.ts +22 -2
- package/dist/events/sse.js +20 -11
- package/dist/http-client/http-types.d.ts +18 -0
- package/dist/http-client/web-client.js +21 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +1999 -536
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +11 -6
- package/dist/index.js +17 -3
- package/dist/index.mjs +1999 -536
- package/dist/index.mjs.map +4 -4
- package/dist/login/login.d.ts +11 -1
- package/dist/login/login.js +5 -4
- package/dist/login/session.d.ts +21 -5
- package/dist/login/session.js +7 -15
- package/dist/namespace-op/namespace-op.d.ts +14 -2
- package/dist/namespace-op/namespace-op.js +9 -5
- package/dist/relay/index.d.ts +3 -3
- package/dist/relay/index.js +1 -1
- package/dist/relay/nonce-source.d.ts +135 -0
- package/dist/relay/nonce-source.js +187 -19
- package/dist/relay/relay-client.d.ts +148 -5
- package/dist/relay/relay-client.js +187 -7
- package/dist/relay-attestation/attest-node-key.d.ts +66 -0
- package/dist/relay-attestation/attest-node-key.js +110 -0
- package/dist/relay-attestation/index.d.ts +1 -0
- package/dist/relay-attestation/index.js +1 -0
- package/dist/request-proof/authorizer.d.ts +48 -0
- package/dist/request-proof/authorizer.js +65 -0
- package/dist/request-proof/index.d.ts +4 -0
- package/dist/request-proof/index.js +2 -0
- package/dist/request-proof/request-proof.d.ts +56 -0
- package/dist/request-proof/request-proof.js +111 -0
- package/dist/rpc/index.d.ts +21 -0
- package/dist/rpc/index.js +20 -0
- package/dist/transport/index.d.ts +6 -0
- package/dist/transport/index.js +3 -0
- package/dist/transport/mero-client.d.ts +286 -0
- package/dist/transport/mero-client.js +379 -0
- package/dist/transport/relay-observer.d.ts +152 -0
- package/dist/transport/relay-observer.js +173 -0
- package/dist/transport/relay-transport.d.ts +73 -0
- package/dist/transport/relay-transport.js +94 -0
- package/dist/transport/types.d.ts +100 -0
- package/dist/transport/types.js +32 -0
- package/dist/warrant/governance-op.d.ts +47 -1
- package/dist/warrant/governance-op.js +62 -2
- package/dist/warrant/index.d.ts +2 -2
- package/dist/warrant/index.js +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -170,8 +170,27 @@ var WebHttpClient = class {
|
|
|
170
170
|
} else {
|
|
171
171
|
headersObj = headers;
|
|
172
172
|
}
|
|
173
|
+
const method = init?.method || "GET";
|
|
174
|
+
if (this.transport.authorizeRequest) {
|
|
175
|
+
const body = init?.body;
|
|
176
|
+
if (body !== void 0 && typeof body !== "string" && !(body instanceof Uint8Array)) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
"this transport authorizes each request against its body, so the body must be a string or Uint8Array \u2014 a stream cannot be signed, because the signature would have to read bytes the request then cannot send."
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
Object.assign(
|
|
182
|
+
headersObj,
|
|
183
|
+
await this.transport.authorizeRequest({
|
|
184
|
+
method,
|
|
185
|
+
path: new URL(url).pathname,
|
|
186
|
+
// Narrowed by the guard above; `BodyInit` is wider than what can be
|
|
187
|
+
// signed.
|
|
188
|
+
body
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
}
|
|
173
192
|
const requestInit = {
|
|
174
|
-
method
|
|
193
|
+
method,
|
|
175
194
|
headers: headersObj
|
|
176
195
|
};
|
|
177
196
|
const isStreamBody = init?.body instanceof ReadableStream || typeof init?.body === "object" && init?.body !== null && "getReader" in init.body && !(init.body instanceof Blob);
|
|
@@ -745,6 +764,41 @@ var CAPABILITIES = {
|
|
|
745
764
|
*/
|
|
746
765
|
CAN_AUTHOR_ON_BEHALF: 1 << 9
|
|
747
766
|
};
|
|
767
|
+
var CAPABILITY_PRESETS = {
|
|
768
|
+
/** No capabilities. */
|
|
769
|
+
NONE: 0,
|
|
770
|
+
/** Join Open subgroups only: what a namespace starts with. */
|
|
771
|
+
JOIN_ONLY: CAPABILITIES.CAN_JOIN_OPEN_SUBGROUPS,
|
|
772
|
+
/**
|
|
773
|
+
* Create contexts, invite members, join Open subgroups (= 7). A member who
|
|
774
|
+
* can work in the namespace but not reshape it.
|
|
775
|
+
*/
|
|
776
|
+
CONTRIBUTOR: CAPABILITIES.CAN_CREATE_CONTEXT | CAPABILITIES.CAN_INVITE_MEMBERS | CAPABILITIES.CAN_JOIN_OPEN_SUBGROUPS,
|
|
777
|
+
/**
|
|
778
|
+
* `CONTRIBUTOR` plus create and delete subgroups and manage their visibility
|
|
779
|
+
* (= 231). What mero-chat gives members, so they can start and close their
|
|
780
|
+
* own channels.
|
|
781
|
+
*/
|
|
782
|
+
COLLABORATOR: CAPABILITIES.CAN_CREATE_CONTEXT | CAPABILITIES.CAN_INVITE_MEMBERS | CAPABILITIES.CAN_JOIN_OPEN_SUBGROUPS | CAPABILITIES.CAN_CREATE_SUBGROUP | CAPABILITIES.CAN_DELETE_SUBGROUP | CAPABILITIES.CAN_MANAGE_VISIBILITY
|
|
783
|
+
};
|
|
784
|
+
function capMask(...caps) {
|
|
785
|
+
let mask = 0;
|
|
786
|
+
for (const cap of caps) {
|
|
787
|
+
if (typeof cap === "number") {
|
|
788
|
+
mask |= cap;
|
|
789
|
+
} else if (Object.prototype.hasOwnProperty.call(CAPABILITIES, cap)) {
|
|
790
|
+
mask |= CAPABILITIES[cap];
|
|
791
|
+
} else {
|
|
792
|
+
throw new Error(`unknown capability: ${String(cap)}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return mask >>> 0;
|
|
796
|
+
}
|
|
797
|
+
function capNames(mask) {
|
|
798
|
+
return Object.keys(CAPABILITIES).filter(
|
|
799
|
+
(name) => hasCap(mask, CAPABILITIES[name])
|
|
800
|
+
);
|
|
801
|
+
}
|
|
748
802
|
function hasCap(mask, cap) {
|
|
749
803
|
const capU32 = cap >>> 0;
|
|
750
804
|
return (mask & capU32) >>> 0 === capU32;
|
|
@@ -896,6 +950,50 @@ async function redeemInvitation(parsed, redeemer) {
|
|
|
896
950
|
}
|
|
897
951
|
|
|
898
952
|
// src/admin-api/admin-client.ts
|
|
953
|
+
var WarrantNonceRouteUnavailableError = class extends Error {
|
|
954
|
+
constructor(path, cause) {
|
|
955
|
+
super(
|
|
956
|
+
`this node does not serve warrant-nonce discovery (404 on ${path}); it predates the route, so a client must fall back to its own nonce counter`
|
|
957
|
+
);
|
|
958
|
+
this.path = path;
|
|
959
|
+
this.cause = cause;
|
|
960
|
+
this.name = "WarrantNonceRouteUnavailableError";
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
function u64FromBody(body, parsed, field) {
|
|
964
|
+
const value = parsed[field];
|
|
965
|
+
if (value === void 0 || value === null) return void 0;
|
|
966
|
+
const digits = new RegExp(`"${field}"\\s*:\\s*(\\d+)`).exec(body);
|
|
967
|
+
if (digits) return BigInt(digits[1]);
|
|
968
|
+
if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
|
|
969
|
+
throw new Error(`warrant-nonce response field \`${field}\` is not a u64: ${JSON.stringify(value)}`);
|
|
970
|
+
}
|
|
971
|
+
function parseWarrantNonce(body) {
|
|
972
|
+
const parsed = JSON.parse(body);
|
|
973
|
+
const data = parsed?.data;
|
|
974
|
+
if (!data || typeof data !== "object") {
|
|
975
|
+
throw new Error(`warrant-nonce response had no \`data\`: ${body.slice(0, 200)}`);
|
|
976
|
+
}
|
|
977
|
+
const common = {
|
|
978
|
+
contextId: String(data.contextId ?? ""),
|
|
979
|
+
authorDeviceKey: String(data.authorDeviceKey ?? ""),
|
|
980
|
+
seen: data.seen === true,
|
|
981
|
+
highWaterNonce: u64FromBody(body, data, "highWaterNonce"),
|
|
982
|
+
// Not defaulted: a node that stopped sending it is a node whose window this
|
|
983
|
+
// client would be guessing at, and the window is not a nonce input anyway.
|
|
984
|
+
windowWidth: u64FromBody(body, data, "windowWidth") ?? 0n
|
|
985
|
+
};
|
|
986
|
+
const nextNonce = u64FromBody(body, data, "nextNonce");
|
|
987
|
+
if (nextNonce === void 0) {
|
|
988
|
+
return {
|
|
989
|
+
...common,
|
|
990
|
+
kind: "exhausted",
|
|
991
|
+
seen: true,
|
|
992
|
+
highWaterNonce: common.highWaterNonce ?? 0xffffffffffffffffn
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
return { ...common, kind: "open", nextNonce };
|
|
996
|
+
}
|
|
899
997
|
function unwrap(response) {
|
|
900
998
|
return response?.data;
|
|
901
999
|
}
|
|
@@ -1696,6 +1794,13 @@ var AdminApiClient = class {
|
|
|
1696
1794
|
* `namespaceId` names where to publish from, not the extent of the revocation:
|
|
1697
1795
|
* a device belongs to the account, so every namespace holding a binding for it
|
|
1698
1796
|
* is withdrawn from and reported back.
|
|
1797
|
+
*
|
|
1798
|
+
* **Revocation is forward-only.** It withdraws the device's authority from
|
|
1799
|
+
* here on and does NOT retract past authorship: ops the device already signed
|
|
1800
|
+
* keep verifying and stay in the DAG, because the certificate that authorised
|
|
1801
|
+
* them was valid when the signature was made. Nothing in this system can
|
|
1802
|
+
* un-author what a device wrote, so a UI built on this call must not offer
|
|
1803
|
+
* that - "this device can no longer act" is the only claim it supports.
|
|
1699
1804
|
*/
|
|
1700
1805
|
async revokeAccountDevice(namespaceId, request) {
|
|
1701
1806
|
return unwrap(
|
|
@@ -1738,6 +1843,138 @@ var AdminApiClient = class {
|
|
|
1738
1843
|
}
|
|
1739
1844
|
return response;
|
|
1740
1845
|
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Account -> devices for the members of a group, as its live bindings record
|
|
1848
|
+
* them.
|
|
1849
|
+
*
|
|
1850
|
+
* The join between a group member listing, which names accounts, and a
|
|
1851
|
+
* context's identities, which name bare signing keys - both 64 hex, and
|
|
1852
|
+
* nothing but the source tells them apart. Batch rather than per-account
|
|
1853
|
+
* because the key -> account direction gets asked about arbitrary authors,
|
|
1854
|
+
* which a per-account route would answer in N calls.
|
|
1855
|
+
*
|
|
1856
|
+
* What comes back is **caller-scoped by the node**: an admin of the group sees
|
|
1857
|
+
* every member's devices, a plain member only its own entry. A single-entry
|
|
1858
|
+
* answer is therefore a statement about this caller's role, not about the
|
|
1859
|
+
* group's size.
|
|
1860
|
+
*
|
|
1861
|
+
* Only live bindings appear, so a revoked device is absent here - use
|
|
1862
|
+
* {@link listAccountDevices} for the account's own devices, which keeps a
|
|
1863
|
+
* revoked one with `revoked: true`. Absence here never means the device never
|
|
1864
|
+
* acted: revocation is forward-only and its past ops remain in the DAG.
|
|
1865
|
+
*
|
|
1866
|
+
* Wrapped in `members` on the wire rather than `data`, unwrapped here for the
|
|
1867
|
+
* same reason {@link listAccountDevices} unwraps `devices`.
|
|
1868
|
+
*/
|
|
1869
|
+
async listGroupMemberDevices(groupId, options) {
|
|
1870
|
+
const params = new URLSearchParams();
|
|
1871
|
+
if (options?.offset !== void 0) params.set("offset", String(options.offset));
|
|
1872
|
+
if (options?.limit !== void 0) params.set("limit", String(options.limit));
|
|
1873
|
+
const query = params.toString();
|
|
1874
|
+
const base = `/admin-api/groups/${encodeURIComponent(groupId)}/member-devices`;
|
|
1875
|
+
const response = await this.httpClient.get(
|
|
1876
|
+
query ? `${base}?${query}` : base
|
|
1877
|
+
);
|
|
1878
|
+
return response.members;
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Where `authorDeviceKey` stands in its warrant-nonce sequence in `contextId`.
|
|
1882
|
+
*
|
|
1883
|
+
* # What this is for
|
|
1884
|
+
*
|
|
1885
|
+
* The nonce is the one input to a warrant that is stateful on the *client*.
|
|
1886
|
+
* Everything else an author needs it either holds (its keys, the method, the
|
|
1887
|
+
* args) or can read from the relay ({@link RelayClient.describe} gives the
|
|
1888
|
+
* executor account and the grant). So an author that has lost its counter —
|
|
1889
|
+
* a browser keyholder in partitioned or periodically cleared storage, which
|
|
1890
|
+
* is its normal lifecycle rather than a fault — otherwise has exactly one
|
|
1891
|
+
* strategy: guess upward and burn a refused round trip per wrong guess, with
|
|
1892
|
+
* each refusal indistinguishable at the API from a genuinely spent nonce.
|
|
1893
|
+
*
|
|
1894
|
+
* Read {@link WarrantNonceOpen.nextNonce} and mint there. If you still hold
|
|
1895
|
+
* your own counter, mint at `max(ownNext, nextNonce)`: nonce state folds per
|
|
1896
|
+
* peer, so a node can only be BEHIND a client whose warrants it has not
|
|
1897
|
+
* applied — never ahead of the truth. An author spread across several relays
|
|
1898
|
+
* asks each and takes the highest answer. {@link createRecoveringNonceSource}
|
|
1899
|
+
* does all of that.
|
|
1900
|
+
*
|
|
1901
|
+
* Purely a read. It spends nothing and advances nothing, so it may be called
|
|
1902
|
+
* as often as you like.
|
|
1903
|
+
*
|
|
1904
|
+
* # Forward compatibility — this route is NEW
|
|
1905
|
+
*
|
|
1906
|
+
* It is not on core `master` yet, so a current node answers **404**, which
|
|
1907
|
+
* this method surfaces as {@link WarrantNonceRouteUnavailableError} rather
|
|
1908
|
+
* than as an opaque {@link HTTPError}. That distinction matters because the
|
|
1909
|
+
* handler itself never 404s: a device with no row is a `200` with
|
|
1910
|
+
* `seen: false`, and a malformed id is a `400`. So a 404 here means the node
|
|
1911
|
+
* does not serve the route at all — an older build, or a deployment that has
|
|
1912
|
+
* not re-mounted it — and the answer is to fall back to a local counter, not
|
|
1913
|
+
* to retry.
|
|
1914
|
+
*
|
|
1915
|
+
* # Authentication
|
|
1916
|
+
*
|
|
1917
|
+
* On the PROTECTED admin router, unlike the two delegated-execution routes it
|
|
1918
|
+
* sits beside: it takes another principal's device key as input and reports
|
|
1919
|
+
* that principal's activity. A `--public-intents` deployment does not serve
|
|
1920
|
+
* this anonymously, so an admin credential is required.
|
|
1921
|
+
*
|
|
1922
|
+
* @param authorDeviceKey the device's signing key, base58 (43-44 chars) or
|
|
1923
|
+
* hex (exactly 64). The node accepts either; the two never collide.
|
|
1924
|
+
*/
|
|
1925
|
+
async getWarrantNonce(contextId, authorDeviceKey) {
|
|
1926
|
+
const path = `/admin-api/contexts/${encodeURIComponent(contextId)}/warrant-nonce/${encodeURIComponent(authorDeviceKey)}`;
|
|
1927
|
+
let raw;
|
|
1928
|
+
try {
|
|
1929
|
+
raw = await this.httpClient.get(path, { parse: "text" });
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
if (error instanceof HTTPError && error.status === 404) {
|
|
1932
|
+
throw new WarrantNonceRouteUnavailableError(path, error);
|
|
1933
|
+
}
|
|
1934
|
+
throw error;
|
|
1935
|
+
}
|
|
1936
|
+
return parseWarrantNonce(raw);
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* The same answer, asked for the way a delegated client has to ask.
|
|
1940
|
+
*
|
|
1941
|
+
* {@link getWarrantNonce} is on the protected admin router, so a keyholder
|
|
1942
|
+
* writing through a relay — the client this whole facility exists for — is
|
|
1943
|
+
* refused it: measured `403` for a session carrying exactly
|
|
1944
|
+
* `context:intent`, `context:query`, `context:subscribe`. That client has no
|
|
1945
|
+
* admin credential and cannot be given one without defeating the point of the
|
|
1946
|
+
* delegated surface.
|
|
1947
|
+
*
|
|
1948
|
+
* What it does have is the credential proving the device is its own, which it
|
|
1949
|
+
* already sends on every write. Presenting it here makes the request
|
|
1950
|
+
* self-scoped: you learn a device's position only by proving, at call time,
|
|
1951
|
+
* that the device is yours. So there is no oracle to protect against, and no
|
|
1952
|
+
* reason for an admin gate.
|
|
1953
|
+
*
|
|
1954
|
+
* The device key is NOT a parameter. It is read from the verified
|
|
1955
|
+
* certificate server-side, which is the only reading that cannot disagree
|
|
1956
|
+
* with the signature — a key supplied alongside would be a second spelling of
|
|
1957
|
+
* the same fact, and two spellings can differ.
|
|
1958
|
+
*
|
|
1959
|
+
* Returns the identical shape {@link getWarrantNonce} returns, so a caller can
|
|
1960
|
+
* swap one for the other without touching what it does with the answer.
|
|
1961
|
+
*
|
|
1962
|
+
* @param authorProof hex-encoded borsh `AccountProof<DeviceCert>` — the same
|
|
1963
|
+
* value passed as `authorProof` to the relay client.
|
|
1964
|
+
*/
|
|
1965
|
+
async getWarrantNonceAsAuthor(contextId, authorProof) {
|
|
1966
|
+
const path = `/admin-api/contexts/${encodeURIComponent(contextId)}/warrant-nonce`;
|
|
1967
|
+
let raw;
|
|
1968
|
+
try {
|
|
1969
|
+
raw = await this.httpClient.post(path, { authorProof }, { parse: "text" });
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
if (error instanceof HTTPError && error.status === 404) {
|
|
1972
|
+
throw new WarrantNonceRouteUnavailableError(path, error);
|
|
1973
|
+
}
|
|
1974
|
+
throw error;
|
|
1975
|
+
}
|
|
1976
|
+
return parseWarrantNonce(raw);
|
|
1977
|
+
}
|
|
1741
1978
|
/**
|
|
1742
1979
|
* Member accounts in the group and the devices bound to each. An admin node
|
|
1743
1980
|
* sees every account; a plain member only its own. `403` if this node is not a
|
|
@@ -2248,6 +2485,16 @@ async function importSigningKey(secretHex) {
|
|
|
2248
2485
|
);
|
|
2249
2486
|
}
|
|
2250
2487
|
}
|
|
2488
|
+
async function verifySignature(publicKey, signature, payload) {
|
|
2489
|
+
const key = await crypto.subtle.importKey(
|
|
2490
|
+
"raw",
|
|
2491
|
+
fromHex(publicKey, "publicKey", 32),
|
|
2492
|
+
{ name: "Ed25519" },
|
|
2493
|
+
false,
|
|
2494
|
+
["verify"]
|
|
2495
|
+
);
|
|
2496
|
+
return crypto.subtle.verify({ name: "Ed25519" }, key, signature, payload);
|
|
2497
|
+
}
|
|
2251
2498
|
async function derivePublicKey(secretHex) {
|
|
2252
2499
|
const seed = fromHex(secretHex, "deviceSecret", 32);
|
|
2253
2500
|
const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
|
|
@@ -2371,6 +2618,15 @@ async function jsonRpcCall(httpClient, method, params) {
|
|
|
2371
2618
|
}
|
|
2372
2619
|
var RpcClient = class {
|
|
2373
2620
|
constructor(opts) {
|
|
2621
|
+
/**
|
|
2622
|
+
* Which write path this is. Present so `RpcClient` satisfies
|
|
2623
|
+
* `ExecuteTransport` as-is — the transport abstraction adopted *this* class's
|
|
2624
|
+
* `execute` shape as the canonical one, so the node side is the interface
|
|
2625
|
+
* rather than an adapter onto it.
|
|
2626
|
+
*/
|
|
2627
|
+
this.kind = "node";
|
|
2628
|
+
/** A node serves `/sse` and `/ws` to the credential it issued. */
|
|
2629
|
+
this.canSubscribe = true;
|
|
2374
2630
|
this.httpClient = opts.httpClient;
|
|
2375
2631
|
}
|
|
2376
2632
|
async execute(params) {
|
|
@@ -2384,6 +2640,17 @@ var RpcClient = class {
|
|
|
2384
2640
|
}
|
|
2385
2641
|
return result;
|
|
2386
2642
|
}
|
|
2643
|
+
/**
|
|
2644
|
+
* The same call, in the shape the transport abstraction shares with the
|
|
2645
|
+
* relay.
|
|
2646
|
+
*
|
|
2647
|
+
* `rootHash` is left absent rather than synthesized: JSON-RPC `execute` does
|
|
2648
|
+
* not report the context's scope root, and inventing a value — or a `''` —
|
|
2649
|
+
* would make "the node does not tell us" indistinguishable from an answer.
|
|
2650
|
+
*/
|
|
2651
|
+
async executeWithMetadata(params) {
|
|
2652
|
+
return { returns: await this.execute(params), transport: "node" };
|
|
2653
|
+
}
|
|
2387
2654
|
/**
|
|
2388
2655
|
* One-tap owner-driven convert: re-signs the caller's identity-gated entries
|
|
2389
2656
|
* to the current schema. The export converts all of the caller's
|
|
@@ -2429,7 +2696,14 @@ var SseClient = class {
|
|
|
2429
2696
|
this.closed = false;
|
|
2430
2697
|
this.listeners = { connect: [], event: [], error: [] };
|
|
2431
2698
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
2432
|
-
|
|
2699
|
+
if (opts.authorize) {
|
|
2700
|
+
this.authorize = opts.authorize;
|
|
2701
|
+
} else if (opts.getAuthToken) {
|
|
2702
|
+
const getAuthToken = opts.getAuthToken;
|
|
2703
|
+
this.authorize = async () => ({ Authorization: `Bearer ${await getAuthToken()}` });
|
|
2704
|
+
} else {
|
|
2705
|
+
throw new Error("SseClient needs either `getAuthToken` or `authorize`");
|
|
2706
|
+
}
|
|
2433
2707
|
this.reconnectDelayMs = opts.reconnectDelayMs ?? 3e3;
|
|
2434
2708
|
this.fetchImpl = (url, init) => opts.fetch ? opts.fetch(url, init) : globalThis.fetch(url, init);
|
|
2435
2709
|
}
|
|
@@ -2496,11 +2770,10 @@ var SseClient = class {
|
|
|
2496
2770
|
this.closed = false;
|
|
2497
2771
|
this.abortController = new AbortController();
|
|
2498
2772
|
try {
|
|
2499
|
-
const token = await this.getAuthToken();
|
|
2500
2773
|
const url = `${this.baseUrl}/sse`;
|
|
2501
2774
|
const response = await this.fetchImpl(url, {
|
|
2502
2775
|
headers: {
|
|
2503
|
-
"
|
|
2776
|
+
...await this.authorize({ method: "GET", path: new URL(url).pathname }),
|
|
2504
2777
|
"Accept": "text/event-stream"
|
|
2505
2778
|
},
|
|
2506
2779
|
signal: this.abortController.signal
|
|
@@ -2653,21 +2926,18 @@ var SseClient = class {
|
|
|
2653
2926
|
}
|
|
2654
2927
|
async sendSubscription(method, ids) {
|
|
2655
2928
|
try {
|
|
2656
|
-
const token = await this.getAuthToken();
|
|
2657
2929
|
const params = {};
|
|
2658
2930
|
if (ids.contextIds && ids.contextIds.length > 0) params.contextIds = ids.contextIds;
|
|
2659
2931
|
if (ids.groupIds && ids.groupIds.length > 0) params.groupIds = ids.groupIds;
|
|
2660
|
-
const
|
|
2932
|
+
const url = `${this.baseUrl}/sse/subscription`;
|
|
2933
|
+
const body = JSON.stringify({ id: this.sessionId, method, params });
|
|
2934
|
+
const response = await this.fetchImpl(url, {
|
|
2661
2935
|
method: "POST",
|
|
2662
2936
|
headers: {
|
|
2663
|
-
"
|
|
2937
|
+
...await this.authorize({ method: "POST", path: new URL(url).pathname, body }),
|
|
2664
2938
|
"Content-Type": "application/json"
|
|
2665
2939
|
},
|
|
2666
|
-
body
|
|
2667
|
-
id: this.sessionId,
|
|
2668
|
-
method,
|
|
2669
|
-
params
|
|
2670
|
-
})
|
|
2940
|
+
body
|
|
2671
2941
|
});
|
|
2672
2942
|
if (!response.ok) {
|
|
2673
2943
|
const terminal = isTerminalAuthFailure(response);
|
|
@@ -2986,11 +3256,123 @@ var EphemeralClient = class {
|
|
|
2986
3256
|
}
|
|
2987
3257
|
};
|
|
2988
3258
|
|
|
3259
|
+
// src/signer/signer.ts
|
|
3260
|
+
var PKCS8_ED25519_PREFIX2 = new Uint8Array([
|
|
3261
|
+
48,
|
|
3262
|
+
46,
|
|
3263
|
+
2,
|
|
3264
|
+
1,
|
|
3265
|
+
0,
|
|
3266
|
+
48,
|
|
3267
|
+
5,
|
|
3268
|
+
6,
|
|
3269
|
+
3,
|
|
3270
|
+
43,
|
|
3271
|
+
101,
|
|
3272
|
+
112,
|
|
3273
|
+
4,
|
|
3274
|
+
34,
|
|
3275
|
+
4,
|
|
3276
|
+
32
|
|
3277
|
+
]);
|
|
3278
|
+
function unsupported(cause) {
|
|
3279
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : "";
|
|
3280
|
+
return new Error(
|
|
3281
|
+
"this runtime has no WebCrypto Ed25519, so nothing can be signed here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it" + detail
|
|
3282
|
+
);
|
|
3283
|
+
}
|
|
3284
|
+
async function signerFromSecret(secret, label2 = "secret") {
|
|
3285
|
+
const seed = fromHex(secret, label2, 32);
|
|
3286
|
+
const pkcs8 = concat(PKCS8_ED25519_PREFIX2, seed);
|
|
3287
|
+
let privateKey;
|
|
3288
|
+
let publicKey;
|
|
3289
|
+
try {
|
|
3290
|
+
const exportable = await crypto.subtle.importKey(
|
|
3291
|
+
"pkcs8",
|
|
3292
|
+
pkcs8,
|
|
3293
|
+
{ name: "Ed25519" },
|
|
3294
|
+
true,
|
|
3295
|
+
["sign"]
|
|
3296
|
+
);
|
|
3297
|
+
const jwk = await crypto.subtle.exportKey("jwk", exportable);
|
|
3298
|
+
publicKey = hex(
|
|
3299
|
+
Uint8Array.from(
|
|
3300
|
+
atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")),
|
|
3301
|
+
(c) => c.charCodeAt(0)
|
|
3302
|
+
)
|
|
3303
|
+
);
|
|
3304
|
+
privateKey = await crypto.subtle.importKey(
|
|
3305
|
+
"pkcs8",
|
|
3306
|
+
pkcs8,
|
|
3307
|
+
{ name: "Ed25519" },
|
|
3308
|
+
false,
|
|
3309
|
+
["sign"]
|
|
3310
|
+
);
|
|
3311
|
+
} catch (cause) {
|
|
3312
|
+
throw unsupported(cause);
|
|
3313
|
+
}
|
|
3314
|
+
return fromKeyAndPublic(privateKey, publicKey);
|
|
3315
|
+
}
|
|
3316
|
+
async function signerFromCryptoKey(privateKey, publicKey) {
|
|
3317
|
+
if (privateKey.type !== "private") {
|
|
3318
|
+
throw new Error(
|
|
3319
|
+
`signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`
|
|
3320
|
+
);
|
|
3321
|
+
}
|
|
3322
|
+
if (!privateKey.usages.includes("sign")) {
|
|
3323
|
+
throw new Error(
|
|
3324
|
+
"this CryptoKey cannot sign: generate it with usages ['sign', 'verify']"
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
let publicHex;
|
|
3328
|
+
if (typeof publicKey === "string") {
|
|
3329
|
+
fromHex(publicKey, "publicKey", 32);
|
|
3330
|
+
publicHex = publicKey.trim().toLowerCase();
|
|
3331
|
+
} else {
|
|
3332
|
+
try {
|
|
3333
|
+
publicHex = hex(
|
|
3334
|
+
new Uint8Array(await crypto.subtle.exportKey("raw", publicKey))
|
|
3335
|
+
);
|
|
3336
|
+
} catch (cause) {
|
|
3337
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : "";
|
|
3338
|
+
throw new Error(
|
|
3339
|
+
"could not read the public half of this key pair; pass the public key as 64 hex instead" + detail
|
|
3340
|
+
);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return fromKeyAndPublic(privateKey, publicHex);
|
|
3344
|
+
}
|
|
3345
|
+
function fromKeyAndPublic(privateKey, publicKey) {
|
|
3346
|
+
return {
|
|
3347
|
+
publicKey,
|
|
3348
|
+
async sign(payload) {
|
|
3349
|
+
try {
|
|
3350
|
+
return new Uint8Array(
|
|
3351
|
+
await crypto.subtle.sign({ name: "Ed25519" }, privateKey, payload)
|
|
3352
|
+
);
|
|
3353
|
+
} catch (cause) {
|
|
3354
|
+
throw unsupported(cause);
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
async function resolveSigner(secret, signer, label2) {
|
|
3360
|
+
if (signer && secret !== void 0) {
|
|
3361
|
+
throw new Error(
|
|
3362
|
+
`pass either ${label2} or signer, not both \u2014 they may name different keys`
|
|
3363
|
+
);
|
|
3364
|
+
}
|
|
3365
|
+
if (signer) return signer;
|
|
3366
|
+
if (secret !== void 0) return signerFromSecret(secret, label2);
|
|
3367
|
+
throw new Error(`${label2} or signer is required`);
|
|
3368
|
+
}
|
|
3369
|
+
|
|
2989
3370
|
// src/device-cert/device-cert.ts
|
|
2990
3371
|
var CERT_DOMAIN = new TextEncoder().encode("calimero.device.cert.v1");
|
|
2991
3372
|
var ACCOUNT_ID_DOMAIN = new TextEncoder().encode("calimero.account.genesis.v1");
|
|
2992
3373
|
var DEVICE_ID_DOMAIN = new TextEncoder().encode("calimero.device.id.v1");
|
|
2993
3374
|
var ACCOUNT_GENESIS_VERSION = 2;
|
|
3375
|
+
var ACCOUNT_PROOF_BYTES = 237;
|
|
2994
3376
|
async function mintDeviceId(account, nonce2) {
|
|
2995
3377
|
if (nonce2.length !== 16) {
|
|
2996
3378
|
throw new Error(`nonce must be 16 bytes, got ${nonce2.length}`);
|
|
@@ -3023,12 +3405,9 @@ function accountProofBytes(input) {
|
|
|
3023
3405
|
);
|
|
3024
3406
|
}
|
|
3025
3407
|
async function signDeviceCert(input) {
|
|
3026
|
-
const
|
|
3027
|
-
const
|
|
3028
|
-
|
|
3029
|
-
concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey)
|
|
3030
|
-
])
|
|
3031
|
-
);
|
|
3408
|
+
const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
|
|
3409
|
+
const rootPublicKey = fromHex(signer.publicKey, "signer.publicKey", 32);
|
|
3410
|
+
const account = await accountForRootPublicKey(signer.publicKey);
|
|
3032
3411
|
const keyEpoch = 0;
|
|
3033
3412
|
const payload = await deviceCertPayload({
|
|
3034
3413
|
account,
|
|
@@ -3038,10 +3417,7 @@ async function signDeviceCert(input) {
|
|
|
3038
3417
|
keyEpoch,
|
|
3039
3418
|
deviceEpoch: input.deviceEpoch
|
|
3040
3419
|
});
|
|
3041
|
-
const
|
|
3042
|
-
const signature = new Uint8Array(
|
|
3043
|
-
await crypto.subtle.sign("Ed25519", key, payload)
|
|
3044
|
-
);
|
|
3420
|
+
const signature = await signer.sign(payload);
|
|
3045
3421
|
const credential = accountProofBytes({
|
|
3046
3422
|
rootPublicKey,
|
|
3047
3423
|
account,
|
|
@@ -3054,11 +3430,67 @@ async function signDeviceCert(input) {
|
|
|
3054
3430
|
});
|
|
3055
3431
|
return hex(credential);
|
|
3056
3432
|
}
|
|
3433
|
+
function parseDeviceCredential(credential) {
|
|
3434
|
+
const bytes = fromHex(
|
|
3435
|
+
credential,
|
|
3436
|
+
"credential",
|
|
3437
|
+
// Fixed-width with an empty chain: 1 + 32 + 4 + 32*4 + 4 + 4 + 64.
|
|
3438
|
+
ACCOUNT_PROOF_BYTES
|
|
3439
|
+
);
|
|
3440
|
+
if (bytes[0] !== ACCOUNT_GENESIS_VERSION) {
|
|
3441
|
+
throw new Error(
|
|
3442
|
+
`this credential has account genesis version ${bytes[0]}, not ${ACCOUNT_GENESIS_VERSION} \u2014 it names a different account than its root key derives here, and core would refuse it`
|
|
3443
|
+
);
|
|
3444
|
+
}
|
|
3445
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3446
|
+
const chainLength = view.getUint32(33, true);
|
|
3447
|
+
if (chainLength !== 0) {
|
|
3448
|
+
throw new Error(
|
|
3449
|
+
`this credential carries a ${chainLength}-handoff root chain, which this SDK cannot verify yet \u2014 the signing root is not the genesis root, so checking it means walking the chain`
|
|
3450
|
+
);
|
|
3451
|
+
}
|
|
3452
|
+
const at = (offset, length) => hex(bytes.subarray(offset, offset + length));
|
|
3453
|
+
return {
|
|
3454
|
+
rootPublicKey: at(1, 32),
|
|
3455
|
+
account: at(37, 32),
|
|
3456
|
+
device: at(69, 32),
|
|
3457
|
+
signPublicKey: at(101, 32),
|
|
3458
|
+
kemPublicKey: at(133, 32),
|
|
3459
|
+
keyEpoch: view.getUint32(165, true),
|
|
3460
|
+
deviceEpoch: view.getUint32(169, true),
|
|
3461
|
+
signature: at(173, 64)
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
async function verifyDeviceCredential(credential) {
|
|
3465
|
+
const parsed = parseDeviceCredential(credential);
|
|
3466
|
+
const derived = await accountForRootPublicKey(parsed.rootPublicKey);
|
|
3467
|
+
if (derived !== parsed.account) {
|
|
3468
|
+
throw new Error(
|
|
3469
|
+
`this credential names account ${parsed.account}, but its root key derives ${derived} \u2014 the certificate was re-pointed at another account`
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
const signed = await verifySignature(
|
|
3473
|
+
parsed.rootPublicKey,
|
|
3474
|
+
fromHex(parsed.signature, "signature", 64),
|
|
3475
|
+
await deviceCertPayload(parsed)
|
|
3476
|
+
);
|
|
3477
|
+
if (!signed) {
|
|
3478
|
+
throw new Error(
|
|
3479
|
+
`the root key of account ${parsed.account} did not sign this certificate`
|
|
3480
|
+
);
|
|
3481
|
+
}
|
|
3482
|
+
return parsed;
|
|
3483
|
+
}
|
|
3057
3484
|
async function accountForRoot(rootSecret) {
|
|
3058
|
-
|
|
3485
|
+
return accountForRootPublicKey(hex(await derivePublicKey(rootSecret)));
|
|
3486
|
+
}
|
|
3487
|
+
async function accountForRootPublicKey(rootPublicKey) {
|
|
3059
3488
|
return hex(
|
|
3060
3489
|
await domainHash(ACCOUNT_ID_DOMAIN, [
|
|
3061
|
-
concat(
|
|
3490
|
+
concat(
|
|
3491
|
+
new Uint8Array([ACCOUNT_GENESIS_VERSION]),
|
|
3492
|
+
fromHex(rootPublicKey, "rootPublicKey", 32)
|
|
3493
|
+
)
|
|
3062
3494
|
])
|
|
3063
3495
|
);
|
|
3064
3496
|
}
|
|
@@ -5141,27 +5573,50 @@ async function accountRootFromSecret(secret) {
|
|
|
5141
5573
|
accountId: await accountForRoot(secret)
|
|
5142
5574
|
};
|
|
5143
5575
|
}
|
|
5576
|
+
async function createAccountRootSigner() {
|
|
5577
|
+
const entropy = crypto.getRandomValues(new Uint8Array(ENTROPY_BITS / 8));
|
|
5578
|
+
const phrase = await encodePhrase(entropy);
|
|
5579
|
+
return { ...await rootSignerFromEntropy(entropy), phrase };
|
|
5580
|
+
}
|
|
5581
|
+
async function accountRootSignerFromPhrase(phrase) {
|
|
5582
|
+
return rootSignerFromEntropy(await decodePhrase(normalisePhrase(phrase)));
|
|
5583
|
+
}
|
|
5584
|
+
async function rootSignerFromEntropy(entropy) {
|
|
5585
|
+
const signer = await signerFromSecret(hex(entropy), "rootSecret");
|
|
5586
|
+
entropy.fill(0);
|
|
5587
|
+
return {
|
|
5588
|
+
publicKey: signer.publicKey,
|
|
5589
|
+
accountId: await accountForRootPublicKey(signer.publicKey),
|
|
5590
|
+
signer
|
|
5591
|
+
};
|
|
5592
|
+
}
|
|
5593
|
+
async function resolveRoot(source) {
|
|
5594
|
+
const signer = typeof source === "string" ? await signerFromSecret(source, "rootSecret") : source;
|
|
5595
|
+
return identify(signer);
|
|
5596
|
+
}
|
|
5597
|
+
async function resolveRootPair(rootSecret, signer) {
|
|
5598
|
+
return identify(await resolveSigner(rootSecret, signer, "rootSecret"));
|
|
5599
|
+
}
|
|
5600
|
+
async function identify(signer) {
|
|
5601
|
+
return {
|
|
5602
|
+
signer,
|
|
5603
|
+
publicKey: signer.publicKey,
|
|
5604
|
+
accountId: await accountForRootPublicKey(signer.publicKey)
|
|
5605
|
+
};
|
|
5606
|
+
}
|
|
5144
5607
|
async function signAccountLink(input) {
|
|
5145
5608
|
if (!input.nonce) throw new Error("nonce is required to sign an account link");
|
|
5146
|
-
const
|
|
5147
|
-
const signature =
|
|
5148
|
-
|
|
5149
|
-
"Ed25519",
|
|
5150
|
-
key,
|
|
5151
|
-
concat(ACCOUNT_LINK_DOMAIN, new TextEncoder().encode(input.nonce))
|
|
5152
|
-
)
|
|
5609
|
+
const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
|
|
5610
|
+
const signature = await signer.sign(
|
|
5611
|
+
concat(ACCOUNT_LINK_DOMAIN, new TextEncoder().encode(input.nonce))
|
|
5153
5612
|
);
|
|
5154
5613
|
return base64(signature);
|
|
5155
5614
|
}
|
|
5156
5615
|
async function signAccountLogin(input) {
|
|
5157
5616
|
if (!input.nonce) throw new Error("nonce is required to sign an account login");
|
|
5158
|
-
const
|
|
5159
|
-
const signature =
|
|
5160
|
-
|
|
5161
|
-
"Ed25519",
|
|
5162
|
-
key,
|
|
5163
|
-
concat(ACCOUNT_LOGIN_DOMAIN, new TextEncoder().encode(input.nonce))
|
|
5164
|
-
)
|
|
5617
|
+
const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
|
|
5618
|
+
const signature = await signer.sign(
|
|
5619
|
+
concat(ACCOUNT_LOGIN_DOMAIN, new TextEncoder().encode(input.nonce))
|
|
5165
5620
|
);
|
|
5166
5621
|
return base64(signature);
|
|
5167
5622
|
}
|
|
@@ -5255,7 +5710,7 @@ function base642(bytes) {
|
|
|
5255
5710
|
}
|
|
5256
5711
|
|
|
5257
5712
|
// src/cloud/cloud-client.ts
|
|
5258
|
-
var DEFAULT_CLOUD_BASE_URL = "https://cloud.calimero.network";
|
|
5713
|
+
var DEFAULT_CLOUD_BASE_URL = "https://manager.cloud.calimero.network";
|
|
5259
5714
|
function canExecuteOf(row, legacy) {
|
|
5260
5715
|
return typeof row.can_execute === "boolean" ? row.can_execute : legacy;
|
|
5261
5716
|
}
|
|
@@ -5319,8 +5774,10 @@ var CloudClient = class {
|
|
|
5319
5774
|
* account behind those reads was claimed by its owner. The claim is recorded
|
|
5320
5775
|
* even when the session is refused.
|
|
5321
5776
|
*
|
|
5322
|
-
* @param
|
|
5323
|
-
*
|
|
5777
|
+
* @param root the account root: its 64-hex secret, or a `Signer` over a root
|
|
5778
|
+
* this process may use and cannot read — the form a phrase-held root in a
|
|
5779
|
+
* browser takes. Either way nothing but a signature over the cloud's nonce
|
|
5780
|
+
* travels.
|
|
5324
5781
|
*
|
|
5325
5782
|
* A `403` means either the signature did not verify, or the account is not
|
|
5326
5783
|
* linked to a cloud login: ownership alone says who you are, and the link
|
|
@@ -5328,13 +5785,13 @@ var CloudClient = class {
|
|
|
5328
5785
|
* on the proof alone would authenticate perfectly and authorize nothing. The
|
|
5329
5786
|
* ownership claim is recorded either way, so linking later needs no re-proof.
|
|
5330
5787
|
*/
|
|
5331
|
-
async signInWithAccount(
|
|
5332
|
-
const
|
|
5788
|
+
async signInWithAccount(root) {
|
|
5789
|
+
const { signer, publicKey } = await resolveRoot(root);
|
|
5333
5790
|
const { nonce: nonce2 } = await this.getAccountLoginChallenge();
|
|
5334
5791
|
return this.submitAccountLogin({
|
|
5335
|
-
rootPublicKey:
|
|
5792
|
+
rootPublicKey: publicKey,
|
|
5336
5793
|
nonce: nonce2,
|
|
5337
|
-
signature: await signAccountLogin({
|
|
5794
|
+
signature: await signAccountLogin({ signer, nonce: nonce2 })
|
|
5338
5795
|
});
|
|
5339
5796
|
}
|
|
5340
5797
|
/**
|
|
@@ -5431,6 +5888,7 @@ var CloudClient = class {
|
|
|
5431
5888
|
peerId: String(row.peer_id ?? ""),
|
|
5432
5889
|
relayUrl,
|
|
5433
5890
|
executorAccount,
|
|
5891
|
+
nodeKey: row.node_key ?? row.node_public_key ?? null,
|
|
5434
5892
|
status: String(row.status ?? ""),
|
|
5435
5893
|
authorshipReady,
|
|
5436
5894
|
teeRole: teeRoleOf(row),
|
|
@@ -5773,22 +6231,23 @@ var CloudClient = class {
|
|
|
5773
6231
|
* an intent; the cloud needs it to know whose plan a hosted node is spending,
|
|
5774
6232
|
* and to sign that account back in later.
|
|
5775
6233
|
*
|
|
5776
|
-
* @param
|
|
5777
|
-
* {@link generateAccountRoot},
|
|
5778
|
-
*
|
|
6234
|
+
* @param root the account root: its 64-hex secret — from
|
|
6235
|
+
* {@link generateAccountRoot}, a restored recovery phrase, or a desktop app
|
|
6236
|
+
* or hardware key that already holds one — or a `Signer` over a root this
|
|
6237
|
+
* process cannot read, which is what a phrase-held browser root is.
|
|
5779
6238
|
*
|
|
5780
6239
|
* Idempotent for the same login. An account already linked to a *different*
|
|
5781
6240
|
* cloud login is a `409`; a replayed or expired challenge is a `403`; and
|
|
5782
6241
|
* exceeding the plan's account limit is a `402`.
|
|
5783
6242
|
*/
|
|
5784
|
-
async linkAccount(
|
|
5785
|
-
const
|
|
6243
|
+
async linkAccount(root) {
|
|
6244
|
+
const { signer, publicKey, accountId } = await resolveRoot(root);
|
|
5786
6245
|
const { nonce: nonce2 } = await this.getAccountLinkChallenge();
|
|
5787
6246
|
return this.submitAccountLink({
|
|
5788
|
-
accountId
|
|
5789
|
-
rootPublicKey:
|
|
6247
|
+
accountId,
|
|
6248
|
+
rootPublicKey: publicKey,
|
|
5790
6249
|
nonce: nonce2,
|
|
5791
|
-
signature: await signAccountLink({
|
|
6250
|
+
signature: await signAccountLink({ signer, nonce: nonce2 })
|
|
5792
6251
|
});
|
|
5793
6252
|
}
|
|
5794
6253
|
/**
|
|
@@ -5908,16 +6367,19 @@ var CloudClient = class {
|
|
|
5908
6367
|
* login; `402` that the plan is full.
|
|
5909
6368
|
*/
|
|
5910
6369
|
async linkAccountWithGrant(options) {
|
|
5911
|
-
const
|
|
6370
|
+
const { signer, publicKey, accountId } = await resolveRootPair(
|
|
6371
|
+
options.rootSecret,
|
|
6372
|
+
options.signer
|
|
6373
|
+
);
|
|
5912
6374
|
const body = await this.request(
|
|
5913
6375
|
"POST",
|
|
5914
6376
|
"/api/cloud/accounts/link",
|
|
5915
6377
|
{
|
|
5916
6378
|
body: {
|
|
5917
6379
|
grant: options.grant,
|
|
5918
|
-
root_public_key:
|
|
6380
|
+
root_public_key: publicKey,
|
|
5919
6381
|
signature: await signAccountLink({
|
|
5920
|
-
|
|
6382
|
+
signer,
|
|
5921
6383
|
nonce: options.grant
|
|
5922
6384
|
})
|
|
5923
6385
|
},
|
|
@@ -5925,7 +6387,7 @@ var CloudClient = class {
|
|
|
5925
6387
|
}
|
|
5926
6388
|
);
|
|
5927
6389
|
return {
|
|
5928
|
-
accountId: String(body.account_id ??
|
|
6390
|
+
accountId: String(body.account_id ?? accountId),
|
|
5929
6391
|
linkedAt: body.linked_at ?? null,
|
|
5930
6392
|
alreadyLinked: body.already_linked === true
|
|
5931
6393
|
};
|
|
@@ -6449,117 +6911,6 @@ var LocalStorageTokenStore = class {
|
|
|
6449
6911
|
}
|
|
6450
6912
|
};
|
|
6451
6913
|
|
|
6452
|
-
// src/signer/signer.ts
|
|
6453
|
-
var PKCS8_ED25519_PREFIX2 = new Uint8Array([
|
|
6454
|
-
48,
|
|
6455
|
-
46,
|
|
6456
|
-
2,
|
|
6457
|
-
1,
|
|
6458
|
-
0,
|
|
6459
|
-
48,
|
|
6460
|
-
5,
|
|
6461
|
-
6,
|
|
6462
|
-
3,
|
|
6463
|
-
43,
|
|
6464
|
-
101,
|
|
6465
|
-
112,
|
|
6466
|
-
4,
|
|
6467
|
-
34,
|
|
6468
|
-
4,
|
|
6469
|
-
32
|
|
6470
|
-
]);
|
|
6471
|
-
function unsupported(cause) {
|
|
6472
|
-
const detail = cause instanceof Error ? `: ${cause.message}` : "";
|
|
6473
|
-
return new Error(
|
|
6474
|
-
"this runtime has no WebCrypto Ed25519, so nothing can be signed here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it" + detail
|
|
6475
|
-
);
|
|
6476
|
-
}
|
|
6477
|
-
async function signerFromSecret(secret, label2 = "secret") {
|
|
6478
|
-
const seed = fromHex(secret, label2, 32);
|
|
6479
|
-
const pkcs8 = concat(PKCS8_ED25519_PREFIX2, seed);
|
|
6480
|
-
let privateKey;
|
|
6481
|
-
let publicKey;
|
|
6482
|
-
try {
|
|
6483
|
-
const exportable = await crypto.subtle.importKey(
|
|
6484
|
-
"pkcs8",
|
|
6485
|
-
pkcs8,
|
|
6486
|
-
{ name: "Ed25519" },
|
|
6487
|
-
true,
|
|
6488
|
-
["sign"]
|
|
6489
|
-
);
|
|
6490
|
-
const jwk = await crypto.subtle.exportKey("jwk", exportable);
|
|
6491
|
-
publicKey = hex(
|
|
6492
|
-
Uint8Array.from(
|
|
6493
|
-
atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")),
|
|
6494
|
-
(c) => c.charCodeAt(0)
|
|
6495
|
-
)
|
|
6496
|
-
);
|
|
6497
|
-
privateKey = await crypto.subtle.importKey(
|
|
6498
|
-
"pkcs8",
|
|
6499
|
-
pkcs8,
|
|
6500
|
-
{ name: "Ed25519" },
|
|
6501
|
-
false,
|
|
6502
|
-
["sign"]
|
|
6503
|
-
);
|
|
6504
|
-
} catch (cause) {
|
|
6505
|
-
throw unsupported(cause);
|
|
6506
|
-
}
|
|
6507
|
-
return fromKeyAndPublic(privateKey, publicKey);
|
|
6508
|
-
}
|
|
6509
|
-
async function signerFromCryptoKey(privateKey, publicKey) {
|
|
6510
|
-
if (privateKey.type !== "private") {
|
|
6511
|
-
throw new Error(
|
|
6512
|
-
`signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`
|
|
6513
|
-
);
|
|
6514
|
-
}
|
|
6515
|
-
if (!privateKey.usages.includes("sign")) {
|
|
6516
|
-
throw new Error(
|
|
6517
|
-
"this CryptoKey cannot sign: generate it with usages ['sign', 'verify']"
|
|
6518
|
-
);
|
|
6519
|
-
}
|
|
6520
|
-
let publicHex;
|
|
6521
|
-
if (typeof publicKey === "string") {
|
|
6522
|
-
fromHex(publicKey, "publicKey", 32);
|
|
6523
|
-
publicHex = publicKey.trim().toLowerCase();
|
|
6524
|
-
} else {
|
|
6525
|
-
try {
|
|
6526
|
-
publicHex = hex(
|
|
6527
|
-
new Uint8Array(await crypto.subtle.exportKey("raw", publicKey))
|
|
6528
|
-
);
|
|
6529
|
-
} catch (cause) {
|
|
6530
|
-
const detail = cause instanceof Error ? `: ${cause.message}` : "";
|
|
6531
|
-
throw new Error(
|
|
6532
|
-
"could not read the public half of this key pair; pass the public key as 64 hex instead" + detail
|
|
6533
|
-
);
|
|
6534
|
-
}
|
|
6535
|
-
}
|
|
6536
|
-
return fromKeyAndPublic(privateKey, publicHex);
|
|
6537
|
-
}
|
|
6538
|
-
function fromKeyAndPublic(privateKey, publicKey) {
|
|
6539
|
-
return {
|
|
6540
|
-
publicKey,
|
|
6541
|
-
async sign(payload) {
|
|
6542
|
-
try {
|
|
6543
|
-
return new Uint8Array(
|
|
6544
|
-
await crypto.subtle.sign({ name: "Ed25519" }, privateKey, payload)
|
|
6545
|
-
);
|
|
6546
|
-
} catch (cause) {
|
|
6547
|
-
throw unsupported(cause);
|
|
6548
|
-
}
|
|
6549
|
-
}
|
|
6550
|
-
};
|
|
6551
|
-
}
|
|
6552
|
-
async function resolveSigner(secret, signer, label2) {
|
|
6553
|
-
if (signer && secret !== void 0) {
|
|
6554
|
-
throw new Error(
|
|
6555
|
-
`pass either ${label2} or signer, not both \u2014 they may name different keys`
|
|
6556
|
-
);
|
|
6557
|
-
}
|
|
6558
|
-
if (signer) return signer;
|
|
6559
|
-
if (secret !== void 0) return signerFromSecret(secret, label2);
|
|
6560
|
-
throw new Error(`${label2} or signer is required`);
|
|
6561
|
-
}
|
|
6562
|
-
|
|
6563
6914
|
// src/warrant/warrant.ts
|
|
6564
6915
|
var SIGN_DOMAIN2 = new TextEncoder().encode("calimero.warrant.v2");
|
|
6565
6916
|
var INTENT_DOMAIN = new TextEncoder().encode("calimero.warrant.intent.v1");
|
|
@@ -6921,6 +7272,138 @@ function kindByte(kind) {
|
|
|
6921
7272
|
return byte;
|
|
6922
7273
|
}
|
|
6923
7274
|
|
|
7275
|
+
// src/warrant/governance-op.ts
|
|
7276
|
+
var ROLE = {
|
|
7277
|
+
Admin: 0,
|
|
7278
|
+
Member: 1,
|
|
7279
|
+
ReadOnly: 2
|
|
7280
|
+
};
|
|
7281
|
+
var GROUP_OP = {
|
|
7282
|
+
MemberAdded: 1,
|
|
7283
|
+
MemberRemoved: 2,
|
|
7284
|
+
MemberLeft: 3,
|
|
7285
|
+
MemberRoleSet: 4,
|
|
7286
|
+
DefaultCapabilitiesSet: 6
|
|
7287
|
+
};
|
|
7288
|
+
var ROOT_OP = {
|
|
7289
|
+
GroupCreated: 0,
|
|
7290
|
+
GroupReparented: 1,
|
|
7291
|
+
GroupDeleted: 2,
|
|
7292
|
+
NamespaceCreatedV2: 9
|
|
7293
|
+
};
|
|
7294
|
+
var NAMESPACE_ID_DOMAIN = new TextEncoder().encode("calimero.namespace.id.v1");
|
|
7295
|
+
var CLEARED_HASH = new Uint8Array(32);
|
|
7296
|
+
var EMPTY_VEC = u32le(0);
|
|
7297
|
+
function role(value) {
|
|
7298
|
+
const byte = ROLE[value];
|
|
7299
|
+
if (byte === void 0) {
|
|
7300
|
+
throw new Error(`role must be Admin, Member or ReadOnly, got ${String(value)}`);
|
|
7301
|
+
}
|
|
7302
|
+
return new Uint8Array([byte]);
|
|
7303
|
+
}
|
|
7304
|
+
function tag(byte) {
|
|
7305
|
+
return new Uint8Array([byte]);
|
|
7306
|
+
}
|
|
7307
|
+
function memberAddedOp(member, memberRole) {
|
|
7308
|
+
return {
|
|
7309
|
+
kind: "group",
|
|
7310
|
+
bytes: concat(tag(GROUP_OP.MemberAdded), fromHex(member, "member", 32), role(memberRole))
|
|
7311
|
+
};
|
|
7312
|
+
}
|
|
7313
|
+
function memberRemovedOp(member) {
|
|
7314
|
+
return {
|
|
7315
|
+
kind: "group",
|
|
7316
|
+
bytes: concat(
|
|
7317
|
+
tag(GROUP_OP.MemberRemoved),
|
|
7318
|
+
fromHex(member, "member", 32),
|
|
7319
|
+
CLEARED_HASH,
|
|
7320
|
+
EMPTY_VEC
|
|
7321
|
+
)
|
|
7322
|
+
};
|
|
7323
|
+
}
|
|
7324
|
+
function memberLeftOp(member) {
|
|
7325
|
+
return {
|
|
7326
|
+
kind: "group",
|
|
7327
|
+
bytes: concat(
|
|
7328
|
+
tag(GROUP_OP.MemberLeft),
|
|
7329
|
+
fromHex(member, "member", 32),
|
|
7330
|
+
CLEARED_HASH,
|
|
7331
|
+
EMPTY_VEC
|
|
7332
|
+
)
|
|
7333
|
+
};
|
|
7334
|
+
}
|
|
7335
|
+
function memberRoleSetOp(member, memberRole) {
|
|
7336
|
+
return {
|
|
7337
|
+
kind: "group",
|
|
7338
|
+
bytes: concat(tag(GROUP_OP.MemberRoleSet), fromHex(member, "member", 32), role(memberRole))
|
|
7339
|
+
};
|
|
7340
|
+
}
|
|
7341
|
+
function defaultCapabilitiesSetOp(capabilities) {
|
|
7342
|
+
if (!Number.isInteger(capabilities) || capabilities < 0 || capabilities > 4294967295) {
|
|
7343
|
+
throw new Error(`capabilities must be a u32 bit mask, got ${String(capabilities)}`);
|
|
7344
|
+
}
|
|
7345
|
+
if (hasCap(capabilities, CAPABILITIES.CAN_AUTHOR_ON_BEHALF)) {
|
|
7346
|
+
throw new Error(
|
|
7347
|
+
"capabilities may not include CAN_AUTHOR_ON_BEHALF (512): a relay never carries a change to it"
|
|
7348
|
+
);
|
|
7349
|
+
}
|
|
7350
|
+
return {
|
|
7351
|
+
kind: "group",
|
|
7352
|
+
bytes: concat(tag(GROUP_OP.DefaultCapabilitiesSet), u32le(capabilities))
|
|
7353
|
+
};
|
|
7354
|
+
}
|
|
7355
|
+
function groupCreatedOp(input) {
|
|
7356
|
+
return {
|
|
7357
|
+
kind: "root",
|
|
7358
|
+
bytes: concat(
|
|
7359
|
+
tag(ROOT_OP.GroupCreated),
|
|
7360
|
+
fromHex(input.groupId, "groupId", 32),
|
|
7361
|
+
fromHex(input.parentId, "parentId", 32),
|
|
7362
|
+
tag(input.restricted ? 1 : 0),
|
|
7363
|
+
fromHex(input.admin, "admin", 32)
|
|
7364
|
+
)
|
|
7365
|
+
};
|
|
7366
|
+
}
|
|
7367
|
+
function groupReparentedOp(childGroupId, newParentId) {
|
|
7368
|
+
return {
|
|
7369
|
+
kind: "root",
|
|
7370
|
+
bytes: concat(
|
|
7371
|
+
tag(ROOT_OP.GroupReparented),
|
|
7372
|
+
fromHex(childGroupId, "childGroupId", 32),
|
|
7373
|
+
fromHex(newParentId, "newParentId", 32)
|
|
7374
|
+
)
|
|
7375
|
+
};
|
|
7376
|
+
}
|
|
7377
|
+
function groupDeletedOp(rootGroupId) {
|
|
7378
|
+
return {
|
|
7379
|
+
kind: "root",
|
|
7380
|
+
bytes: concat(tag(ROOT_OP.GroupDeleted), fromHex(rootGroupId, "rootGroupId", 32), EMPTY_VEC, EMPTY_VEC)
|
|
7381
|
+
};
|
|
7382
|
+
}
|
|
7383
|
+
async function foundedNamespaceId(founder, salt) {
|
|
7384
|
+
return hex(
|
|
7385
|
+
await domainHash(NAMESPACE_ID_DOMAIN, [
|
|
7386
|
+
fromHex(founder, "founder", 32),
|
|
7387
|
+
fromHex(salt, "salt", 32)
|
|
7388
|
+
])
|
|
7389
|
+
);
|
|
7390
|
+
}
|
|
7391
|
+
function namespaceCreatedOp(input) {
|
|
7392
|
+
const credential = fromHexUnsized(input.credential, "credential");
|
|
7393
|
+
if (credential.length === 0) {
|
|
7394
|
+
throw new Error("credential must be the founder's AccountProof<DeviceCert>, got nothing");
|
|
7395
|
+
}
|
|
7396
|
+
return {
|
|
7397
|
+
kind: "root",
|
|
7398
|
+
bytes: concat(
|
|
7399
|
+
tag(ROOT_OP.NamespaceCreatedV2),
|
|
7400
|
+
fromHex(input.founder, "founder", 32),
|
|
7401
|
+
credential,
|
|
7402
|
+
fromHex(input.salt, "salt", 32)
|
|
7403
|
+
)
|
|
7404
|
+
};
|
|
7405
|
+
}
|
|
7406
|
+
|
|
6924
7407
|
// src/relay/relay-client.ts
|
|
6925
7408
|
var DEFAULT_TTL_SECONDS = 300;
|
|
6926
7409
|
var IntentRefusedError = class extends Error {
|
|
@@ -6932,6 +7415,16 @@ var IntentRefusedError = class extends Error {
|
|
|
6932
7415
|
this.name = "IntentRefusedError";
|
|
6933
7416
|
}
|
|
6934
7417
|
};
|
|
7418
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7419
|
+
function retryAfterMs(headers) {
|
|
7420
|
+
const raw = headers?.get?.("Retry-After");
|
|
7421
|
+
if (!raw) return null;
|
|
7422
|
+
const seconds = Number.parseInt(raw, 10);
|
|
7423
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
7424
|
+
const at = Date.parse(raw);
|
|
7425
|
+
if (Number.isFinite(at)) return Math.max(0, at - Date.now());
|
|
7426
|
+
return null;
|
|
7427
|
+
}
|
|
6935
7428
|
var RelayClient = class {
|
|
6936
7429
|
constructor(config) {
|
|
6937
7430
|
this.config = config;
|
|
@@ -6940,11 +7433,40 @@ var RelayClient = class {
|
|
|
6940
7433
|
const injected = config.fetch;
|
|
6941
7434
|
this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
|
|
6942
7435
|
this.timeoutMs = config.timeoutMs ?? 1e4;
|
|
7436
|
+
this.rateLimitRetries = config.rateLimitRetries ?? 3;
|
|
7437
|
+
}
|
|
7438
|
+
/**
|
|
7439
|
+
* The relay's origin, trailing slash stripped.
|
|
7440
|
+
*
|
|
7441
|
+
* Exposed because a relay **is a node**: the same origin that answers the
|
|
7442
|
+
* intents routes also answers `/auth/challenge`, `/auth/token`, `/sse` and
|
|
7443
|
+
* `/ws`. Anything building an observing session over this relay needs the
|
|
7444
|
+
* URL, and asking the caller to pass it a second time is how the two drift.
|
|
7445
|
+
*/
|
|
7446
|
+
get relayUrl() {
|
|
7447
|
+
return this.baseUrl;
|
|
7448
|
+
}
|
|
7449
|
+
/** The author's account, hex — whose consent every warrant here carries. */
|
|
7450
|
+
get authorAccount() {
|
|
7451
|
+
return this.config.authorAccount;
|
|
7452
|
+
}
|
|
7453
|
+
/**
|
|
7454
|
+
* The author's `AccountProof<DeviceCert>`, hex.
|
|
7455
|
+
*
|
|
7456
|
+
* The same credential `login()` wants, which is why it is readable: a caller
|
|
7457
|
+
* that already handed it to this client should not have to hand an identical
|
|
7458
|
+
* copy to the login as well, where the two could disagree.
|
|
7459
|
+
*/
|
|
7460
|
+
get authorProof() {
|
|
7461
|
+
return this.config.authorProof;
|
|
6943
7462
|
}
|
|
6944
7463
|
/**
|
|
6945
|
-
* The author device's signer
|
|
6946
|
-
*
|
|
6947
|
-
*
|
|
7464
|
+
* The author device's signer.
|
|
7465
|
+
*
|
|
7466
|
+
* A {@link Signer} and never the secret: this returns something that can
|
|
7467
|
+
* *sign*, so no caller of a `RelayClient` gains the ability to read a key
|
|
7468
|
+
* that was passed in as `deviceSecret`. That asymmetry is the point — the
|
|
7469
|
+
* login needs a signature, not key material.
|
|
6948
7470
|
*/
|
|
6949
7471
|
async authorSigner() {
|
|
6950
7472
|
return resolveSigner(this.config.deviceSecret, this.config.signer, "deviceSecret");
|
|
@@ -7121,12 +7643,93 @@ var RelayClient = class {
|
|
|
7121
7643
|
notAfter: BigInt(Math.floor(Date.now() / 1e3)) + BigInt(ttl),
|
|
7122
7644
|
signer
|
|
7123
7645
|
});
|
|
7124
|
-
const
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7646
|
+
const data = await this.postGovernance(input.groupId, input.op, warrant);
|
|
7647
|
+
return { groupId: data.groupId };
|
|
7648
|
+
}
|
|
7649
|
+
/**
|
|
7650
|
+
* Found a namespace through the relay, with the author as its founder, owner
|
|
7651
|
+
* and admin. For an account with no node, this is how it gets a namespace at
|
|
7652
|
+
* all.
|
|
7653
|
+
*
|
|
7654
|
+
* The author signs the exact genesis (`NamespaceCreatedV2`, carrying the
|
|
7655
|
+
* author's own credential and the salt) under a root-plane governance
|
|
7656
|
+
* warrant scoped to the new namespace's id, which is derived from the
|
|
7657
|
+
* author's account and the salt, so the relay can neither choose another id
|
|
7658
|
+
* nor found it for anyone else. The relay takes an identity in the new
|
|
7659
|
+
* namespace, mints its key, publishes the genesis, and is seated as the
|
|
7660
|
+
* founding relay (a `Member` holding `CAN_AUTHOR_ON_BEHALF`), so later
|
|
7661
|
+
* `govern`, `createContext` and `execute` calls in the namespace work
|
|
7662
|
+
* through it straight away. A TEE relay then admits itself as the
|
|
7663
|
+
* namespace's first TEE; `teeEnabled` reports whether it did.
|
|
7664
|
+
*
|
|
7665
|
+
* The executor cannot be learned from the relay first: the namespace does not
|
|
7666
|
+
* exist, so `describeGovernance` has nothing to answer about. It comes from
|
|
7667
|
+
* `input.executorAccount`, the configured one, or one an earlier call learned
|
|
7668
|
+
* (`describe`, `createContext`, `govern`), and without any of them this
|
|
7669
|
+
* throws before taking a nonce.
|
|
7670
|
+
* The nonce is spent in the new namespace's ledger.
|
|
7671
|
+
*
|
|
7672
|
+
* With `defaultCapabilities`, the founder then sets the namespace's default
|
|
7673
|
+
* capability mask through the same relay (a second warrant, a second nonce)
|
|
7674
|
+
* and `defaultCapabilitiesSet` reports whether it landed. A failure there
|
|
7675
|
+
* does not throw, since the namespace is already founded.
|
|
7676
|
+
*
|
|
7677
|
+
* Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
|
|
7678
|
+
* that names an existing namespace is a `409`, an `HTTPError`.
|
|
7679
|
+
*/
|
|
7680
|
+
async foundNamespace(input = {}) {
|
|
7681
|
+
const executor = input.executorAccount ? this.checkedExecutor(input.executorAccount) : this.executorAccount;
|
|
7682
|
+
if (!executor) {
|
|
7683
|
+
throw new Error(
|
|
7684
|
+
"foundNamespace needs the relay's account: pass executorAccount or configure it (checked before signing, no nonce was spent)"
|
|
7685
|
+
);
|
|
7686
|
+
}
|
|
7687
|
+
const salt = input.salt ? hex(fromHex(input.salt, "salt", 32)) : hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
7688
|
+
const namespaceId = await foundedNamespaceId(this.config.authorAccount, salt);
|
|
7689
|
+
const op = namespaceCreatedOp({
|
|
7690
|
+
founder: this.config.authorAccount,
|
|
7691
|
+
credential: this.config.authorProof,
|
|
7692
|
+
salt
|
|
7693
|
+
});
|
|
7694
|
+
const capabilitiesOp = input.defaultCapabilities === void 0 ? void 0 : defaultCapabilitiesSetOp(input.defaultCapabilities);
|
|
7695
|
+
const signer = await this.authorSigner();
|
|
7696
|
+
const nonce2 = await this.config.nonces.next();
|
|
7697
|
+
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
7698
|
+
const warrant = await signGovernanceWarrant({
|
|
7699
|
+
scope: namespaceId,
|
|
7700
|
+
op,
|
|
7701
|
+
authorAccount: this.config.authorAccount,
|
|
7702
|
+
executor,
|
|
7703
|
+
nonce: nonce2,
|
|
7704
|
+
notAfter: BigInt(Math.floor(Date.now() / 1e3)) + BigInt(ttl),
|
|
7705
|
+
signer
|
|
7706
|
+
});
|
|
7707
|
+
const data = await this.postGovernance(namespaceId, op, warrant);
|
|
7708
|
+
const founded = {
|
|
7709
|
+
namespaceId: data.groupId,
|
|
7710
|
+
salt,
|
|
7711
|
+
teeEnabled: data.teeEnabled ?? false,
|
|
7712
|
+
...data.teeError ? { teeError: data.teeError } : {}
|
|
7713
|
+
};
|
|
7714
|
+
if (!capabilitiesOp) return founded;
|
|
7715
|
+
try {
|
|
7716
|
+
await this.govern({ groupId: founded.namespaceId, op: capabilitiesOp });
|
|
7717
|
+
return { ...founded, defaultCapabilitiesSet: true };
|
|
7718
|
+
} catch (err) {
|
|
7719
|
+
return {
|
|
7720
|
+
...founded,
|
|
7721
|
+
defaultCapabilitiesSet: false,
|
|
7722
|
+
defaultCapabilitiesError: err instanceof Error ? err.message : String(err)
|
|
7723
|
+
};
|
|
7724
|
+
}
|
|
7725
|
+
}
|
|
7726
|
+
async postGovernance(groupId, op, warrant) {
|
|
7727
|
+
const body = await this.json("POST", `/admin-api/groups/${encodeURIComponent(groupId)}/governance-intents`, {
|
|
7728
|
+
warrant,
|
|
7729
|
+
authorProof: this.config.authorProof,
|
|
7730
|
+
op: hex(op.bytes)
|
|
7731
|
+
});
|
|
7732
|
+
return body.data;
|
|
7130
7733
|
}
|
|
7131
7734
|
/**
|
|
7132
7735
|
* The executor a warrant must name: the one the relay answered with, which a
|
|
@@ -7142,10 +7745,40 @@ var RelayClient = class {
|
|
|
7142
7745
|
this.executorAccount = answered;
|
|
7143
7746
|
return answered;
|
|
7144
7747
|
}
|
|
7748
|
+
/**
|
|
7749
|
+
* One request, waiting out a rate limit rather than failing the caller.
|
|
7750
|
+
*
|
|
7751
|
+
* The relay's ingress limits the delegated-execution routes to 20 requests a
|
|
7752
|
+
* second per source IP, burst 40 — tighter than everything else it serves,
|
|
7753
|
+
* because those two routes are the ones it answers to anonymous callers.
|
|
7754
|
+
*
|
|
7755
|
+
* Retrying a 429 is safe here in a way retrying most refusals is not: the
|
|
7756
|
+
* limiter sits *first* in the middleware chain, so a throttled request never
|
|
7757
|
+
* reached the node and its warrant nonce was never spent. The same bytes can
|
|
7758
|
+
* go again. Contrast the spent-nonce 403 below, which needs a fresh warrant.
|
|
7759
|
+
*
|
|
7760
|
+
* `Retry-After` is honoured when the ingress sends one; otherwise the wait
|
|
7761
|
+
* doubles from a little over the limiter's own window, which is the shortest
|
|
7762
|
+
* delay that can actually clear.
|
|
7763
|
+
*/
|
|
7145
7764
|
async json(method, path, body) {
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7765
|
+
let wait = 250;
|
|
7766
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
7767
|
+
try {
|
|
7768
|
+
return await this.jsonOnce(method, path, body);
|
|
7769
|
+
} catch (err) {
|
|
7770
|
+
const limited = err instanceof HTTPError && err.status === 429;
|
|
7771
|
+
if (!limited || attempt >= this.rateLimitRetries) throw err;
|
|
7772
|
+
const after = retryAfterMs(err.headers);
|
|
7773
|
+
await sleep(after ?? wait);
|
|
7774
|
+
wait *= 2;
|
|
7775
|
+
}
|
|
7776
|
+
}
|
|
7777
|
+
}
|
|
7778
|
+
async jsonOnce(method, path, body) {
|
|
7779
|
+
const headers = { Accept: "application/json" };
|
|
7780
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
7781
|
+
const controller = new AbortController();
|
|
7149
7782
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
7150
7783
|
let response;
|
|
7151
7784
|
try {
|
|
@@ -7180,19 +7813,681 @@ var RelayClient = class {
|
|
|
7180
7813
|
text
|
|
7181
7814
|
);
|
|
7182
7815
|
}
|
|
7183
|
-
return await response.json();
|
|
7816
|
+
return await response.json();
|
|
7817
|
+
}
|
|
7818
|
+
};
|
|
7819
|
+
function extractReason(bodyText) {
|
|
7820
|
+
if (!bodyText) return "no reason given";
|
|
7821
|
+
try {
|
|
7822
|
+
const parsed = JSON.parse(bodyText);
|
|
7823
|
+
for (const candidate of [parsed.error, parsed.message]) {
|
|
7824
|
+
if (typeof candidate === "string" && candidate) return candidate;
|
|
7825
|
+
}
|
|
7826
|
+
} catch {
|
|
7827
|
+
}
|
|
7828
|
+
return bodyText;
|
|
7829
|
+
}
|
|
7830
|
+
|
|
7831
|
+
// src/transport/relay-transport.ts
|
|
7832
|
+
var RelayTransport = class {
|
|
7833
|
+
constructor(relay, observer = null) {
|
|
7834
|
+
this.relay = relay;
|
|
7835
|
+
this.observer = observer;
|
|
7836
|
+
this.kind = "relay";
|
|
7837
|
+
}
|
|
7838
|
+
/**
|
|
7839
|
+
* Whether this relay can be observed — a fact, not a constant.
|
|
7840
|
+
*
|
|
7841
|
+
* A relay IS a node: it serves `/auth/challenge`, `/auth/token`, `/sse` and
|
|
7842
|
+
* `/ws`, and the `account_proof` provider grants `context:subscribe` by
|
|
7843
|
+
* default. What decides the answer is therefore not the transport but whether
|
|
7844
|
+
* a caller supplied the node's device signing key, which cannot be inferred
|
|
7845
|
+
* from the node or from its `peerId`. See `./relay-observer.ts`.
|
|
7846
|
+
*/
|
|
7847
|
+
get canSubscribe() {
|
|
7848
|
+
return this.observer !== null;
|
|
7849
|
+
}
|
|
7850
|
+
/** The event session, or `null` when no node key was supplied. */
|
|
7851
|
+
get events() {
|
|
7852
|
+
return this.observer;
|
|
7853
|
+
}
|
|
7854
|
+
/** The underlying client, for `describe()` and anything else relay-specific. */
|
|
7855
|
+
get client() {
|
|
7856
|
+
return this.relay;
|
|
7857
|
+
}
|
|
7858
|
+
async execute(params) {
|
|
7859
|
+
const { returns } = await this.executeWithMetadata(params);
|
|
7860
|
+
return returns;
|
|
7861
|
+
}
|
|
7862
|
+
async executeWithMetadata(params) {
|
|
7863
|
+
const result = await this.relay.execute(
|
|
7864
|
+
params.contextId,
|
|
7865
|
+
params.method,
|
|
7866
|
+
// The node defaults a missing `argsJson` to `{}`; matching that here keeps
|
|
7867
|
+
// a zero-argument call identical on both transports rather than sending
|
|
7868
|
+
// `undefined` and letting the relay's JSON body decide.
|
|
7869
|
+
params.argsJson ?? {}
|
|
7870
|
+
);
|
|
7871
|
+
return {
|
|
7872
|
+
// Verbatim. `RelayClient` has already normalized a missing `returns` to
|
|
7873
|
+
// `null`, which is what a node reports for a null output — see the
|
|
7874
|
+
// module note on why this is not rewritten to `undefined`.
|
|
7875
|
+
returns: result.returns,
|
|
7876
|
+
transport: "relay",
|
|
7877
|
+
rootHash: result.rootHash
|
|
7878
|
+
};
|
|
7879
|
+
}
|
|
7880
|
+
/**
|
|
7881
|
+
* Both of these are plain context methods, so they work through a relay
|
|
7882
|
+
* exactly as they do on a node — the warrant names `migrate_my_entries` and
|
|
7883
|
+
* the delta is attributed to the author, which is precisely the identity the
|
|
7884
|
+
* convert is scoped to. They are re-declared rather than inherited because
|
|
7885
|
+
* `ExecuteTransport` is an interface, not a base class.
|
|
7886
|
+
*/
|
|
7887
|
+
async migrateMyEntries(contextId) {
|
|
7888
|
+
return this.execute({
|
|
7889
|
+
contextId,
|
|
7890
|
+
method: "migrate_my_entries"
|
|
7891
|
+
});
|
|
7892
|
+
}
|
|
7893
|
+
/** Read-only count of the caller's entries still below the target schema. */
|
|
7894
|
+
async countMyPending(contextId) {
|
|
7895
|
+
return this.execute({ contextId, method: "count_my_pending" });
|
|
7896
|
+
}
|
|
7897
|
+
};
|
|
7898
|
+
|
|
7899
|
+
// src/login/login.ts
|
|
7900
|
+
var SIGN_DOMAIN5 = new TextEncoder().encode("calimero.auth.login.v1");
|
|
7901
|
+
var AUDIENCE_TAG = {
|
|
7902
|
+
webOrigin: 0,
|
|
7903
|
+
codeSigningId: 1,
|
|
7904
|
+
cli: 2
|
|
7905
|
+
};
|
|
7906
|
+
function audienceBody(audience) {
|
|
7907
|
+
const encoder = new TextEncoder();
|
|
7908
|
+
switch (audience.kind) {
|
|
7909
|
+
case "webOrigin":
|
|
7910
|
+
return encoder.encode(audience.origin);
|
|
7911
|
+
case "codeSigningId":
|
|
7912
|
+
return encoder.encode(audience.id);
|
|
7913
|
+
case "cli":
|
|
7914
|
+
return new Uint8Array(0);
|
|
7915
|
+
}
|
|
7916
|
+
}
|
|
7917
|
+
function audienceSigningBytes(audience) {
|
|
7918
|
+
return concat(
|
|
7919
|
+
Uint8Array.from([AUDIENCE_TAG[audience.kind]]),
|
|
7920
|
+
audienceBody(audience)
|
|
7921
|
+
);
|
|
7922
|
+
}
|
|
7923
|
+
function audienceWire(audience) {
|
|
7924
|
+
const tag2 = Uint8Array.from([AUDIENCE_TAG[audience.kind]]);
|
|
7925
|
+
if (audience.kind === "cli") {
|
|
7926
|
+
return tag2;
|
|
7927
|
+
}
|
|
7928
|
+
const body = audienceBody(audience);
|
|
7929
|
+
return concat(tag2, u32le(body.length), body);
|
|
7930
|
+
}
|
|
7931
|
+
async function signLoginStatement(input) {
|
|
7932
|
+
const node = fromHex(input.node, "node", 32);
|
|
7933
|
+
const challenge = fromHex(input.challenge, "challenge", 32);
|
|
7934
|
+
const sessionKey = fromHex(input.sessionKey, "sessionKey", 32);
|
|
7935
|
+
const signer = await resolveSigner(
|
|
7936
|
+
input.deviceSecret,
|
|
7937
|
+
input.signer,
|
|
7938
|
+
"deviceSecret"
|
|
7939
|
+
);
|
|
7940
|
+
const deviceKey = fromHex(signer.publicKey, "signer.publicKey", 32);
|
|
7941
|
+
const issuedAt = u64le(input.issuedAt);
|
|
7942
|
+
const expiresAt = u64le(input.expiresAt);
|
|
7943
|
+
const preimage = await domainHash(SIGN_DOMAIN5, [
|
|
7944
|
+
node,
|
|
7945
|
+
audienceSigningBytes(input.audience),
|
|
7946
|
+
challenge,
|
|
7947
|
+
sessionKey,
|
|
7948
|
+
deviceKey,
|
|
7949
|
+
issuedAt,
|
|
7950
|
+
expiresAt
|
|
7951
|
+
]);
|
|
7952
|
+
const signature = await signer.sign(preimage);
|
|
7953
|
+
return hex(
|
|
7954
|
+
concat(
|
|
7955
|
+
node,
|
|
7956
|
+
audienceWire(input.audience),
|
|
7957
|
+
challenge,
|
|
7958
|
+
sessionKey,
|
|
7959
|
+
deviceKey,
|
|
7960
|
+
issuedAt,
|
|
7961
|
+
expiresAt,
|
|
7962
|
+
signature
|
|
7963
|
+
)
|
|
7964
|
+
);
|
|
7965
|
+
}
|
|
7966
|
+
|
|
7967
|
+
// src/login/session.ts
|
|
7968
|
+
var DEFAULT_TTL_SECONDS2 = 300;
|
|
7969
|
+
async function generateSessionKey() {
|
|
7970
|
+
const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
7971
|
+
return { publicKey: hex(await derivePublicKey(secret)), secret };
|
|
7972
|
+
}
|
|
7973
|
+
async function login(config) {
|
|
7974
|
+
const baseUrl = config.nodeUrl.replace(/\/+$/, "");
|
|
7975
|
+
const timeoutMs = config.timeoutMs ?? 1e4;
|
|
7976
|
+
const request = async (path, init) => {
|
|
7977
|
+
const controller = new AbortController();
|
|
7978
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
7979
|
+
let response;
|
|
7980
|
+
try {
|
|
7981
|
+
const requestInit = { ...init, signal: controller.signal };
|
|
7982
|
+
response = config.fetch ? await config.fetch(`${baseUrl}${path}`, requestInit) : await globalThis.fetch(`${baseUrl}${path}`, requestInit);
|
|
7983
|
+
} catch (err) {
|
|
7984
|
+
throw new HTTPError(
|
|
7985
|
+
0,
|
|
7986
|
+
err instanceof Error ? err.name : "NetworkError",
|
|
7987
|
+
`${baseUrl}${path}`,
|
|
7988
|
+
new Headers(),
|
|
7989
|
+
err instanceof Error ? err.message : String(err)
|
|
7990
|
+
);
|
|
7991
|
+
} finally {
|
|
7992
|
+
clearTimeout(timer);
|
|
7993
|
+
}
|
|
7994
|
+
const text = await response.text().catch(() => "");
|
|
7995
|
+
if (!response.ok) {
|
|
7996
|
+
throw new HTTPError(
|
|
7997
|
+
response.status,
|
|
7998
|
+
response.statusText,
|
|
7999
|
+
`${baseUrl}${path}`,
|
|
8000
|
+
response.headers,
|
|
8001
|
+
text
|
|
8002
|
+
);
|
|
8003
|
+
}
|
|
8004
|
+
return text ? JSON.parse(text) : {};
|
|
8005
|
+
};
|
|
8006
|
+
const challengeBody = await request("/auth/challenge", {
|
|
8007
|
+
method: "GET",
|
|
8008
|
+
headers: { Accept: "application/json" }
|
|
8009
|
+
});
|
|
8010
|
+
const challenge = challengeBody.challenge ?? challengeBody.data?.challenge;
|
|
8011
|
+
if (!challenge) {
|
|
8012
|
+
throw new Error("the node issued no challenge");
|
|
8013
|
+
}
|
|
8014
|
+
const session = await generateSessionKey();
|
|
8015
|
+
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
8016
|
+
const statement = await signLoginStatement({
|
|
8017
|
+
node: config.node,
|
|
8018
|
+
audience: config.audience,
|
|
8019
|
+
challenge,
|
|
8020
|
+
sessionKey: session.publicKey,
|
|
8021
|
+
issuedAt,
|
|
8022
|
+
expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS2),
|
|
8023
|
+
// Resolved once and handed on, so a config carrying a secret and a config
|
|
8024
|
+
// carrying a `CryptoKey` take exactly the same path from here down.
|
|
8025
|
+
signer: await resolveSigner(config.deviceSecret, config.signer, "deviceSecret")
|
|
8026
|
+
});
|
|
8027
|
+
const tokenBody = await request("/auth/token", {
|
|
8028
|
+
method: "POST",
|
|
8029
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
8030
|
+
body: JSON.stringify({
|
|
8031
|
+
auth_method: "account_proof",
|
|
8032
|
+
public_key: session.publicKey,
|
|
8033
|
+
client_name: config.clientName ?? baseUrl,
|
|
8034
|
+
timestamp: issuedAt,
|
|
8035
|
+
provider_data: {
|
|
8036
|
+
challenge,
|
|
8037
|
+
login_statement: statement,
|
|
8038
|
+
account_proof: config.accountProof
|
|
8039
|
+
}
|
|
8040
|
+
})
|
|
8041
|
+
});
|
|
8042
|
+
const accessToken = tokenBody.access_token ?? tokenBody.data?.access_token;
|
|
8043
|
+
const refreshToken = tokenBody.refresh_token ?? tokenBody.data?.refresh_token;
|
|
8044
|
+
if (!accessToken) {
|
|
8045
|
+
throw new Error("the node minted no session");
|
|
8046
|
+
}
|
|
8047
|
+
return {
|
|
8048
|
+
accessToken,
|
|
8049
|
+
refreshToken: refreshToken ?? "",
|
|
8050
|
+
sessionKey: session.publicKey,
|
|
8051
|
+
sessionSecret: session.secret
|
|
8052
|
+
};
|
|
8053
|
+
}
|
|
8054
|
+
|
|
8055
|
+
// src/transport/relay-observer.ts
|
|
8056
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
8057
|
+
function defaultAudience() {
|
|
8058
|
+
const origin = globalThis.location?.origin;
|
|
8059
|
+
return origin ? { kind: "webOrigin", origin } : { kind: "cli" };
|
|
8060
|
+
}
|
|
8061
|
+
function jwtExpiryMs(token) {
|
|
8062
|
+
try {
|
|
8063
|
+
const parts = token.split(".");
|
|
8064
|
+
if (parts.length !== 3) return null;
|
|
8065
|
+
let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
8066
|
+
while (b64.length % 4) b64 += "=";
|
|
8067
|
+
const payload = JSON.parse(atob(b64));
|
|
8068
|
+
return typeof payload.exp === "number" ? payload.exp * 1e3 : null;
|
|
8069
|
+
} catch {
|
|
8070
|
+
return null;
|
|
8071
|
+
}
|
|
8072
|
+
}
|
|
8073
|
+
var RelayObserver = class {
|
|
8074
|
+
constructor(config) {
|
|
8075
|
+
this.session = null;
|
|
8076
|
+
this.sessionExpiresAt = null;
|
|
8077
|
+
/** In-flight login, so two surfaces asking at once mint one session, not two. */
|
|
8078
|
+
this.pending = null;
|
|
8079
|
+
this.sseClient = null;
|
|
8080
|
+
this.wsClient = null;
|
|
8081
|
+
this.config = config;
|
|
8082
|
+
}
|
|
8083
|
+
/** The node key this observer logs in with. Read for diagnostics. */
|
|
8084
|
+
get nodeKey() {
|
|
8085
|
+
return this.config.nodeKey;
|
|
8086
|
+
}
|
|
8087
|
+
/**
|
|
8088
|
+
* A usable access token, logging in if there is not one.
|
|
8089
|
+
*
|
|
8090
|
+
* Re-mints once the current token is within {@link EXPIRY_SKEW_MS} of
|
|
8091
|
+
* expiring, and on every call when the token's lifetime cannot be read —
|
|
8092
|
+
* reconnects are backed off, so an extra handshake costs far less than a
|
|
8093
|
+
* stream that silently stops. The refresh token is *held* but not spent:
|
|
8094
|
+
* rotating it is tracked in #87/#88 and a second login is correct in the
|
|
8095
|
+
* meantime, where re-presenting an unrotated refresh token would not be.
|
|
8096
|
+
*/
|
|
8097
|
+
async accessToken() {
|
|
8098
|
+
const now = Date.now();
|
|
8099
|
+
if (this.session && this.sessionExpiresAt !== null && this.sessionExpiresAt - EXPIRY_SKEW_MS > now) {
|
|
8100
|
+
return this.session.accessToken;
|
|
8101
|
+
}
|
|
8102
|
+
if (!this.pending) {
|
|
8103
|
+
this.pending = this.loginNow().finally(() => {
|
|
8104
|
+
this.pending = null;
|
|
8105
|
+
});
|
|
8106
|
+
}
|
|
8107
|
+
const session = await this.pending;
|
|
8108
|
+
return session.accessToken;
|
|
8109
|
+
}
|
|
8110
|
+
async loginNow() {
|
|
8111
|
+
const session = await login({
|
|
8112
|
+
nodeUrl: this.config.nodeUrl,
|
|
8113
|
+
node: this.config.nodeKey,
|
|
8114
|
+
signer: await this.config.signer(),
|
|
8115
|
+
accountProof: this.config.accountProof,
|
|
8116
|
+
audience: this.config.audience,
|
|
8117
|
+
clientName: this.config.clientName,
|
|
8118
|
+
ttlSeconds: this.config.ttlSeconds,
|
|
8119
|
+
fetch: this.config.fetch,
|
|
8120
|
+
timeoutMs: this.config.timeoutMs
|
|
8121
|
+
});
|
|
8122
|
+
this.session = session;
|
|
8123
|
+
this.sessionExpiresAt = jwtExpiryMs(session.accessToken);
|
|
8124
|
+
return session;
|
|
8125
|
+
}
|
|
8126
|
+
/**
|
|
8127
|
+
* SSE events over the relay node — the same `SseClient` a node client hands
|
|
8128
|
+
* out, constructed the same way, so nothing downstream can tell which
|
|
8129
|
+
* transport produced it.
|
|
8130
|
+
*/
|
|
8131
|
+
get events() {
|
|
8132
|
+
if (!this.sseClient) {
|
|
8133
|
+
this.sseClient = new SseClient({
|
|
8134
|
+
baseUrl: this.config.nodeUrl,
|
|
8135
|
+
getAuthToken: () => this.accessToken()
|
|
8136
|
+
});
|
|
8137
|
+
}
|
|
8138
|
+
return this.sseClient;
|
|
8139
|
+
}
|
|
8140
|
+
/** WebSocket events over the relay node. @experimental — prefer `events`. */
|
|
8141
|
+
get ws() {
|
|
8142
|
+
if (!this.wsClient) {
|
|
8143
|
+
this.wsClient = new WsClient({
|
|
8144
|
+
baseUrl: this.config.nodeUrl,
|
|
8145
|
+
getAuthToken: () => this.accessToken()
|
|
8146
|
+
});
|
|
8147
|
+
}
|
|
8148
|
+
return this.wsClient;
|
|
8149
|
+
}
|
|
8150
|
+
/** Close whatever was opened. Never touches the relay's write path. */
|
|
8151
|
+
close() {
|
|
8152
|
+
this.sseClient?.close();
|
|
8153
|
+
this.wsClient?.close();
|
|
8154
|
+
}
|
|
8155
|
+
};
|
|
8156
|
+
|
|
8157
|
+
// src/request-proof/request-proof.ts
|
|
8158
|
+
var utf8 = (text) => new TextEncoder().encode(text);
|
|
8159
|
+
var REQUEST_SIGN_DOMAIN = utf8("calimero.auth.request.v1");
|
|
8160
|
+
var REQUEST_BODY_DOMAIN = utf8("calimero.auth.request.body.v1");
|
|
8161
|
+
var REQUEST_PROOF_HEADER = "X-Calimero-Proof";
|
|
8162
|
+
var DEFAULT_VALID_FOR = 300;
|
|
8163
|
+
async function signRequestProof(input) {
|
|
8164
|
+
const { method, path, credential } = input;
|
|
8165
|
+
if (!method) throw new Error("signRequestProof needs the method the request will use");
|
|
8166
|
+
if (!path.startsWith("/")) {
|
|
8167
|
+
throw new Error(`signRequestProof needs a path, got ${JSON.stringify(path)}`);
|
|
8168
|
+
}
|
|
8169
|
+
if (path.includes("?")) {
|
|
8170
|
+
throw new Error(
|
|
8171
|
+
`the path must carry no query string, got ${JSON.stringify(path)} \u2014 a query is excluded from the signature on purpose, because a proxy may rewrite one. Anything that must be bound belongs in the body.`
|
|
8172
|
+
);
|
|
8173
|
+
}
|
|
8174
|
+
const bodyBytes2 = input.body === void 0 ? new Uint8Array() : typeof input.body === "string" ? utf8(input.body) : input.body;
|
|
8175
|
+
const bodyHash = await domainHash(REQUEST_BODY_DOMAIN, [bodyBytes2]);
|
|
8176
|
+
const issuedAt = Math.floor(input.now ?? Date.now() / 1e3);
|
|
8177
|
+
const expiresAt = issuedAt + (input.validForSeconds ?? DEFAULT_VALID_FOR);
|
|
8178
|
+
const payload = await domainHash(REQUEST_SIGN_DOMAIN, [
|
|
8179
|
+
utf8(method),
|
|
8180
|
+
utf8(path),
|
|
8181
|
+
bodyHash,
|
|
8182
|
+
u64le(issuedAt),
|
|
8183
|
+
u64le(expiresAt)
|
|
8184
|
+
]);
|
|
8185
|
+
const signature = await signPayload(payload, input);
|
|
8186
|
+
if (signature.length !== 64) {
|
|
8187
|
+
throw new Error(`the signer returned ${signature.length} bytes, not a 64-byte signature`);
|
|
8188
|
+
}
|
|
8189
|
+
const requestSig = concat(
|
|
8190
|
+
u32le(utf8(method).length),
|
|
8191
|
+
utf8(method),
|
|
8192
|
+
u32le(utf8(path).length),
|
|
8193
|
+
utf8(path),
|
|
8194
|
+
bodyHash,
|
|
8195
|
+
u64le(issuedAt),
|
|
8196
|
+
u64le(expiresAt),
|
|
8197
|
+
signature
|
|
8198
|
+
);
|
|
8199
|
+
const accountProof = hexToBytes(credential, "credential");
|
|
8200
|
+
return hex(concat(accountProof, new Uint8Array([0]), requestSig));
|
|
8201
|
+
}
|
|
8202
|
+
async function signPayload(payload, input) {
|
|
8203
|
+
if (input.signer) return input.signer.sign(payload);
|
|
8204
|
+
if (!input.deviceSecret) {
|
|
8205
|
+
throw new Error("signRequestProof needs either `deviceSecret` or `signer`");
|
|
8206
|
+
}
|
|
8207
|
+
const key = await importSigningKey(input.deviceSecret);
|
|
8208
|
+
return new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, payload));
|
|
8209
|
+
}
|
|
8210
|
+
function hexToBytes(value, label2) {
|
|
8211
|
+
const clean = value.trim();
|
|
8212
|
+
if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length % 2 !== 0) {
|
|
8213
|
+
throw new Error(`${label2} is not an even-length hex string`);
|
|
8214
|
+
}
|
|
8215
|
+
return fromHex(clean, label2, clean.length / 2);
|
|
8216
|
+
}
|
|
8217
|
+
|
|
8218
|
+
// src/request-proof/authorizer.ts
|
|
8219
|
+
function createProofAuthorizer(credential) {
|
|
8220
|
+
if (!credential.credential) {
|
|
8221
|
+
throw new Error("a proof authorizer needs the `credential` from signDeviceCert");
|
|
8222
|
+
}
|
|
8223
|
+
if (!credential.deviceSecret && !credential.signer) {
|
|
8224
|
+
throw new Error("a proof authorizer needs either `deviceSecret` or `signer`");
|
|
8225
|
+
}
|
|
8226
|
+
return async ({ method, path, body }) => {
|
|
8227
|
+
const proof = await signRequestProof({
|
|
8228
|
+
method,
|
|
8229
|
+
path,
|
|
8230
|
+
body,
|
|
8231
|
+
credential: credential.credential,
|
|
8232
|
+
deviceSecret: credential.deviceSecret,
|
|
8233
|
+
signer: credential.signer,
|
|
8234
|
+
validForSeconds: credential.validForSeconds
|
|
8235
|
+
});
|
|
8236
|
+
return { [REQUEST_PROOF_HEADER]: proof };
|
|
8237
|
+
};
|
|
8238
|
+
}
|
|
8239
|
+
function signedPath(url) {
|
|
8240
|
+
return new URL(url).pathname;
|
|
8241
|
+
}
|
|
8242
|
+
|
|
8243
|
+
// src/transport/mero-client.ts
|
|
8244
|
+
function relayHasNo(surface, why) {
|
|
8245
|
+
return new Error(
|
|
8246
|
+
`this client was constructed with the relay transport, which has no ${surface}: ${why}. Construct a node client (createMeroClient({ baseUrl })) for that surface \u2014 nothing here will silently connect to a node you did not choose.`
|
|
8247
|
+
);
|
|
8248
|
+
}
|
|
8249
|
+
function missingNodeKey(surface) {
|
|
8250
|
+
return new Error(
|
|
8251
|
+
`this relay-transport client cannot open ${surface}: it was given no relay node key. A relay is an ordinary node and does serve /sse and /ws \u2014 but the login statement must be signed against the node's device signing key, learned out of band (never read from the node, and never derived from its peerId). Pass it as \`observe: { nodeKey }\`. For a cloud-hosted relay the cloud does not publish it yet \u2014 see mdma #312.`
|
|
8252
|
+
);
|
|
8253
|
+
}
|
|
8254
|
+
function buildObserver(relay, observe) {
|
|
8255
|
+
const nodeKey = observe?.nodeKey;
|
|
8256
|
+
if (!nodeKey) return null;
|
|
8257
|
+
return new RelayObserver({
|
|
8258
|
+
nodeUrl: observe?.nodeUrl ?? relay.relayUrl,
|
|
8259
|
+
nodeKey,
|
|
8260
|
+
accountProof: observe?.accountProof ?? relay.authorProof,
|
|
8261
|
+
audience: observe?.audience ?? defaultAudience(),
|
|
8262
|
+
signer: observe?.signer ? async () => observe.signer : () => relay.authorSigner(),
|
|
8263
|
+
clientName: observe?.clientName,
|
|
8264
|
+
ttlSeconds: observe?.ttlSeconds,
|
|
8265
|
+
fetch: observe?.fetch,
|
|
8266
|
+
timeoutMs: observe?.timeoutMs
|
|
8267
|
+
});
|
|
8268
|
+
}
|
|
8269
|
+
var MeroClient = class {
|
|
8270
|
+
constructor(config) {
|
|
8271
|
+
/** The relay's event session, or `null` on a node client / with no node key. */
|
|
8272
|
+
this.relayObserver = null;
|
|
8273
|
+
/** Admin reads against the relay, when a session or a proof was supplied. */
|
|
8274
|
+
this.relayAdmin = null;
|
|
8275
|
+
/**
|
|
8276
|
+
* Events off the relay authenticated by a request proof, built lazily.
|
|
8277
|
+
*
|
|
8278
|
+
* Separate from {@link relayObserver} rather than a mode inside it: the
|
|
8279
|
+
* observer's whole subject is a session addressed to a node key, and a proof
|
|
8280
|
+
* has neither. Holding the authorizer instead of the client keeps construction
|
|
8281
|
+
* free of the first signature.
|
|
8282
|
+
*/
|
|
8283
|
+
this.proofAuthorizer = null;
|
|
8284
|
+
this.relayUrl = null;
|
|
8285
|
+
this.proofEvents = null;
|
|
8286
|
+
if (config.transport === "relay") {
|
|
8287
|
+
this.transport = "relay";
|
|
8288
|
+
this.nodeClient = null;
|
|
8289
|
+
const relay = config.relay instanceof RelayClient ? config.relay : new RelayClient(config.relay);
|
|
8290
|
+
this.relayObserver = buildObserver(relay, config.observe);
|
|
8291
|
+
this.rpcTransport = new RelayTransport(relay, this.relayObserver);
|
|
8292
|
+
this.relayUrl = relay.relayUrl;
|
|
8293
|
+
if (config.proof) {
|
|
8294
|
+
this.proofAuthorizer = createProofAuthorizer(config.proof);
|
|
8295
|
+
}
|
|
8296
|
+
if (config.session) {
|
|
8297
|
+
const token = config.session;
|
|
8298
|
+
this.relayAdmin = createAdminApiClient({
|
|
8299
|
+
baseUrl: relay.relayUrl,
|
|
8300
|
+
getAuthToken: typeof token === "string" ? async () => token : token
|
|
8301
|
+
});
|
|
8302
|
+
} else if (this.proofAuthorizer) {
|
|
8303
|
+
const authorizeRequest = this.proofAuthorizer;
|
|
8304
|
+
this.relayAdmin = createAdminApiClientFromHttpClient(
|
|
8305
|
+
createHttpClient({
|
|
8306
|
+
fetch: (url, init) => globalThis.fetch(url, init),
|
|
8307
|
+
baseUrl: relay.relayUrl,
|
|
8308
|
+
authorizeRequest
|
|
8309
|
+
}),
|
|
8310
|
+
{ baseUrl: relay.relayUrl }
|
|
8311
|
+
);
|
|
8312
|
+
}
|
|
8313
|
+
return;
|
|
8314
|
+
}
|
|
8315
|
+
this.transport = "node";
|
|
8316
|
+
const { transport: _ignored, ...nodeConfig } = config;
|
|
8317
|
+
this.nodeClient = new MeroJs(nodeConfig);
|
|
8318
|
+
this.rpcTransport = this.nodeClient.rpc;
|
|
8319
|
+
}
|
|
8320
|
+
/**
|
|
8321
|
+
* The write surface. Identical on both transports — this is the whole point.
|
|
8322
|
+
*
|
|
8323
|
+
* On a node this IS the `RpcClient` a `MeroJs` would have handed out, not a
|
|
8324
|
+
* wrapper around it, so `client.rpc` and `client.node.rpc` are the same
|
|
8325
|
+
* object.
|
|
8326
|
+
*/
|
|
8327
|
+
get rpc() {
|
|
8328
|
+
return this.rpcTransport;
|
|
8329
|
+
}
|
|
8330
|
+
/**
|
|
8331
|
+
* Whether `events` / `ws` will work on this client.
|
|
8332
|
+
*
|
|
8333
|
+
* Always `true` on the node transport. On the relay transport it says whether
|
|
8334
|
+
* a node key was supplied — the relay can be observed, but only by a caller
|
|
8335
|
+
* who can name the key the login statement is signed against.
|
|
8336
|
+
*/
|
|
8337
|
+
get canSubscribe() {
|
|
8338
|
+
return this.rpcTransport.canSubscribe || this.proofAuthorizer !== null;
|
|
8339
|
+
}
|
|
8340
|
+
/** The underlying `MeroJs`. Node transport only. */
|
|
8341
|
+
get node() {
|
|
8342
|
+
if (!this.nodeClient) {
|
|
8343
|
+
throw relayHasNo("node connection", "it holds a signing key and a relay URL, not a node");
|
|
8344
|
+
}
|
|
8345
|
+
return this.nodeClient;
|
|
8346
|
+
}
|
|
8347
|
+
/** The underlying `RelayClient`, for `describe()`. Relay transport only. */
|
|
8348
|
+
get relay() {
|
|
8349
|
+
if (!(this.rpcTransport instanceof RelayTransport)) {
|
|
8350
|
+
throw new Error("this client was constructed with the node transport and has no relay");
|
|
8351
|
+
}
|
|
8352
|
+
return this.rpcTransport.client;
|
|
8353
|
+
}
|
|
8354
|
+
/**
|
|
8355
|
+
* Admin API. Node transport only.
|
|
8356
|
+
*
|
|
8357
|
+
* Two different things live behind this accessor today, and only one of them
|
|
8358
|
+
* is genuinely unavailable through a relay.
|
|
8359
|
+
*
|
|
8360
|
+
* The **mutations** — `createNamespace`, `setMemberCapabilities`,
|
|
8361
|
+
* `upgradeGroup` and the rest — are operator actions on somebody else's
|
|
8362
|
+
* machine. A keyholder does not get those, and should not; admin is for
|
|
8363
|
+
* admins.
|
|
8364
|
+
*
|
|
8365
|
+
* The **reads** are a different matter. `getContext`, `getContexts`,
|
|
8366
|
+
* `getApplication`, `listNamespaces`, `getBlob` are an app asking what it is
|
|
8367
|
+
* looking at, not an operator administering anything. They sit under
|
|
8368
|
+
* `/admin-api/` for historical reasons, and a delegated session is refused
|
|
8369
|
+
* them (measured: `403`, while the same session reads context state through
|
|
8370
|
+
* `POST /contexts/{ctx}/query` and writes through `/intents` quite happily).
|
|
8371
|
+
*
|
|
8372
|
+
* So this is the one place where "the same code runs on both transports"
|
|
8373
|
+
* does not hold yet, and it is a missing surface rather than a missing
|
|
8374
|
+
* permission. A transport-independent read API is designed but not built; the
|
|
8375
|
+
* throw below is deliberate until it exists, because the alternative — quietly
|
|
8376
|
+
* reaching for a node the caller did not choose — is worse than a clear stop.
|
|
8377
|
+
*/
|
|
8378
|
+
/**
|
|
8379
|
+
* The admin surface.
|
|
8380
|
+
*
|
|
8381
|
+
* On a node client this is the node's own. On a relay client it exists only
|
|
8382
|
+
* when a `session` was supplied, and it is **the relay's admin API
|
|
8383
|
+
* authenticated as the account** — so the caller-scoped reads answer:
|
|
8384
|
+
* `getContexts` and `listNamespaces` return this account's own, because
|
|
8385
|
+
* `account_proof` grants `context:list-own` and `namespace:list-own`.
|
|
8386
|
+
*
|
|
8387
|
+
* Mutations are not filtered out here, deliberately. A delegated session does
|
|
8388
|
+
* not carry the grants they require, so the node refuses them with a `403`
|
|
8389
|
+
* that names the missing permission — which is a better answer than a
|
|
8390
|
+
* client-side guess, and keeps the policy in the one place that owns it.
|
|
8391
|
+
*/
|
|
8392
|
+
get admin() {
|
|
8393
|
+
if (this.nodeClient) return this.nodeClient.admin;
|
|
8394
|
+
if (this.relayAdmin) return this.relayAdmin;
|
|
8395
|
+
throw new Error(
|
|
8396
|
+
"this relay-transport client has no admin surface because it was given neither a `proof` nor a `session`. A warrant authorises one intent and is not a credential for reading. The caller-scoped reads (getContexts, listNamespaces) want one of the other two: pass `proof: { credential, deviceSecret }` \u2014 the device cert you already hold, which needs no login and no node key \u2014 or a `session` minted with `login()`. Context STATE reads work through execute/query either way."
|
|
8397
|
+
);
|
|
8398
|
+
}
|
|
8399
|
+
/** Auth API. Node transport only — a relay issues no node credential. */
|
|
8400
|
+
get auth() {
|
|
8401
|
+
return this.node.auth;
|
|
8402
|
+
}
|
|
8403
|
+
/**
|
|
8404
|
+
* SSE events — on **both** transports.
|
|
8405
|
+
*
|
|
8406
|
+
* On a relay client this is an `SseClient` against the relay node, fed by a
|
|
8407
|
+
* session minted with the author's account proof. The session is minted
|
|
8408
|
+
* lazily, on the first token the stream asks for, so reading this getter
|
|
8409
|
+
* performs no network call.
|
|
8410
|
+
*
|
|
8411
|
+
* Throws only when the relay client was given no node key: the one input
|
|
8412
|
+
* that cannot be inferred. See {@link missingNodeKey}.
|
|
8413
|
+
*/
|
|
8414
|
+
get events() {
|
|
8415
|
+
if (this.nodeClient) return this.nodeClient.events;
|
|
8416
|
+
if (this.relayObserver) return this.relayObserver.events;
|
|
8417
|
+
if (this.proofAuthorizer && this.relayUrl) {
|
|
8418
|
+
this.proofEvents ?? (this.proofEvents = new SseClient({
|
|
8419
|
+
baseUrl: this.relayUrl,
|
|
8420
|
+
authorize: this.proofAuthorizer
|
|
8421
|
+
}));
|
|
8422
|
+
return this.proofEvents;
|
|
8423
|
+
}
|
|
8424
|
+
throw missingNodeKey("SSE events");
|
|
8425
|
+
}
|
|
8426
|
+
/** WebSocket events, on both transports. @experimental — prefer `events`. */
|
|
8427
|
+
get ws() {
|
|
8428
|
+
if (this.nodeClient) return this.nodeClient.ws;
|
|
8429
|
+
if (this.relayObserver) return this.relayObserver.ws;
|
|
8430
|
+
if (this.proofAuthorizer) {
|
|
8431
|
+
throw new Error(
|
|
8432
|
+
"this client authenticates with a request proof, which cannot open a WebSocket from a browser: `WebSocket` cannot set the X-Calimero-Proof header, and core's guard reads the proof from a header only (the ?token= query parameter is the session equivalent and has no proof counterpart). Use `client.events` \u2014 SSE is fetch-based, carries the header, and is the recommended transport anyway."
|
|
8433
|
+
);
|
|
8434
|
+
}
|
|
8435
|
+
throw missingNodeKey("WebSocket events");
|
|
8436
|
+
}
|
|
8437
|
+
/**
|
|
8438
|
+
* Ephemeral presence. Node transport only.
|
|
8439
|
+
*
|
|
8440
|
+
* Not extended to the relay even though a logged-in session could reach both
|
|
8441
|
+
* halves of it mechanically. Publishing resolves the author from an *owned
|
|
8442
|
+
* context identity on the node*, and whether a delegated session has one has
|
|
8443
|
+
* not been established — a presence client that silently published nothing,
|
|
8444
|
+
* or published as the wrong identity, is worse than one that says it is not
|
|
8445
|
+
* available. The read half is not lost: presence arrives on `client.events`
|
|
8446
|
+
* like any other context event, and can be filtered there.
|
|
8447
|
+
*
|
|
8448
|
+
* The rejected alternative was to expose it over the relay session and let
|
|
8449
|
+
* `set` fail at the node. That trades a clear construction-time answer for a
|
|
8450
|
+
* runtime failure inside a UI that has already rendered.
|
|
8451
|
+
*/
|
|
8452
|
+
get ephemeral() {
|
|
8453
|
+
if (!this.nodeClient) {
|
|
8454
|
+
throw relayHasNo(
|
|
8455
|
+
"ephemeral presence",
|
|
8456
|
+
"publishing resolves the author from an owned context identity on the node, which a delegated session is not known to have \u2014 read presence off `client.events` instead"
|
|
8457
|
+
);
|
|
8458
|
+
}
|
|
8459
|
+
return this.nodeClient.ephemeral;
|
|
8460
|
+
}
|
|
8461
|
+
/**
|
|
8462
|
+
* Calimero Cloud.
|
|
8463
|
+
*
|
|
8464
|
+
* Available on both transports, because it is neither: the cloud session is
|
|
8465
|
+
* independent of the node credential and of the relay's signing key, and a
|
|
8466
|
+
* relay-transport app still needs it to list namespaces and manage billing.
|
|
8467
|
+
*/
|
|
8468
|
+
get cloud() {
|
|
8469
|
+
if (!this.nodeClient) {
|
|
8470
|
+
throw relayHasNo(
|
|
8471
|
+
"bundled cloud client",
|
|
8472
|
+
"a relay client is built from a cloud connection rather than owning one \u2014 reach it through the `cloud` field that `connectCloud` returns, or construct a `CloudClient` directly"
|
|
8473
|
+
);
|
|
8474
|
+
}
|
|
8475
|
+
return this.nodeClient.cloud;
|
|
7184
8476
|
}
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
8477
|
+
/**
|
|
8478
|
+
* Release whatever the transport is holding.
|
|
8479
|
+
*
|
|
8480
|
+
* Safe on a relay client, where there is nothing open to close — a caller
|
|
8481
|
+
* that tears down on unmount should not have to know which transport it got.
|
|
8482
|
+
*/
|
|
8483
|
+
close() {
|
|
8484
|
+
this.nodeClient?.close();
|
|
8485
|
+
this.relayObserver?.close();
|
|
8486
|
+
this.proofEvents?.close();
|
|
7194
8487
|
}
|
|
7195
|
-
|
|
8488
|
+
};
|
|
8489
|
+
function createMeroClient(config) {
|
|
8490
|
+
return new MeroClient(config);
|
|
7196
8491
|
}
|
|
7197
8492
|
|
|
7198
8493
|
// src/relay/nonce-source.ts
|
|
@@ -7206,6 +8501,10 @@ function createMemoryNonceSource(start = 1) {
|
|
|
7206
8501
|
const value = next;
|
|
7207
8502
|
next += 1n;
|
|
7208
8503
|
return Promise.resolve(value);
|
|
8504
|
+
},
|
|
8505
|
+
advanceTo(floor) {
|
|
8506
|
+
if (floor > next) next = floor;
|
|
8507
|
+
return Promise.resolve();
|
|
7209
8508
|
}
|
|
7210
8509
|
};
|
|
7211
8510
|
}
|
|
@@ -7216,21 +8515,101 @@ function createLocalStorageNonceSource(key, storage) {
|
|
|
7216
8515
|
"no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence"
|
|
7217
8516
|
);
|
|
7218
8517
|
}
|
|
8518
|
+
const lastHandedOut = () => {
|
|
8519
|
+
const raw = store.getItem(key);
|
|
8520
|
+
if (raw === null || raw === "") return 0n;
|
|
8521
|
+
try {
|
|
8522
|
+
const parsed = BigInt(raw);
|
|
8523
|
+
return parsed > 0n ? parsed : 0n;
|
|
8524
|
+
} catch {
|
|
8525
|
+
return 0n;
|
|
8526
|
+
}
|
|
8527
|
+
};
|
|
7219
8528
|
return {
|
|
7220
8529
|
next() {
|
|
7221
|
-
const
|
|
7222
|
-
let current = 0n;
|
|
7223
|
-
if (raw !== null && raw !== "") {
|
|
7224
|
-
try {
|
|
7225
|
-
const parsed = BigInt(raw);
|
|
7226
|
-
if (parsed > 0n) current = parsed;
|
|
7227
|
-
} catch {
|
|
7228
|
-
current = 0n;
|
|
7229
|
-
}
|
|
7230
|
-
}
|
|
7231
|
-
const value = current + 1n;
|
|
8530
|
+
const value = lastHandedOut() + 1n;
|
|
7232
8531
|
store.setItem(key, value.toString());
|
|
7233
8532
|
return Promise.resolve(value);
|
|
8533
|
+
},
|
|
8534
|
+
advanceTo(floor) {
|
|
8535
|
+
const wanted = floor > 0n ? floor - 1n : 0n;
|
|
8536
|
+
if (wanted > lastHandedOut()) store.setItem(key, wanted.toString());
|
|
8537
|
+
return Promise.resolve();
|
|
8538
|
+
}
|
|
8539
|
+
};
|
|
8540
|
+
}
|
|
8541
|
+
function authorNonceLookup(options) {
|
|
8542
|
+
return {
|
|
8543
|
+
getWarrantNonce: (contextId, _authorDeviceKey) => options.client.getWarrantNonceAsAuthor(contextId, options.authorProof)
|
|
8544
|
+
};
|
|
8545
|
+
}
|
|
8546
|
+
var WarrantNonceExhaustedError = class extends Error {
|
|
8547
|
+
constructor(contextId, authorDeviceKey) {
|
|
8548
|
+
super(
|
|
8549
|
+
`author device ${authorDeviceKey} has spent every nonce in context ${contextId}; it must re-key to write again`
|
|
8550
|
+
);
|
|
8551
|
+
this.contextId = contextId;
|
|
8552
|
+
this.authorDeviceKey = authorDeviceKey;
|
|
8553
|
+
this.name = "WarrantNonceExhaustedError";
|
|
8554
|
+
}
|
|
8555
|
+
};
|
|
8556
|
+
function createRecoveringNonceSource(options) {
|
|
8557
|
+
const lookups = Array.isArray(options.lookups) ? options.lookups : [options.lookups];
|
|
8558
|
+
if (lookups.length === 0) {
|
|
8559
|
+
throw new Error("a recovering nonce source needs at least one node to ask");
|
|
8560
|
+
}
|
|
8561
|
+
const local = options.local ?? createMemoryNonceSource(1);
|
|
8562
|
+
const onLookupFailure = options.onLookupFailure ?? "throw";
|
|
8563
|
+
let floor = null;
|
|
8564
|
+
let pending = null;
|
|
8565
|
+
const resolveFloor = async () => {
|
|
8566
|
+
const answers = await Promise.allSettled(
|
|
8567
|
+
lookups.map(
|
|
8568
|
+
(lookup) => lookup.getWarrantNonce(options.contextId, options.authorDeviceKey)
|
|
8569
|
+
)
|
|
8570
|
+
);
|
|
8571
|
+
let highest = null;
|
|
8572
|
+
let exhausted = false;
|
|
8573
|
+
for (const answer of answers) {
|
|
8574
|
+
if (answer.status !== "fulfilled") continue;
|
|
8575
|
+
const state = answer.value;
|
|
8576
|
+
if (state.kind === "exhausted") {
|
|
8577
|
+
exhausted = true;
|
|
8578
|
+
continue;
|
|
8579
|
+
}
|
|
8580
|
+
if (highest === null || state.nextNonce > highest) highest = state.nextNonce;
|
|
8581
|
+
}
|
|
8582
|
+
if (highest === null && exhausted) {
|
|
8583
|
+
throw new WarrantNonceExhaustedError(options.contextId, options.authorDeviceKey);
|
|
8584
|
+
}
|
|
8585
|
+
if (highest === null) {
|
|
8586
|
+
const first = answers.find((a) => a.status === "rejected");
|
|
8587
|
+
const reason = first && first.status === "rejected" ? first.reason : void 0;
|
|
8588
|
+
if (onLookupFailure === "local") {
|
|
8589
|
+
floor = 0n;
|
|
8590
|
+
return;
|
|
8591
|
+
}
|
|
8592
|
+
throw reason instanceof Error ? reason : new Error(`no node could answer where the warrant-nonce sequence stands: ${String(reason)}`);
|
|
8593
|
+
}
|
|
8594
|
+
floor = highest;
|
|
8595
|
+
await local.advanceTo?.(highest);
|
|
8596
|
+
};
|
|
8597
|
+
return {
|
|
8598
|
+
async next() {
|
|
8599
|
+
if (floor === null) {
|
|
8600
|
+
pending ?? (pending = resolveFloor().finally(() => {
|
|
8601
|
+
pending = null;
|
|
8602
|
+
}));
|
|
8603
|
+
await pending;
|
|
8604
|
+
}
|
|
8605
|
+
const mine = await local.next();
|
|
8606
|
+
const value = floor !== null && floor > mine ? floor : mine;
|
|
8607
|
+
floor = value + 1n;
|
|
8608
|
+
return value;
|
|
8609
|
+
},
|
|
8610
|
+
async advanceTo(wanted) {
|
|
8611
|
+
if (floor === null || wanted > floor) floor = wanted;
|
|
8612
|
+
await local.advanceTo?.(wanted);
|
|
7234
8613
|
}
|
|
7235
8614
|
};
|
|
7236
8615
|
}
|
|
@@ -7403,7 +8782,7 @@ var MAX_HANDSHAKE_REPLY = 1024;
|
|
|
7403
8782
|
var RENEW_BEFORE_MS = 6e4;
|
|
7404
8783
|
var TRANSPORT_BINDING_DOMAIN = "calimero.tee-attest.transport-key.v1";
|
|
7405
8784
|
var NULL_BODY_STATUSES = [101, 204, 205, 304];
|
|
7406
|
-
var
|
|
8785
|
+
var utf82 = new TextEncoder();
|
|
7407
8786
|
var StaleTransportKeyError = class extends Error {
|
|
7408
8787
|
constructor() {
|
|
7409
8788
|
super("The node no longer holds the attested transport key; attest again for the current one");
|
|
@@ -7411,18 +8790,18 @@ var StaleTransportKeyError = class extends Error {
|
|
|
7411
8790
|
}
|
|
7412
8791
|
};
|
|
7413
8792
|
var SealedTransportError = class extends Error {
|
|
7414
|
-
constructor(status, code, message,
|
|
8793
|
+
constructor(status, code, message, retryAfterMs2) {
|
|
7415
8794
|
super(message);
|
|
7416
8795
|
this.status = status;
|
|
7417
8796
|
this.code = code;
|
|
7418
|
-
this.retryAfterMs =
|
|
8797
|
+
this.retryAfterMs = retryAfterMs2;
|
|
7419
8798
|
this.name = "SealedTransportError";
|
|
7420
8799
|
}
|
|
7421
8800
|
};
|
|
7422
8801
|
async function transportKeyBinding(inner, transportKey) {
|
|
7423
8802
|
const digest = await crypto.subtle.digest(
|
|
7424
8803
|
"SHA-256",
|
|
7425
|
-
concat(
|
|
8804
|
+
concat(utf82.encode(TRANSPORT_BINDING_DOMAIN), inner, transportKey)
|
|
7426
8805
|
);
|
|
7427
8806
|
return new Uint8Array(digest);
|
|
7428
8807
|
}
|
|
@@ -7626,7 +9005,7 @@ function nonce(requestId, index) {
|
|
|
7626
9005
|
return bytes;
|
|
7627
9006
|
}
|
|
7628
9007
|
function joinInner(head, body) {
|
|
7629
|
-
const encoded =
|
|
9008
|
+
const encoded = utf82.encode(JSON.stringify(head));
|
|
7630
9009
|
const len = new Uint8Array(4);
|
|
7631
9010
|
new DataView(len.buffer).setUint32(0, encoded.length, false);
|
|
7632
9011
|
return concat(len, encoded, body);
|
|
@@ -7735,8 +9114,8 @@ async function envelopeRefusal(response) {
|
|
|
7735
9114
|
}
|
|
7736
9115
|
if (code === "stale_transport_key") return new StaleTransportKeyError();
|
|
7737
9116
|
const retryAfter = Number(response.headers.get("retry-after"));
|
|
7738
|
-
const
|
|
7739
|
-
return new SealedTransportError(response.status, code, message,
|
|
9117
|
+
const retryAfterMs2 = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 5) * 1e3 : void 0;
|
|
9118
|
+
return new SealedTransportError(response.status, code, message, retryAfterMs2);
|
|
7740
9119
|
}
|
|
7741
9120
|
|
|
7742
9121
|
// src/cloud/connect.ts
|
|
@@ -7744,6 +9123,7 @@ async function connectCloud(options) {
|
|
|
7744
9123
|
if (!options.sessionToken && !options.googleIdToken) {
|
|
7745
9124
|
throw new Error("connectCloud needs either a sessionToken or a googleIdToken");
|
|
7746
9125
|
}
|
|
9126
|
+
const signer = await resolveSigner(options.deviceSecret, options.signer, "deviceSecret");
|
|
7747
9127
|
const cloud = new CloudClient({
|
|
7748
9128
|
cloudBaseUrl: options.cloudBaseUrl,
|
|
7749
9129
|
sessionToken: options.sessionToken,
|
|
@@ -7769,8 +9149,8 @@ async function connectCloud(options) {
|
|
|
7769
9149
|
executorAccount: relayInfo.executorAccount,
|
|
7770
9150
|
authorAccount: options.authorAccount,
|
|
7771
9151
|
authorProof: options.authorProof,
|
|
7772
|
-
|
|
7773
|
-
nonces: options.nonces ??
|
|
9152
|
+
signer,
|
|
9153
|
+
nonces: options.nonces ?? defaultNonceSource(signer.publicKey),
|
|
7774
9154
|
ttlSeconds: options.ttlSeconds,
|
|
7775
9155
|
// Only the relay is sealed: a sealed fetch is bound to one node and refuses
|
|
7776
9156
|
// any other URL, and the cloud calls above carry no intent.
|
|
@@ -7787,9 +9167,50 @@ async function connectCloud(options) {
|
|
|
7787
9167
|
namespaceId: namespace.namespaceId,
|
|
7788
9168
|
namespace,
|
|
7789
9169
|
relayInfo,
|
|
7790
|
-
execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson)
|
|
9170
|
+
execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson),
|
|
9171
|
+
// Built from the same `relay`, not a second one: one nonce sequence, one
|
|
9172
|
+
// warrant signer. Two clients over one relay would hand out the same nonce
|
|
9173
|
+
// twice and have the network refuse the loser as a replay.
|
|
9174
|
+
//
|
|
9175
|
+
// `observe` is always passed, with whatever key is known — the caller's if
|
|
9176
|
+
// they had one, otherwise the cloud's, which is `null` until mdma #312. One
|
|
9177
|
+
// code path rather than two, so the hosted case starts observing the day
|
|
9178
|
+
// the field appears, without a line changing here or at the call site.
|
|
9179
|
+
client: new MeroClient({
|
|
9180
|
+
transport: "relay",
|
|
9181
|
+
relay,
|
|
9182
|
+
observe: {
|
|
9183
|
+
nodeKey: options.relayNodeKey ?? relayInfo.nodeKey,
|
|
9184
|
+
audience: options.observeAudience,
|
|
9185
|
+
fetch: options.fetch,
|
|
9186
|
+
timeoutMs: options.timeoutMs
|
|
9187
|
+
}
|
|
9188
|
+
})
|
|
7791
9189
|
};
|
|
7792
9190
|
}
|
|
9191
|
+
async function connectCloudWithAccount(options) {
|
|
9192
|
+
const { root, authorAccount, ...rest } = options;
|
|
9193
|
+
const signer = await resolveSigner(rest.deviceSecret, rest.signer, "deviceSecret");
|
|
9194
|
+
const resolved = await resolveRoot(root);
|
|
9195
|
+
const cloud = new CloudClient({
|
|
9196
|
+
cloudBaseUrl: options.cloudBaseUrl,
|
|
9197
|
+
// Reported here as well as on the connection's own client: this is where
|
|
9198
|
+
// the session is first minted, and a caller persisting it would otherwise
|
|
9199
|
+
// only hear about the rolling refreshes of a token it never saw.
|
|
9200
|
+
onSession: options.onSession,
|
|
9201
|
+
fetch: options.fetch,
|
|
9202
|
+
timeoutMs: options.timeoutMs
|
|
9203
|
+
});
|
|
9204
|
+
const session = await cloud.signInWithAccount(resolved.signer);
|
|
9205
|
+
return connectCloud({
|
|
9206
|
+
...rest,
|
|
9207
|
+
// Already resolved above, and passing both forms on would be refused.
|
|
9208
|
+
deviceSecret: void 0,
|
|
9209
|
+
signer,
|
|
9210
|
+
sessionToken: session.sessionToken,
|
|
9211
|
+
authorAccount: authorAccount ?? resolved.accountId
|
|
9212
|
+
});
|
|
9213
|
+
}
|
|
7793
9214
|
function pickNamespace(namespaces, requested) {
|
|
7794
9215
|
if (requested) {
|
|
7795
9216
|
const found = namespaces.find((n) => n.namespaceId === requested);
|
|
@@ -7826,8 +9247,8 @@ function explainNoRelay(namespace, relays) {
|
|
|
7826
9247
|
}
|
|
7827
9248
|
return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none is ready to relay yet (a URL, an executor account and a recent heartbeat) \u2014 retry shortly`;
|
|
7828
9249
|
}
|
|
7829
|
-
|
|
7830
|
-
const key = `mero-js:warrant-nonce:${
|
|
9250
|
+
function defaultNonceSource(devicePublicKey) {
|
|
9251
|
+
const key = `mero-js:warrant-nonce:${devicePublicKey}`;
|
|
7831
9252
|
try {
|
|
7832
9253
|
return createLocalStorageNonceSource(key);
|
|
7833
9254
|
} catch {
|
|
@@ -7835,6 +9256,168 @@ async function defaultNonceSource(deviceSecret) {
|
|
|
7835
9256
|
}
|
|
7836
9257
|
}
|
|
7837
9258
|
|
|
9259
|
+
// src/cloud/link-redirect.ts
|
|
9260
|
+
var DEFAULT_CLOUD_WEB_URL = "https://cloud.calimero.network";
|
|
9261
|
+
function cloudLinkUrl(options) {
|
|
9262
|
+
if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
|
|
9263
|
+
throw new Error("accountId must be 64 lowercase hex characters");
|
|
9264
|
+
}
|
|
9265
|
+
const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
|
|
9266
|
+
url.searchParams.set("link-account", options.accountId);
|
|
9267
|
+
url.searchParams.set("callback-url", options.returnTo);
|
|
9268
|
+
return url.toString();
|
|
9269
|
+
}
|
|
9270
|
+
function beginCloudLink(options) {
|
|
9271
|
+
window.location.assign(cloudLinkUrl(options));
|
|
9272
|
+
throw new Error("redirecting to the cloud to authorise this link");
|
|
9273
|
+
}
|
|
9274
|
+
function readCloudLinkCallback(location2 = window.location) {
|
|
9275
|
+
const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
|
|
9276
|
+
const error = params.get("error");
|
|
9277
|
+
if (error) {
|
|
9278
|
+
stripFragment();
|
|
9279
|
+
throw new Error(
|
|
9280
|
+
error === "denied" ? "the account link was declined at the cloud" : `the cloud refused this link: ${error}`
|
|
9281
|
+
);
|
|
9282
|
+
}
|
|
9283
|
+
const grant = params.get("grant");
|
|
9284
|
+
const account = params.get("account");
|
|
9285
|
+
if (!grant || !account) return null;
|
|
9286
|
+
stripFragment();
|
|
9287
|
+
return { grant, account };
|
|
9288
|
+
}
|
|
9289
|
+
function stripFragment() {
|
|
9290
|
+
if (typeof history === "undefined" || !history.replaceState) return;
|
|
9291
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
9292
|
+
}
|
|
9293
|
+
async function completeCloudLink(options) {
|
|
9294
|
+
const root = await resolveRoot(options.root);
|
|
9295
|
+
if (root.accountId !== options.account) {
|
|
9296
|
+
throw new Error(
|
|
9297
|
+
`this grant is for account ${options.account}, but the root here derives ${root.accountId} \u2014 it was minted for a different account`
|
|
9298
|
+
);
|
|
9299
|
+
}
|
|
9300
|
+
const cloud = new CloudClient({
|
|
9301
|
+
cloudBaseUrl: options.cloudBaseUrl,
|
|
9302
|
+
fetch: options.fetch
|
|
9303
|
+
});
|
|
9304
|
+
return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
|
|
9305
|
+
}
|
|
9306
|
+
|
|
9307
|
+
// src/cloud/enrol-redirect.ts
|
|
9308
|
+
var DEVICE_PARAM = "enrol-device";
|
|
9309
|
+
var KEM_PARAM = "enrol-kem";
|
|
9310
|
+
var CALLBACK_PARAM = "callback-url";
|
|
9311
|
+
var STATE_PARAM = "state";
|
|
9312
|
+
function deviceEnrolmentUrl(options) {
|
|
9313
|
+
assertHex(options.devicePublicKey, "devicePublicKey");
|
|
9314
|
+
assertHex(options.kemPublicKey, "kemPublicKey");
|
|
9315
|
+
assertSafeReturnTo(options.returnTo);
|
|
9316
|
+
warnOnForeignCallback(options.returnTo);
|
|
9317
|
+
const url = new URL(options.walletUrl);
|
|
9318
|
+
url.searchParams.set(DEVICE_PARAM, options.devicePublicKey);
|
|
9319
|
+
url.searchParams.set(KEM_PARAM, options.kemPublicKey);
|
|
9320
|
+
url.searchParams.set(CALLBACK_PARAM, options.returnTo);
|
|
9321
|
+
if (options.state) url.searchParams.set(STATE_PARAM, options.state);
|
|
9322
|
+
return url.toString();
|
|
9323
|
+
}
|
|
9324
|
+
function beginDeviceEnrolment(options) {
|
|
9325
|
+
window.location.assign(deviceEnrolmentUrl(options));
|
|
9326
|
+
throw new Error("redirecting to the wallet to approve this device");
|
|
9327
|
+
}
|
|
9328
|
+
function readEnrolmentCallback(location2 = window.location) {
|
|
9329
|
+
const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
|
|
9330
|
+
const error = params.get("error");
|
|
9331
|
+
if (error) {
|
|
9332
|
+
stripFragment2();
|
|
9333
|
+
throw new Error(
|
|
9334
|
+
error === "denied" || error === "cancelled" ? "the device was not approved at the wallet" : `the wallet refused this enrolment: ${error}`
|
|
9335
|
+
);
|
|
9336
|
+
}
|
|
9337
|
+
const credential = params.get("credential");
|
|
9338
|
+
const account = params.get("account");
|
|
9339
|
+
const device = params.get("device");
|
|
9340
|
+
if (!credential || !account || !device) return null;
|
|
9341
|
+
stripFragment2();
|
|
9342
|
+
const state = params.get("state");
|
|
9343
|
+
return { credential, account, device, ...state ? { state } : {} };
|
|
9344
|
+
}
|
|
9345
|
+
function stripFragment2() {
|
|
9346
|
+
if (typeof history === "undefined" || !history.replaceState) return;
|
|
9347
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
9348
|
+
}
|
|
9349
|
+
async function completeDeviceEnrolment(options) {
|
|
9350
|
+
if (options.expectState !== void 0 && options.expectState !== options.state) {
|
|
9351
|
+
throw new Error(
|
|
9352
|
+
"this enrolment answers a request this app did not make \u2014 the state it carries is not the one sent"
|
|
9353
|
+
);
|
|
9354
|
+
}
|
|
9355
|
+
const certificate = await verifyDeviceCredential(options.credential);
|
|
9356
|
+
if (certificate.signPublicKey !== normaliseHex(options.devicePublicKey)) {
|
|
9357
|
+
throw new Error(
|
|
9358
|
+
`this credential certifies device key ${certificate.signPublicKey}, but this app holds ${normaliseHex(options.devicePublicKey)} \u2014 it would sign with a key the certificate does not cover`
|
|
9359
|
+
);
|
|
9360
|
+
}
|
|
9361
|
+
if (options.kemPublicKey !== void 0 && certificate.kemPublicKey !== normaliseHex(options.kemPublicKey)) {
|
|
9362
|
+
throw new Error(
|
|
9363
|
+
`this credential names delivery key ${certificate.kemPublicKey}, not the one this app asked about \u2014 group keys would be sealed to somebody else`
|
|
9364
|
+
);
|
|
9365
|
+
}
|
|
9366
|
+
if (certificate.account !== normaliseHex(options.account)) {
|
|
9367
|
+
throw new Error(
|
|
9368
|
+
`the wallet reported account ${normaliseHex(options.account)}, but the credential names ${certificate.account}`
|
|
9369
|
+
);
|
|
9370
|
+
}
|
|
9371
|
+
if (certificate.device !== normaliseHex(options.device)) {
|
|
9372
|
+
throw new Error(
|
|
9373
|
+
`the wallet reported device ${normaliseHex(options.device)}, but the credential names ${certificate.device}`
|
|
9374
|
+
);
|
|
9375
|
+
}
|
|
9376
|
+
return {
|
|
9377
|
+
credential: options.credential,
|
|
9378
|
+
account: certificate.account,
|
|
9379
|
+
device: certificate.device,
|
|
9380
|
+
certificate
|
|
9381
|
+
};
|
|
9382
|
+
}
|
|
9383
|
+
function assertHex(value, label2) {
|
|
9384
|
+
if (!/^[0-9a-f]{64}$/.test(value)) {
|
|
9385
|
+
throw new Error(`${label2} must be 64 lowercase hex characters`);
|
|
9386
|
+
}
|
|
9387
|
+
}
|
|
9388
|
+
function normaliseHex(value) {
|
|
9389
|
+
return value.trim().toLowerCase();
|
|
9390
|
+
}
|
|
9391
|
+
function callbackIsForeign(returnTo, pageOrigin) {
|
|
9392
|
+
const here = pageOrigin ?? (typeof window === "undefined" ? void 0 : window.location.origin);
|
|
9393
|
+
if (!here) return false;
|
|
9394
|
+
try {
|
|
9395
|
+
return new URL(returnTo).origin !== here;
|
|
9396
|
+
} catch {
|
|
9397
|
+
return false;
|
|
9398
|
+
}
|
|
9399
|
+
}
|
|
9400
|
+
function warnOnForeignCallback(returnTo) {
|
|
9401
|
+
if (!callbackIsForeign(returnTo)) return;
|
|
9402
|
+
console.warn(
|
|
9403
|
+
`[calimero] enrolling a device but sending the credential to ${new URL(returnTo).origin}, which is not this page's origin. Legitimate when deliberate; worth a second look otherwise.`
|
|
9404
|
+
);
|
|
9405
|
+
}
|
|
9406
|
+
function assertSafeReturnTo(returnTo) {
|
|
9407
|
+
let url;
|
|
9408
|
+
try {
|
|
9409
|
+
url = new URL(returnTo);
|
|
9410
|
+
} catch {
|
|
9411
|
+
throw new Error(`returnTo must be an absolute URL, got ${returnTo}`);
|
|
9412
|
+
}
|
|
9413
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
9414
|
+
if (url.protocol === "https:") return;
|
|
9415
|
+
if (url.protocol === "http:" && loopback) return;
|
|
9416
|
+
throw new Error(
|
|
9417
|
+
`returnTo must be https:, or http: on localhost \u2014 got ${url.protocol}//${url.hostname}`
|
|
9418
|
+
);
|
|
9419
|
+
}
|
|
9420
|
+
|
|
7838
9421
|
// src/sealed/verify.ts
|
|
7839
9422
|
var MEASUREMENT_HEX = /^[0-9a-f]{96}$/;
|
|
7840
9423
|
var REGISTERS = ["mrtd", "rtmr0", "rtmr1", "rtmr2", "rtmr3"];
|
|
@@ -8203,7 +9786,7 @@ var FULCIO_ISSUER_V1 = "1.3.6.1.4.1.57264.1.1";
|
|
|
8203
9786
|
var SAN_URI = 134;
|
|
8204
9787
|
var UTF8_STRING = 12;
|
|
8205
9788
|
var SEQUENCE2 = 48;
|
|
8206
|
-
var
|
|
9789
|
+
var utf83 = new TextEncoder();
|
|
8207
9790
|
var fromUtf8 = (bytes) => new TextDecoder().decode(bytes);
|
|
8208
9791
|
async function verifyCosignBundle(body, bundle, signer, signature) {
|
|
8209
9792
|
const { base64Signature, cert, rekorBundle } = cosignBundle(bundle);
|
|
@@ -8216,7 +9799,7 @@ async function verifyCosignBundle(body, bundle, signer, signature) {
|
|
|
8216
9799
|
const leaf = parseCertificate(leafDer);
|
|
8217
9800
|
const log = REKOR_LOGS.find((candidate) => candidate.logId === payload.logID.toLowerCase());
|
|
8218
9801
|
if (!log) throw new Error(`The bundle names Rekor log ${payload.logID}, which the Sigstore trust root does not know`);
|
|
8219
|
-
const canonical =
|
|
9802
|
+
const canonical = utf83.encode(
|
|
8220
9803
|
`{"body":${JSON.stringify(payload.body)},"integratedTime":${payload.integratedTime},"logID":${JSON.stringify(payload.logID)},"logIndex":${payload.logIndex}}`
|
|
8221
9804
|
);
|
|
8222
9805
|
const setVerifies = await verifyEcdsa(
|
|
@@ -8454,162 +10037,70 @@ function createSignedReleaseVerifier(options) {
|
|
|
8454
10037
|
minReleaseVersion: options.minReleaseVersion,
|
|
8455
10038
|
identity: options.identity,
|
|
8456
10039
|
issuer: options.issuer
|
|
8457
|
-
});
|
|
8458
|
-
return createQuoteVerifier({
|
|
8459
|
-
dcapVerify: options.dcapVerify,
|
|
8460
|
-
fetchCollateral: options.fetchCollateral,
|
|
8461
|
-
now: options.now,
|
|
8462
|
-
...trusted
|
|
8463
|
-
})(args);
|
|
8464
|
-
};
|
|
8465
|
-
return Object.assign(verifier, { includeCollateral: true });
|
|
8466
|
-
}
|
|
8467
|
-
function createSignedReleaseSealedFetch(options) {
|
|
8468
|
-
const {
|
|
8469
|
-
baseUrl,
|
|
8470
|
-
releaseSource,
|
|
8471
|
-
releaseVersion: fallbackVersion,
|
|
8472
|
-
applicationId,
|
|
8473
|
-
applicationHash,
|
|
8474
|
-
fetch: givenFetch,
|
|
8475
|
-
...verifierOptions
|
|
8476
|
-
} = options;
|
|
8477
|
-
if (fallbackVersion !== void 0) releaseVersion(fallbackVersion, "releaseVersion");
|
|
8478
|
-
const baseFetch = givenFetch ?? ((input, init) => fetch(input, init));
|
|
8479
|
-
const release = async () => {
|
|
8480
|
-
const version = fallbackVersion === void 0 ? await fetchNodeReleaseVersion(baseUrl, { fetch: baseFetch }) : await readNodeReleaseVersion(baseUrl, baseFetch) ?? fallbackVersion;
|
|
8481
|
-
return typeof releaseSource === "function" ? releaseSource({ baseUrl, version, fetch: baseFetch }) : fetchNodeRelease(cloudNodeReleaseUrl(releaseSource ?? DEFAULT_RELEASE_MIRROR, version), { fetch: baseFetch });
|
|
8482
|
-
};
|
|
8483
|
-
return createAttestedSealedFetch({
|
|
8484
|
-
baseUrl,
|
|
8485
|
-
verify: createSignedReleaseVerifier({ ...verifierOptions, release }),
|
|
8486
|
-
applicationId,
|
|
8487
|
-
applicationHash,
|
|
8488
|
-
fetch: baseFetch
|
|
8489
|
-
});
|
|
8490
|
-
}
|
|
8491
|
-
var RELEASE_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
8492
|
-
function releaseVersion(value, what) {
|
|
8493
|
-
const match = typeof value === "string" ? RELEASE_VERSION.exec(value) : null;
|
|
8494
|
-
const pre = match?.[4];
|
|
8495
|
-
if (!match || pre !== void 0 && pre.split(".").some((id) => id === "")) {
|
|
8496
|
-
throw new Error(`${what} is ${JSON.stringify(value)}, not a release version like 2.3.87`);
|
|
8497
|
-
}
|
|
8498
|
-
return value;
|
|
8499
|
-
}
|
|
8500
|
-
function compareReleaseVersions(a, b) {
|
|
8501
|
-
const [aCore, aPre] = splitVersion2(a);
|
|
8502
|
-
const [bCore, bPre] = splitVersion2(b);
|
|
8503
|
-
for (let i = 0; i < 3; i += 1) {
|
|
8504
|
-
if (aCore[i] !== bCore[i]) return aCore[i] < bCore[i] ? -1 : 1;
|
|
8505
|
-
}
|
|
8506
|
-
if (aPre === void 0 || bPre === void 0) return aPre === bPre ? 0 : aPre === void 0 ? 1 : -1;
|
|
8507
|
-
const aIds = aPre.split(".");
|
|
8508
|
-
const bIds = bPre.split(".");
|
|
8509
|
-
for (let i = 0; i < Math.max(aIds.length, bIds.length); i += 1) {
|
|
8510
|
-
const [x, y] = [aIds[i], bIds[i]];
|
|
8511
|
-
if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
|
|
8512
|
-
const [xNum, yNum] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
8513
|
-
const order = xNum && yNum ? Math.sign(Number(x) - Number(y)) : xNum !== yNum ? xNum ? -1 : 1 : x < y ? -1 : x > y ? 1 : 0;
|
|
8514
|
-
if (order !== 0) return order;
|
|
8515
|
-
}
|
|
8516
|
-
return 0;
|
|
8517
|
-
}
|
|
8518
|
-
function splitVersion2(version) {
|
|
8519
|
-
const match = RELEASE_VERSION.exec(version);
|
|
8520
|
-
return [[Number(match[1]), Number(match[2]), Number(match[3])], match[4]];
|
|
8521
|
-
}
|
|
8522
|
-
|
|
8523
|
-
// src/warrant/governance-op.ts
|
|
8524
|
-
var ROLE = {
|
|
8525
|
-
Admin: 0,
|
|
8526
|
-
Member: 1,
|
|
8527
|
-
ReadOnly: 2
|
|
8528
|
-
};
|
|
8529
|
-
var GROUP_OP = {
|
|
8530
|
-
MemberAdded: 1,
|
|
8531
|
-
MemberRemoved: 2,
|
|
8532
|
-
MemberLeft: 3,
|
|
8533
|
-
MemberRoleSet: 4
|
|
8534
|
-
};
|
|
8535
|
-
var ROOT_OP = {
|
|
8536
|
-
GroupCreated: 0,
|
|
8537
|
-
GroupReparented: 1,
|
|
8538
|
-
GroupDeleted: 2
|
|
8539
|
-
};
|
|
8540
|
-
var CLEARED_HASH = new Uint8Array(32);
|
|
8541
|
-
var EMPTY_VEC = u32le(0);
|
|
8542
|
-
function role(value) {
|
|
8543
|
-
const byte = ROLE[value];
|
|
8544
|
-
if (byte === void 0) {
|
|
8545
|
-
throw new Error(`role must be Admin, Member or ReadOnly, got ${String(value)}`);
|
|
8546
|
-
}
|
|
8547
|
-
return new Uint8Array([byte]);
|
|
8548
|
-
}
|
|
8549
|
-
function tag(byte) {
|
|
8550
|
-
return new Uint8Array([byte]);
|
|
8551
|
-
}
|
|
8552
|
-
function memberAddedOp(member, memberRole) {
|
|
8553
|
-
return {
|
|
8554
|
-
kind: "group",
|
|
8555
|
-
bytes: concat(tag(GROUP_OP.MemberAdded), fromHex(member, "member", 32), role(memberRole))
|
|
8556
|
-
};
|
|
8557
|
-
}
|
|
8558
|
-
function memberRemovedOp(member) {
|
|
8559
|
-
return {
|
|
8560
|
-
kind: "group",
|
|
8561
|
-
bytes: concat(
|
|
8562
|
-
tag(GROUP_OP.MemberRemoved),
|
|
8563
|
-
fromHex(member, "member", 32),
|
|
8564
|
-
CLEARED_HASH,
|
|
8565
|
-
EMPTY_VEC
|
|
8566
|
-
)
|
|
8567
|
-
};
|
|
8568
|
-
}
|
|
8569
|
-
function memberLeftOp(member) {
|
|
8570
|
-
return {
|
|
8571
|
-
kind: "group",
|
|
8572
|
-
bytes: concat(
|
|
8573
|
-
tag(GROUP_OP.MemberLeft),
|
|
8574
|
-
fromHex(member, "member", 32),
|
|
8575
|
-
CLEARED_HASH,
|
|
8576
|
-
EMPTY_VEC
|
|
8577
|
-
)
|
|
8578
|
-
};
|
|
8579
|
-
}
|
|
8580
|
-
function memberRoleSetOp(member, memberRole) {
|
|
8581
|
-
return {
|
|
8582
|
-
kind: "group",
|
|
8583
|
-
bytes: concat(tag(GROUP_OP.MemberRoleSet), fromHex(member, "member", 32), role(memberRole))
|
|
8584
|
-
};
|
|
8585
|
-
}
|
|
8586
|
-
function groupCreatedOp(input) {
|
|
8587
|
-
return {
|
|
8588
|
-
kind: "root",
|
|
8589
|
-
bytes: concat(
|
|
8590
|
-
tag(ROOT_OP.GroupCreated),
|
|
8591
|
-
fromHex(input.groupId, "groupId", 32),
|
|
8592
|
-
fromHex(input.parentId, "parentId", 32),
|
|
8593
|
-
tag(input.restricted ? 1 : 0),
|
|
8594
|
-
fromHex(input.admin, "admin", 32)
|
|
8595
|
-
)
|
|
10040
|
+
});
|
|
10041
|
+
return createQuoteVerifier({
|
|
10042
|
+
dcapVerify: options.dcapVerify,
|
|
10043
|
+
fetchCollateral: options.fetchCollateral,
|
|
10044
|
+
now: options.now,
|
|
10045
|
+
...trusted
|
|
10046
|
+
})(args);
|
|
8596
10047
|
};
|
|
10048
|
+
return Object.assign(verifier, { includeCollateral: true });
|
|
8597
10049
|
}
|
|
8598
|
-
function
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
10050
|
+
function createSignedReleaseSealedFetch(options) {
|
|
10051
|
+
const {
|
|
10052
|
+
baseUrl,
|
|
10053
|
+
releaseSource,
|
|
10054
|
+
releaseVersion: fallbackVersion,
|
|
10055
|
+
applicationId,
|
|
10056
|
+
applicationHash,
|
|
10057
|
+
fetch: givenFetch,
|
|
10058
|
+
...verifierOptions
|
|
10059
|
+
} = options;
|
|
10060
|
+
if (fallbackVersion !== void 0) releaseVersion(fallbackVersion, "releaseVersion");
|
|
10061
|
+
const baseFetch = givenFetch ?? ((input, init) => fetch(input, init));
|
|
10062
|
+
const release = async () => {
|
|
10063
|
+
const version = fallbackVersion === void 0 ? await fetchNodeReleaseVersion(baseUrl, { fetch: baseFetch }) : await readNodeReleaseVersion(baseUrl, baseFetch) ?? fallbackVersion;
|
|
10064
|
+
return typeof releaseSource === "function" ? releaseSource({ baseUrl, version, fetch: baseFetch }) : fetchNodeRelease(cloudNodeReleaseUrl(releaseSource ?? DEFAULT_RELEASE_MIRROR, version), { fetch: baseFetch });
|
|
8606
10065
|
};
|
|
10066
|
+
return createAttestedSealedFetch({
|
|
10067
|
+
baseUrl,
|
|
10068
|
+
verify: createSignedReleaseVerifier({ ...verifierOptions, release }),
|
|
10069
|
+
applicationId,
|
|
10070
|
+
applicationHash,
|
|
10071
|
+
fetch: baseFetch
|
|
10072
|
+
});
|
|
8607
10073
|
}
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
|
|
10074
|
+
var RELEASE_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
10075
|
+
function releaseVersion(value, what) {
|
|
10076
|
+
const match = typeof value === "string" ? RELEASE_VERSION.exec(value) : null;
|
|
10077
|
+
const pre = match?.[4];
|
|
10078
|
+
if (!match || pre !== void 0 && pre.split(".").some((id) => id === "")) {
|
|
10079
|
+
throw new Error(`${what} is ${JSON.stringify(value)}, not a release version like 2.3.87`);
|
|
10080
|
+
}
|
|
10081
|
+
return value;
|
|
10082
|
+
}
|
|
10083
|
+
function compareReleaseVersions(a, b) {
|
|
10084
|
+
const [aCore, aPre] = splitVersion2(a);
|
|
10085
|
+
const [bCore, bPre] = splitVersion2(b);
|
|
10086
|
+
for (let i = 0; i < 3; i += 1) {
|
|
10087
|
+
if (aCore[i] !== bCore[i]) return aCore[i] < bCore[i] ? -1 : 1;
|
|
10088
|
+
}
|
|
10089
|
+
if (aPre === void 0 || bPre === void 0) return aPre === bPre ? 0 : aPre === void 0 ? 1 : -1;
|
|
10090
|
+
const aIds = aPre.split(".");
|
|
10091
|
+
const bIds = bPre.split(".");
|
|
10092
|
+
for (let i = 0; i < Math.max(aIds.length, bIds.length); i += 1) {
|
|
10093
|
+
const [x, y] = [aIds[i], bIds[i]];
|
|
10094
|
+
if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
|
|
10095
|
+
const [xNum, yNum] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
10096
|
+
const order = xNum && yNum ? Math.sign(Number(x) - Number(y)) : xNum !== yNum ? xNum ? -1 : 1 : x < y ? -1 : x > y ? 1 : 0;
|
|
10097
|
+
if (order !== 0) return order;
|
|
10098
|
+
}
|
|
10099
|
+
return 0;
|
|
10100
|
+
}
|
|
10101
|
+
function splitVersion2(version) {
|
|
10102
|
+
const match = RELEASE_VERSION.exec(version);
|
|
10103
|
+
return [[Number(match[1]), Number(match[2]), Number(match[3])], match[4]];
|
|
8613
10104
|
}
|
|
8614
10105
|
|
|
8615
10106
|
// src/namespace-op/namespace-op.ts
|
|
@@ -8703,186 +10194,22 @@ async function signMemberJoinOp(input) {
|
|
|
8703
10194
|
credential
|
|
8704
10195
|
);
|
|
8705
10196
|
const op = concat(new Uint8Array([NAMESPACE_OP_ROOT]), rootOp);
|
|
8706
|
-
const
|
|
8707
|
-
const
|
|
10197
|
+
const signingKey = await resolveSigner(input.deviceSecret, input.signer, "deviceSecret");
|
|
10198
|
+
const signerPublicKey = fromHex(signingKey.publicKey, "signer.publicKey", 32);
|
|
8708
10199
|
const signable = concat(
|
|
8709
10200
|
new Uint8Array([SIGNED_NAMESPACE_OP_SCHEMA_VERSION]),
|
|
8710
10201
|
fromHex(input.namespaceId, "namespaceId", 32),
|
|
8711
10202
|
u32le(parents.length),
|
|
8712
10203
|
...parents.map((p, i) => fromHex(p, `parentOpHashes[${i}]`, 32)),
|
|
8713
|
-
|
|
10204
|
+
signerPublicKey,
|
|
8714
10205
|
u64le(input.nonce),
|
|
8715
10206
|
op
|
|
8716
10207
|
);
|
|
8717
|
-
const signature =
|
|
8718
|
-
await crypto.subtle.sign("Ed25519", key, concat(NAMESPACE_SIGN_DOMAIN, signable))
|
|
8719
|
-
);
|
|
10208
|
+
const signature = await signingKey.sign(concat(NAMESPACE_SIGN_DOMAIN, signable));
|
|
8720
10209
|
const unendorsed = new Uint8Array([0]);
|
|
8721
10210
|
return hex(concat(signable, signature, unendorsed));
|
|
8722
10211
|
}
|
|
8723
10212
|
|
|
8724
|
-
// src/login/login.ts
|
|
8725
|
-
var SIGN_DOMAIN5 = new TextEncoder().encode("calimero.auth.login.v1");
|
|
8726
|
-
var AUDIENCE_TAG = {
|
|
8727
|
-
webOrigin: 0,
|
|
8728
|
-
codeSigningId: 1,
|
|
8729
|
-
cli: 2
|
|
8730
|
-
};
|
|
8731
|
-
function audienceBody(audience) {
|
|
8732
|
-
const encoder = new TextEncoder();
|
|
8733
|
-
switch (audience.kind) {
|
|
8734
|
-
case "webOrigin":
|
|
8735
|
-
return encoder.encode(audience.origin);
|
|
8736
|
-
case "codeSigningId":
|
|
8737
|
-
return encoder.encode(audience.id);
|
|
8738
|
-
case "cli":
|
|
8739
|
-
return new Uint8Array(0);
|
|
8740
|
-
}
|
|
8741
|
-
}
|
|
8742
|
-
function audienceSigningBytes(audience) {
|
|
8743
|
-
return concat(
|
|
8744
|
-
Uint8Array.from([AUDIENCE_TAG[audience.kind]]),
|
|
8745
|
-
audienceBody(audience)
|
|
8746
|
-
);
|
|
8747
|
-
}
|
|
8748
|
-
function audienceWire(audience) {
|
|
8749
|
-
const tag2 = Uint8Array.from([AUDIENCE_TAG[audience.kind]]);
|
|
8750
|
-
if (audience.kind === "cli") {
|
|
8751
|
-
return tag2;
|
|
8752
|
-
}
|
|
8753
|
-
const body = audienceBody(audience);
|
|
8754
|
-
return concat(tag2, u32le(body.length), body);
|
|
8755
|
-
}
|
|
8756
|
-
async function signLoginStatement(input) {
|
|
8757
|
-
const node = fromHex(input.node, "node", 32);
|
|
8758
|
-
const challenge = fromHex(input.challenge, "challenge", 32);
|
|
8759
|
-
const sessionKey = fromHex(input.sessionKey, "sessionKey", 32);
|
|
8760
|
-
const key = await importSigningKey(input.deviceSecret);
|
|
8761
|
-
const deviceKey = await derivePublicKey(input.deviceSecret);
|
|
8762
|
-
const issuedAt = u64le(input.issuedAt);
|
|
8763
|
-
const expiresAt = u64le(input.expiresAt);
|
|
8764
|
-
const preimage = await domainHash(SIGN_DOMAIN5, [
|
|
8765
|
-
node,
|
|
8766
|
-
audienceSigningBytes(input.audience),
|
|
8767
|
-
challenge,
|
|
8768
|
-
sessionKey,
|
|
8769
|
-
deviceKey,
|
|
8770
|
-
issuedAt,
|
|
8771
|
-
expiresAt
|
|
8772
|
-
]);
|
|
8773
|
-
const signature = new Uint8Array(
|
|
8774
|
-
await crypto.subtle.sign({ name: "Ed25519" }, key, preimage)
|
|
8775
|
-
);
|
|
8776
|
-
return hex(
|
|
8777
|
-
concat(
|
|
8778
|
-
node,
|
|
8779
|
-
audienceWire(input.audience),
|
|
8780
|
-
challenge,
|
|
8781
|
-
sessionKey,
|
|
8782
|
-
deviceKey,
|
|
8783
|
-
issuedAt,
|
|
8784
|
-
expiresAt,
|
|
8785
|
-
signature
|
|
8786
|
-
)
|
|
8787
|
-
);
|
|
8788
|
-
}
|
|
8789
|
-
|
|
8790
|
-
// src/login/session.ts
|
|
8791
|
-
var DEFAULT_TTL_SECONDS2 = 300;
|
|
8792
|
-
async function generateSessionKey() {
|
|
8793
|
-
const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
8794
|
-
return { publicKey: hex(await derivePublicKey(secret)), secret };
|
|
8795
|
-
}
|
|
8796
|
-
function audienceLabel(audience) {
|
|
8797
|
-
switch (audience.kind) {
|
|
8798
|
-
case "webOrigin":
|
|
8799
|
-
return audience.origin;
|
|
8800
|
-
case "codeSigningId":
|
|
8801
|
-
return audience.id;
|
|
8802
|
-
case "cli":
|
|
8803
|
-
return "cli";
|
|
8804
|
-
}
|
|
8805
|
-
}
|
|
8806
|
-
async function login(config) {
|
|
8807
|
-
const baseUrl = config.nodeUrl.replace(/\/+$/, "");
|
|
8808
|
-
const timeoutMs = config.timeoutMs ?? 1e4;
|
|
8809
|
-
const request = async (path, init) => {
|
|
8810
|
-
const controller = new AbortController();
|
|
8811
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
8812
|
-
let response;
|
|
8813
|
-
try {
|
|
8814
|
-
const requestInit = { ...init, signal: controller.signal };
|
|
8815
|
-
response = config.fetch ? await config.fetch(`${baseUrl}${path}`, requestInit) : await globalThis.fetch(`${baseUrl}${path}`, requestInit);
|
|
8816
|
-
} catch (err) {
|
|
8817
|
-
throw new HTTPError(
|
|
8818
|
-
0,
|
|
8819
|
-
err instanceof Error ? err.name : "NetworkError",
|
|
8820
|
-
`${baseUrl}${path}`,
|
|
8821
|
-
new Headers(),
|
|
8822
|
-
err instanceof Error ? err.message : String(err)
|
|
8823
|
-
);
|
|
8824
|
-
} finally {
|
|
8825
|
-
clearTimeout(timer);
|
|
8826
|
-
}
|
|
8827
|
-
const text = await response.text().catch(() => "");
|
|
8828
|
-
if (!response.ok) {
|
|
8829
|
-
throw new HTTPError(
|
|
8830
|
-
response.status,
|
|
8831
|
-
response.statusText,
|
|
8832
|
-
`${baseUrl}${path}`,
|
|
8833
|
-
response.headers,
|
|
8834
|
-
text
|
|
8835
|
-
);
|
|
8836
|
-
}
|
|
8837
|
-
return text ? JSON.parse(text) : {};
|
|
8838
|
-
};
|
|
8839
|
-
const challengeBody = await request("/auth/challenge", {
|
|
8840
|
-
method: "GET",
|
|
8841
|
-
headers: { Accept: "application/json" }
|
|
8842
|
-
});
|
|
8843
|
-
const challenge = challengeBody.challenge ?? challengeBody.data?.challenge;
|
|
8844
|
-
if (!challenge) {
|
|
8845
|
-
throw new Error("the node issued no challenge");
|
|
8846
|
-
}
|
|
8847
|
-
const session = await generateSessionKey();
|
|
8848
|
-
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
8849
|
-
const statement = await signLoginStatement({
|
|
8850
|
-
node: config.node,
|
|
8851
|
-
audience: config.audience,
|
|
8852
|
-
challenge,
|
|
8853
|
-
sessionKey: session.publicKey,
|
|
8854
|
-
issuedAt,
|
|
8855
|
-
expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS2),
|
|
8856
|
-
deviceSecret: config.deviceSecret
|
|
8857
|
-
});
|
|
8858
|
-
const tokenBody = await request("/auth/token", {
|
|
8859
|
-
method: "POST",
|
|
8860
|
-
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
8861
|
-
body: JSON.stringify({
|
|
8862
|
-
auth_method: "account_proof",
|
|
8863
|
-
public_key: session.publicKey,
|
|
8864
|
-
client_name: config.clientName ?? audienceLabel(config.audience),
|
|
8865
|
-
timestamp: issuedAt,
|
|
8866
|
-
provider_data: {
|
|
8867
|
-
challenge,
|
|
8868
|
-
login_statement: statement,
|
|
8869
|
-
account_proof: config.accountProof
|
|
8870
|
-
}
|
|
8871
|
-
})
|
|
8872
|
-
});
|
|
8873
|
-
const accessToken = tokenBody.access_token ?? tokenBody.data?.access_token;
|
|
8874
|
-
const refreshToken = tokenBody.refresh_token ?? tokenBody.data?.refresh_token;
|
|
8875
|
-
if (!accessToken) {
|
|
8876
|
-
throw new Error("the node minted no session");
|
|
8877
|
-
}
|
|
8878
|
-
return {
|
|
8879
|
-
accessToken,
|
|
8880
|
-
refreshToken: refreshToken ?? "",
|
|
8881
|
-
sessionKey: session.publicKey,
|
|
8882
|
-
sessionSecret: session.secret
|
|
8883
|
-
};
|
|
8884
|
-
}
|
|
8885
|
-
|
|
8886
10213
|
// src/nodeDiscovery.ts
|
|
8887
10214
|
var DEFAULT_LOCAL_NODE_PORTS = [2428, 2429, 2528, 2529];
|
|
8888
10215
|
var LOCAL_HOST = "localhost";
|
|
@@ -8935,11 +10262,108 @@ async function discoverLocalNodes(options = {}) {
|
|
|
8935
10262
|
);
|
|
8936
10263
|
return results.filter((url) => url !== null);
|
|
8937
10264
|
}
|
|
10265
|
+
|
|
10266
|
+
// src/relay-attestation/attest-node-key.ts
|
|
10267
|
+
var KEY_BINDING_DOMAIN = new TextEncoder().encode("calimero.tee-attest.key-binding.v1");
|
|
10268
|
+
var MOCK_QUOTE_HEADER = new TextEncoder().encode("MOCK_TDX_QUOTE_V1");
|
|
10269
|
+
var TDX_REPORT_DATA_OFFSET = 48 + 520;
|
|
10270
|
+
function concat2(...parts) {
|
|
10271
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
10272
|
+
let at = 0;
|
|
10273
|
+
for (const p of parts) {
|
|
10274
|
+
out.set(p, at);
|
|
10275
|
+
at += p.length;
|
|
10276
|
+
}
|
|
10277
|
+
return out;
|
|
10278
|
+
}
|
|
10279
|
+
function equal2(a, b) {
|
|
10280
|
+
if (a.length !== b.length) return false;
|
|
10281
|
+
let diff = 0;
|
|
10282
|
+
for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
|
|
10283
|
+
return diff === 0;
|
|
10284
|
+
}
|
|
10285
|
+
function fromBase643(b64) {
|
|
10286
|
+
const bin = atob(b64);
|
|
10287
|
+
const out = new Uint8Array(bin.length);
|
|
10288
|
+
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
|
10289
|
+
return out;
|
|
10290
|
+
}
|
|
10291
|
+
function fromHex32(value, label2) {
|
|
10292
|
+
if (typeof value !== "string" || !/^[0-9a-fA-F]{64}$/.test(value)) {
|
|
10293
|
+
throw new Error(`${label2} must be 64 hex characters`);
|
|
10294
|
+
}
|
|
10295
|
+
const out = new Uint8Array(32);
|
|
10296
|
+
for (let i = 0; i < 32; i += 1) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);
|
|
10297
|
+
return out;
|
|
10298
|
+
}
|
|
10299
|
+
async function attestKeyBinding(nodeKey) {
|
|
10300
|
+
const digest = await crypto.subtle.digest(
|
|
10301
|
+
"SHA-256",
|
|
10302
|
+
concat2(KEY_BINDING_DOMAIN, new Uint8Array(32), nodeKey)
|
|
10303
|
+
);
|
|
10304
|
+
return new Uint8Array(digest);
|
|
10305
|
+
}
|
|
10306
|
+
function reportDataOf(quote) {
|
|
10307
|
+
const mock = quote.length >= MOCK_QUOTE_HEADER.length && equal2(quote.subarray(0, MOCK_QUOTE_HEADER.length), MOCK_QUOTE_HEADER);
|
|
10308
|
+
const at = mock ? MOCK_QUOTE_HEADER.length : TDX_REPORT_DATA_OFFSET;
|
|
10309
|
+
if (quote.length < at + 64) {
|
|
10310
|
+
throw new Error(`the quote is too short to carry report data (${quote.length} bytes)`);
|
|
10311
|
+
}
|
|
10312
|
+
return { reportData: quote.slice(at, at + 64), mock };
|
|
10313
|
+
}
|
|
10314
|
+
async function attestRelayNodeKey(opts) {
|
|
10315
|
+
const nonce2 = crypto.getRandomValues(new Uint8Array(32));
|
|
10316
|
+
const url = `${opts.relayUrl.replace(/\/+$/, "")}/admin-api/tee/attest`;
|
|
10317
|
+
const body = JSON.stringify({
|
|
10318
|
+
nonce: hex(nonce2),
|
|
10319
|
+
bindNodeKey: true,
|
|
10320
|
+
...opts.verify?.includeCollateral ? { includeCollateral: true } : {}
|
|
10321
|
+
});
|
|
10322
|
+
const init = { method: "POST", headers: { "Content-Type": "application/json" }, body };
|
|
10323
|
+
const response = opts.fetch ? await opts.fetch(url, init) : await globalThis.fetch(url, init);
|
|
10324
|
+
if (!response.ok) {
|
|
10325
|
+
const text = await response.text().catch(() => "");
|
|
10326
|
+
throw new Error(`the relay would not attest (HTTP ${response.status})${text ? `: ${text}` : ""}`);
|
|
10327
|
+
}
|
|
10328
|
+
const parsed = await response.json();
|
|
10329
|
+
const data = parsed.data ?? parsed;
|
|
10330
|
+
if (typeof data.quoteB64 !== "string") throw new Error("the relay returned no quote");
|
|
10331
|
+
const nodeKey = fromHex32(data.boundPublicKey, "boundPublicKey");
|
|
10332
|
+
const quote = fromBase643(data.quoteB64);
|
|
10333
|
+
const { reportData, mock } = reportDataOf(quote);
|
|
10334
|
+
if (mock) {
|
|
10335
|
+
if (!opts.allowMock) {
|
|
10336
|
+
throw new Error("the relay answered with a MOCK quote, which proves nothing about the hardware");
|
|
10337
|
+
}
|
|
10338
|
+
} else if (opts.verify) {
|
|
10339
|
+
const accepted = await opts.verify({
|
|
10340
|
+
quoteB64: data.quoteB64,
|
|
10341
|
+
nonce: hex(nonce2),
|
|
10342
|
+
reportDataSuffix: hex(await attestKeyBinding(nodeKey)),
|
|
10343
|
+
...data.collateral ? { collateral: data.collateral } : {}
|
|
10344
|
+
});
|
|
10345
|
+
if (!accepted) {
|
|
10346
|
+
throw new Error("the relay's quote did not verify: not a trusted image, or not bound to this request");
|
|
10347
|
+
}
|
|
10348
|
+
} else if (opts.verifyQuote) {
|
|
10349
|
+
await opts.verifyQuote(quote);
|
|
10350
|
+
} else {
|
|
10351
|
+
throw new Error("a real TDX quote needs a verify (or verifyQuote) to check its signature and measurements");
|
|
10352
|
+
}
|
|
10353
|
+
if (!equal2(reportData.subarray(0, 32), nonce2)) {
|
|
10354
|
+
throw new Error("the quote does not carry our nonce: it was not made for this request");
|
|
10355
|
+
}
|
|
10356
|
+
if (!equal2(reportData.subarray(32, 64), await attestKeyBinding(nodeKey))) {
|
|
10357
|
+
throw new Error("the quote does not bind the key the relay named: refusing it");
|
|
10358
|
+
}
|
|
10359
|
+
return { nodeKey: hex(nodeKey), mock };
|
|
10360
|
+
}
|
|
8938
10361
|
export {
|
|
8939
10362
|
AdminApiClient,
|
|
8940
10363
|
AuthApiClient,
|
|
8941
10364
|
AuthRevokedError,
|
|
8942
10365
|
CAPABILITIES,
|
|
10366
|
+
CAPABILITY_PRESETS,
|
|
8943
10367
|
CloudClient,
|
|
8944
10368
|
DEFAULT_LOCAL_NODE_PORTS,
|
|
8945
10369
|
DEFAULT_RELEASE_MIRROR,
|
|
@@ -8949,9 +10373,13 @@ export {
|
|
|
8949
10373
|
IntentRefusedError,
|
|
8950
10374
|
LocalStorageTokenStore,
|
|
8951
10375
|
MemoryTokenStore,
|
|
10376
|
+
MeroClient,
|
|
8952
10377
|
MeroJs,
|
|
8953
10378
|
NODE_RELEASE_SIGNER,
|
|
10379
|
+
REQUEST_PROOF_HEADER,
|
|
8954
10380
|
RelayClient,
|
|
10381
|
+
RelayObserver,
|
|
10382
|
+
RelayTransport,
|
|
8955
10383
|
RpcClient,
|
|
8956
10384
|
RpcError,
|
|
8957
10385
|
SEALED_CONTENT_TYPE,
|
|
@@ -8960,19 +10388,36 @@ export {
|
|
|
8960
10388
|
SealedTransportError,
|
|
8961
10389
|
SseClient,
|
|
8962
10390
|
StaleTransportKeyError,
|
|
10391
|
+
WarrantNonceExhaustedError,
|
|
10392
|
+
WarrantNonceRouteUnavailableError,
|
|
8963
10393
|
WebHttpClient,
|
|
8964
10394
|
WsClient,
|
|
8965
10395
|
accountForRoot,
|
|
10396
|
+
accountForRootPublicKey,
|
|
8966
10397
|
accountRootFromPhrase,
|
|
8967
10398
|
accountRootFromSecret,
|
|
10399
|
+
accountRootSignerFromPhrase,
|
|
8968
10400
|
admitterAddrsFromNetworkStatus,
|
|
10401
|
+
attestKeyBinding,
|
|
10402
|
+
attestRelayNodeKey,
|
|
10403
|
+
authorNonceLookup,
|
|
10404
|
+
beginCloudLink,
|
|
10405
|
+
beginDeviceEnrolment,
|
|
8969
10406
|
buildAuthLoginUrl,
|
|
10407
|
+
callbackIsForeign,
|
|
8970
10408
|
callerProof,
|
|
10409
|
+
capMask,
|
|
10410
|
+
capNames,
|
|
8971
10411
|
classifyError,
|
|
10412
|
+
cloudLinkUrl,
|
|
8972
10413
|
cloudNodeReleaseUrl,
|
|
8973
10414
|
combineSignals,
|
|
8974
10415
|
compareSemver,
|
|
10416
|
+
completeCloudLink,
|
|
10417
|
+
completeDeviceEnrolment,
|
|
8975
10418
|
connectCloud,
|
|
10419
|
+
connectCloudWithAccount,
|
|
10420
|
+
createAccountRootSigner,
|
|
8976
10421
|
createAdminApiClient,
|
|
8977
10422
|
createAdminApiClientFromHttpClient,
|
|
8978
10423
|
createAttestedSealedFetch,
|
|
@@ -8984,12 +10429,15 @@ export {
|
|
|
8984
10429
|
createHttpClient,
|
|
8985
10430
|
createLocalStorageNonceSource,
|
|
8986
10431
|
createMemoryNonceSource,
|
|
10432
|
+
createMeroClient,
|
|
8987
10433
|
createMeroJs,
|
|
8988
10434
|
createNodeAdminApiClient,
|
|
8989
10435
|
createNodeAuthApiClient,
|
|
8990
10436
|
createNodeHttpClient,
|
|
10437
|
+
createProofAuthorizer,
|
|
8991
10438
|
createProofSigner,
|
|
8992
10439
|
createQuoteVerifier,
|
|
10440
|
+
createRecoveringNonceSource,
|
|
8993
10441
|
createRetryableMethod,
|
|
8994
10442
|
createSealedFetch,
|
|
8995
10443
|
createSignedReleaseSealedFetch,
|
|
@@ -8997,12 +10445,16 @@ export {
|
|
|
8997
10445
|
createTimeoutSignal,
|
|
8998
10446
|
createUniversalHttpClient,
|
|
8999
10447
|
creationInitHash,
|
|
10448
|
+
defaultAudience,
|
|
10449
|
+
defaultCapabilitiesSetOp,
|
|
9000
10450
|
deviceCertPayload,
|
|
10451
|
+
deviceEnrolmentUrl,
|
|
9001
10452
|
discoverLocalNodes,
|
|
9002
10453
|
encodeSignedInvitation,
|
|
9003
10454
|
fetchAttestedTransportKey,
|
|
9004
10455
|
fetchNodeRelease,
|
|
9005
10456
|
fetchNodeReleaseVersion,
|
|
10457
|
+
foundedNamespaceId,
|
|
9006
10458
|
generateAccountRoot,
|
|
9007
10459
|
generateSessionKey,
|
|
9008
10460
|
governanceOpHash,
|
|
@@ -9021,14 +10473,22 @@ export {
|
|
|
9021
10473
|
memberRemovedOp,
|
|
9022
10474
|
memberRoleSetOp,
|
|
9023
10475
|
mintDeviceId,
|
|
10476
|
+
namespaceCreatedOp,
|
|
9024
10477
|
nodeEndpoint,
|
|
9025
10478
|
nodeReleaseVersionFromImage,
|
|
9026
10479
|
parseAuthCallback,
|
|
9027
10480
|
parseCreationWarrant,
|
|
10481
|
+
parseDeviceCredential,
|
|
9028
10482
|
parseGovernanceWarrant,
|
|
10483
|
+
parseWarrantNonce,
|
|
9029
10484
|
probeNodeHealth,
|
|
10485
|
+
readCloudLinkCallback,
|
|
10486
|
+
readEnrolmentCallback,
|
|
9030
10487
|
redeemInvitation,
|
|
10488
|
+
reportDataOf,
|
|
9031
10489
|
requestBodyHash,
|
|
10490
|
+
resolveRoot,
|
|
10491
|
+
resolveRootPair,
|
|
9032
10492
|
routingProofHeaders,
|
|
9033
10493
|
shouldRetain,
|
|
9034
10494
|
signAccountLink,
|
|
@@ -9039,13 +10499,16 @@ export {
|
|
|
9039
10499
|
signLoginStatement,
|
|
9040
10500
|
signMemberJoinOp,
|
|
9041
10501
|
signRequest,
|
|
10502
|
+
signRequestProof,
|
|
9042
10503
|
signRoutingChallenge,
|
|
9043
10504
|
signWarrant,
|
|
10505
|
+
signedPath,
|
|
9044
10506
|
signerFromCryptoKey,
|
|
9045
10507
|
signerFromSecret,
|
|
9046
10508
|
transportKeyBinding,
|
|
9047
10509
|
trustSignedRelease,
|
|
9048
10510
|
trustedMeasurementsFromReleases,
|
|
10511
|
+
verifyDeviceCredential,
|
|
9049
10512
|
verifySignedNodeRelease,
|
|
9050
10513
|
withCap,
|
|
9051
10514
|
withRetry,
|