@calimero-network/mero-js 22.7.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/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 +1666 -365
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +9 -4
- package/dist/index.js +16 -2
- package/dist/index.mjs +1666 -365
- 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 +2 -2
- 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 +60 -5
- package/dist/relay/relay-client.js +84 -4
- 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/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linking an account to a cloud login **from a browser**.
|
|
3
|
+
*
|
|
4
|
+
* The cloud's own accounts screen says it plainly: *"The cloud cannot link an
|
|
5
|
+
* account on your behalf: proving you own it is a signature by the account root
|
|
6
|
+
* key, which the cloud never holds."* So the link needs two things that live in
|
|
7
|
+
* different places — a cloud session, which only Google can mint, and a root
|
|
8
|
+
* signature, which only the holder can make. Neither side can do it alone.
|
|
9
|
+
*
|
|
10
|
+
* The cloud already implements its half: send a person to
|
|
11
|
+
* `?link-account=<id>&callback-url=<url>`, it shows a consent screen and
|
|
12
|
+
* redirects back with a **grant** — that login's sealed consent to link one
|
|
13
|
+
* named account. This module is the missing other half, and it is deliberately
|
|
14
|
+
* small: send them there, then spend what comes back.
|
|
15
|
+
*
|
|
16
|
+
* ## Why a grant rather than a session
|
|
17
|
+
*
|
|
18
|
+
* A grant is spendable by anyone holding the account's root, and by nobody
|
|
19
|
+
* else. That is what lets the page complete the link while holding **no cloud
|
|
20
|
+
* session at all** — and it is why a wallet should never be handed one. A
|
|
21
|
+
* session token is a credential for everything that login can do; a grant is a
|
|
22
|
+
* credential for exactly one link, to exactly one account, sealed at the moment
|
|
23
|
+
* the person read the id on screen and agreed to it.
|
|
24
|
+
*
|
|
25
|
+
* ## The account id is chosen by us and read by them
|
|
26
|
+
*
|
|
27
|
+
* The cloud seals the account into the grant at consent time, and its own note
|
|
28
|
+
* says why: the server can check that the key which later signs derives that
|
|
29
|
+
* same account, but *"it cannot know whether the person meant this account"*.
|
|
30
|
+
* The consent screen is that check. So show the same id the person will see,
|
|
31
|
+
* before sending them — a wallet that hides it has removed the only safeguard.
|
|
32
|
+
*/
|
|
33
|
+
import type { CloudAccountLink } from './cloud-client.js';
|
|
34
|
+
import { type RootSource } from '../account/account.js';
|
|
35
|
+
export interface BeginCloudLinkOptions {
|
|
36
|
+
/** The account to link — 64 hex. Show this to the person before redirecting. */
|
|
37
|
+
accountId: string;
|
|
38
|
+
/**
|
|
39
|
+
* Where the cloud sends them back. Must be `https:`, or `http:` on localhost.
|
|
40
|
+
*
|
|
41
|
+
* The grant returns in the URL **fragment**, which is never sent to a server,
|
|
42
|
+
* so it does not land in anyone's access log on the way.
|
|
43
|
+
*/
|
|
44
|
+
returnTo: string;
|
|
45
|
+
/** The cloud's web origin. Defaults to the hosted one. */
|
|
46
|
+
cloudWebUrl?: string;
|
|
47
|
+
}
|
|
48
|
+
/** The consent URL to send a person to. */
|
|
49
|
+
export declare function cloudLinkUrl(options: BeginCloudLinkOptions): string;
|
|
50
|
+
/** Send the person to the cloud to consent. Does not return. */
|
|
51
|
+
export declare function beginCloudLink(options: BeginCloudLinkOptions): never;
|
|
52
|
+
/** What came back in the fragment, if anything. */
|
|
53
|
+
export interface CloudLinkCallback {
|
|
54
|
+
grant: string;
|
|
55
|
+
/** The account the cloud sealed into the grant. */
|
|
56
|
+
account: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read a grant out of the current URL fragment, and strip it.
|
|
60
|
+
*
|
|
61
|
+
* Returns `null` when this is an ordinary page load, so a caller can run it
|
|
62
|
+
* unconditionally at startup. Throws when the person declined, because that is
|
|
63
|
+
* an outcome the app has to show rather than a state to wait in.
|
|
64
|
+
*/
|
|
65
|
+
export declare function readCloudLinkCallback(location?: {
|
|
66
|
+
hash: string;
|
|
67
|
+
}): CloudLinkCallback | null;
|
|
68
|
+
export interface CompleteCloudLinkOptions extends CloudLinkCallback {
|
|
69
|
+
/** The account root — a 64-hex secret, or a Signer over a key you cannot read. */
|
|
70
|
+
root: RootSource;
|
|
71
|
+
/** The **manager** API base, e.g. `https://manager.cloud.calimero.network`. */
|
|
72
|
+
cloudBaseUrl?: string;
|
|
73
|
+
fetch?: typeof fetch;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Spend the grant: sign it with the root and hand it back to the cloud.
|
|
77
|
+
*
|
|
78
|
+
* Anonymous by construction — the client is built with no session, because the
|
|
79
|
+
* whole point is that the page never needed one.
|
|
80
|
+
*
|
|
81
|
+
* @throws if the grant names a different account than the root derives, before
|
|
82
|
+
* anything is sent. The cloud checks this too, but failing here says which two
|
|
83
|
+
* accounts disagreed rather than returning a bare 403.
|
|
84
|
+
*/
|
|
85
|
+
export declare function completeCloudLink(options: CompleteCloudLinkOptions): Promise<CloudAccountLink>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linking an account to a cloud login **from a browser**.
|
|
3
|
+
*
|
|
4
|
+
* The cloud's own accounts screen says it plainly: *"The cloud cannot link an
|
|
5
|
+
* account on your behalf: proving you own it is a signature by the account root
|
|
6
|
+
* key, which the cloud never holds."* So the link needs two things that live in
|
|
7
|
+
* different places — a cloud session, which only Google can mint, and a root
|
|
8
|
+
* signature, which only the holder can make. Neither side can do it alone.
|
|
9
|
+
*
|
|
10
|
+
* The cloud already implements its half: send a person to
|
|
11
|
+
* `?link-account=<id>&callback-url=<url>`, it shows a consent screen and
|
|
12
|
+
* redirects back with a **grant** — that login's sealed consent to link one
|
|
13
|
+
* named account. This module is the missing other half, and it is deliberately
|
|
14
|
+
* small: send them there, then spend what comes back.
|
|
15
|
+
*
|
|
16
|
+
* ## Why a grant rather than a session
|
|
17
|
+
*
|
|
18
|
+
* A grant is spendable by anyone holding the account's root, and by nobody
|
|
19
|
+
* else. That is what lets the page complete the link while holding **no cloud
|
|
20
|
+
* session at all** — and it is why a wallet should never be handed one. A
|
|
21
|
+
* session token is a credential for everything that login can do; a grant is a
|
|
22
|
+
* credential for exactly one link, to exactly one account, sealed at the moment
|
|
23
|
+
* the person read the id on screen and agreed to it.
|
|
24
|
+
*
|
|
25
|
+
* ## The account id is chosen by us and read by them
|
|
26
|
+
*
|
|
27
|
+
* The cloud seals the account into the grant at consent time, and its own note
|
|
28
|
+
* says why: the server can check that the key which later signs derives that
|
|
29
|
+
* same account, but *"it cannot know whether the person meant this account"*.
|
|
30
|
+
* The consent screen is that check. So show the same id the person will see,
|
|
31
|
+
* before sending them — a wallet that hides it has removed the only safeguard.
|
|
32
|
+
*/
|
|
33
|
+
import { CloudClient } from './cloud-client.js';
|
|
34
|
+
import { resolveRoot } from '../account/account.js';
|
|
35
|
+
/** Where the consent screen lives. The web app, not the manager API. */
|
|
36
|
+
const DEFAULT_CLOUD_WEB_URL = 'https://cloud.calimero.network';
|
|
37
|
+
/** The consent URL to send a person to. */
|
|
38
|
+
export function cloudLinkUrl(options) {
|
|
39
|
+
if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
|
|
40
|
+
throw new Error('accountId must be 64 lowercase hex characters');
|
|
41
|
+
}
|
|
42
|
+
const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
|
|
43
|
+
url.searchParams.set('link-account', options.accountId);
|
|
44
|
+
url.searchParams.set('callback-url', options.returnTo);
|
|
45
|
+
return url.toString();
|
|
46
|
+
}
|
|
47
|
+
/** Send the person to the cloud to consent. Does not return. */
|
|
48
|
+
export function beginCloudLink(options) {
|
|
49
|
+
window.location.assign(cloudLinkUrl(options));
|
|
50
|
+
// `assign` does not stop execution, and a caller that kept going would act on
|
|
51
|
+
// a page already being replaced.
|
|
52
|
+
throw new Error('redirecting to the cloud to authorise this link');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Read a grant out of the current URL fragment, and strip it.
|
|
56
|
+
*
|
|
57
|
+
* Returns `null` when this is an ordinary page load, so a caller can run it
|
|
58
|
+
* unconditionally at startup. Throws when the person declined, because that is
|
|
59
|
+
* an outcome the app has to show rather than a state to wait in.
|
|
60
|
+
*/
|
|
61
|
+
export function readCloudLinkCallback(location = window.location) {
|
|
62
|
+
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
63
|
+
const error = params.get('error');
|
|
64
|
+
if (error) {
|
|
65
|
+
stripFragment();
|
|
66
|
+
throw new Error(error === 'denied'
|
|
67
|
+
? 'the account link was declined at the cloud'
|
|
68
|
+
: `the cloud refused this link: ${error}`);
|
|
69
|
+
}
|
|
70
|
+
const grant = params.get('grant');
|
|
71
|
+
const account = params.get('account');
|
|
72
|
+
if (!grant || !account)
|
|
73
|
+
return null;
|
|
74
|
+
// Consumed once. Leaving it in the bar means a reload replays a spent grant
|
|
75
|
+
// and reports a confusing failure instead of the link that already worked.
|
|
76
|
+
stripFragment();
|
|
77
|
+
return { grant, account };
|
|
78
|
+
}
|
|
79
|
+
function stripFragment() {
|
|
80
|
+
if (typeof history === 'undefined' || !history.replaceState)
|
|
81
|
+
return;
|
|
82
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Spend the grant: sign it with the root and hand it back to the cloud.
|
|
86
|
+
*
|
|
87
|
+
* Anonymous by construction — the client is built with no session, because the
|
|
88
|
+
* whole point is that the page never needed one.
|
|
89
|
+
*
|
|
90
|
+
* @throws if the grant names a different account than the root derives, before
|
|
91
|
+
* anything is sent. The cloud checks this too, but failing here says which two
|
|
92
|
+
* accounts disagreed rather than returning a bare 403.
|
|
93
|
+
*/
|
|
94
|
+
export async function completeCloudLink(options) {
|
|
95
|
+
const root = await resolveRoot(options.root);
|
|
96
|
+
if (root.accountId !== options.account) {
|
|
97
|
+
throw new Error(`this grant is for account ${options.account}, but the root here derives ` +
|
|
98
|
+
`${root.accountId} — it was minted for a different account`);
|
|
99
|
+
}
|
|
100
|
+
const cloud = new CloudClient({
|
|
101
|
+
cloudBaseUrl: options.cloudBaseUrl,
|
|
102
|
+
fetch: options.fetch,
|
|
103
|
+
});
|
|
104
|
+
return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
|
|
105
|
+
}
|
|
@@ -31,6 +31,21 @@ export declare function concat(...parts: Uint8Array[]): Uint8Array;
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function domainHash(domain: Uint8Array, parts: Uint8Array[]): Promise<Uint8Array>;
|
|
33
33
|
export declare function importSigningKey(secretHex: string): Promise<CryptoKey>;
|
|
34
|
+
/**
|
|
35
|
+
* Check an ed25519 signature against a public key, both raw.
|
|
36
|
+
*
|
|
37
|
+
* The one primitive here that consumes rather than produces a signature: a page
|
|
38
|
+
* handed a credential by another origin has to check it before it acts on it,
|
|
39
|
+
* and "the node will reject it later" is not the same safeguard — by then the
|
|
40
|
+
* page has already stored an account it does not control.
|
|
41
|
+
*
|
|
42
|
+
* `raw` import is the public-key counterpart of the PKCS#8 import above and is
|
|
43
|
+
* available on exactly the runtimes that can sign, so a runtime that got this
|
|
44
|
+
* far will not fail here for lack of support.
|
|
45
|
+
*
|
|
46
|
+
* @param publicKey 64 hex, the key the signature must verify against
|
|
47
|
+
*/
|
|
48
|
+
export declare function verifySignature(publicKey: string, signature: Uint8Array, payload: Uint8Array): Promise<boolean>;
|
|
34
49
|
/**
|
|
35
50
|
* The public half of an ed25519 seed.
|
|
36
51
|
*
|
package/dist/crypto/internal.js
CHANGED
|
@@ -97,6 +97,24 @@ export async function importSigningKey(secretHex) {
|
|
|
97
97
|
detail);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Check an ed25519 signature against a public key, both raw.
|
|
102
|
+
*
|
|
103
|
+
* The one primitive here that consumes rather than produces a signature: a page
|
|
104
|
+
* handed a credential by another origin has to check it before it acts on it,
|
|
105
|
+
* and "the node will reject it later" is not the same safeguard — by then the
|
|
106
|
+
* page has already stored an account it does not control.
|
|
107
|
+
*
|
|
108
|
+
* `raw` import is the public-key counterpart of the PKCS#8 import above and is
|
|
109
|
+
* available on exactly the runtimes that can sign, so a runtime that got this
|
|
110
|
+
* far will not fail here for lack of support.
|
|
111
|
+
*
|
|
112
|
+
* @param publicKey 64 hex, the key the signature must verify against
|
|
113
|
+
*/
|
|
114
|
+
export async function verifySignature(publicKey, signature, payload) {
|
|
115
|
+
const key = await crypto.subtle.importKey('raw', fromHex(publicKey, 'publicKey', 32), { name: 'Ed25519' }, false, ['verify']);
|
|
116
|
+
return crypto.subtle.verify({ name: 'Ed25519' }, key, signature, payload);
|
|
117
|
+
}
|
|
100
118
|
/**
|
|
101
119
|
* The public half of an ed25519 seed.
|
|
102
120
|
*
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
import { type Signer } from '../signer/signer.js';
|
|
1
2
|
/** What a root certifies about one device. */
|
|
2
3
|
export interface DeviceCertInput {
|
|
3
|
-
/**
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The account root's signing secret, 64 hex. Signs the certificate.
|
|
6
|
+
*
|
|
7
|
+
* Mutually exclusive with {@link DeviceCertInput.signer}, and the weaker of
|
|
8
|
+
* the two in a browser: this is the one key in the system with nothing above
|
|
9
|
+
* it to revoke it, so a page that can read it can take the account for good.
|
|
10
|
+
* Prefer `signer` anywhere the root is held rather than loaded.
|
|
11
|
+
*/
|
|
12
|
+
rootSecret?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The account root's signer — use instead of {@link DeviceCertInput.rootSecret}
|
|
15
|
+
* when the root is a key this page may use and cannot read.
|
|
16
|
+
*
|
|
17
|
+
* This is what lets the certifying page keep its root as a non-extractable
|
|
18
|
+
* `CryptoKey`: {@link accountRootSignerFromPhrase} hands one back, and script
|
|
19
|
+
* injected into the page can then mint certificates only while the page is
|
|
20
|
+
* open, rather than walking off with the account.
|
|
21
|
+
*/
|
|
22
|
+
signer?: Signer;
|
|
5
23
|
/** The device being certified, 64 hex — see {@link mintDeviceId}. */
|
|
6
24
|
device: string;
|
|
7
25
|
/** The key that device signs with, 64 hex. */
|
|
@@ -88,5 +106,70 @@ export declare function accountProofBytes(input: {
|
|
|
88
106
|
* than accepting a value it would then have to ignore.
|
|
89
107
|
*/
|
|
90
108
|
export declare function signDeviceCert(input: DeviceCertInput): Promise<string>;
|
|
109
|
+
/** A credential read back apart, field for field. */
|
|
110
|
+
export interface DeviceCredential {
|
|
111
|
+
/** The account root that signed, 64 hex — `AccountGenesis::root_sign_pk`. */
|
|
112
|
+
rootPublicKey: string;
|
|
113
|
+
/** The account the certificate names, 64 hex. */
|
|
114
|
+
account: string;
|
|
115
|
+
/** The device it certifies, 64 hex. */
|
|
116
|
+
device: string;
|
|
117
|
+
/** That device's ed25519 signing key, 64 hex. */
|
|
118
|
+
signPublicKey: string;
|
|
119
|
+
/** That device's X25519 key-delivery key, 64 hex. */
|
|
120
|
+
kemPublicKey: string;
|
|
121
|
+
keyEpoch: number;
|
|
122
|
+
deviceEpoch: number;
|
|
123
|
+
/** The root's signature over {@link deviceCertPayload}, 128 hex. */
|
|
124
|
+
signature: string;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Read an `AccountProof<DeviceCert>` back apart — the inverse of
|
|
128
|
+
* {@link accountProofBytes}, and deliberately in the same file as it.
|
|
129
|
+
*
|
|
130
|
+
* A decoder that lives away from its encoder drifts: the layout is only checked
|
|
131
|
+
* by a peer rejecting a signature, which points at the signature rather than at
|
|
132
|
+
* whichever copy went stale.
|
|
133
|
+
*
|
|
134
|
+
* This checks the *shape* and nothing else — it says what the bytes claim, not
|
|
135
|
+
* whether the claim is true. {@link verifyDeviceCredential} is the one that
|
|
136
|
+
* answers that, and it is the one a caller acting on a credential from another
|
|
137
|
+
* origin wants.
|
|
138
|
+
*
|
|
139
|
+
* @throws on a wrong genesis version, a non-empty handoff chain, or trailing
|
|
140
|
+
* bytes. A non-empty chain is refused rather than skipped: verifying a handed-off
|
|
141
|
+
* root means walking the chain, and a decoder that returned the statement while
|
|
142
|
+
* ignoring who was entitled to sign it would hand a verifier the wrong root.
|
|
143
|
+
*/
|
|
144
|
+
export declare function parseDeviceCredential(credential: string): DeviceCredential;
|
|
145
|
+
/**
|
|
146
|
+
* Check that a credential says what it claims: the root signed it, and the
|
|
147
|
+
* account it names is the one that root derives.
|
|
148
|
+
*
|
|
149
|
+
* Both halves matter and neither implies the other. The signature says the
|
|
150
|
+
* genesis root consented to these exact keys; the account derivation says the
|
|
151
|
+
* certificate cannot have been re-pointed at a different account while keeping a
|
|
152
|
+
* signature that verifies. A caller that checked only the first would accept a
|
|
153
|
+
* certificate naming an account whose root never signed anything.
|
|
154
|
+
*
|
|
155
|
+
* What it does **not** establish is that the account is one the caller wanted.
|
|
156
|
+
* Anyone can mint a root offline and certify any public key with it, so a
|
|
157
|
+
* credential arriving from elsewhere proves a *consistent* account, not *your*
|
|
158
|
+
* account — see `cloud/enrol-redirect.ts` for the binding that closes that.
|
|
159
|
+
*
|
|
160
|
+
* @throws with which check failed, rather than returning false — every failure
|
|
161
|
+
* here is a credential that must not be stored, and a boolean gets ignored.
|
|
162
|
+
*/
|
|
163
|
+
export declare function verifyDeviceCredential(credential: string): Promise<DeviceCredential>;
|
|
91
164
|
/** The account this root owns — the content address of its genesis. */
|
|
92
165
|
export declare function accountForRoot(rootSecret: string): Promise<string>;
|
|
166
|
+
/**
|
|
167
|
+
* The same account id, from the root's **public** half.
|
|
168
|
+
*
|
|
169
|
+
* The form a root that cannot be exported needs. It is also the honest shape of
|
|
170
|
+
* the derivation: an account id is a function of the public key, and asking for
|
|
171
|
+
* the secret only ever meant "let me derive the public key first".
|
|
172
|
+
*
|
|
173
|
+
* @param rootPublicKey the root's ed25519 public key, 64 hex
|
|
174
|
+
*/
|
|
175
|
+
export declare function accountForRootPublicKey(rootPublicKey: string): Promise<string>;
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* - `DeviceId::mint` — `crates/primitives/src/identity.rs`
|
|
16
16
|
* - `AccountProof` / `AccountGenesis` borsh layout — `crates/account/src/{signed,account}.rs`
|
|
17
17
|
*/
|
|
18
|
-
import { concat, derivePublicKey, domainHash, fromHex, hex,
|
|
18
|
+
import { concat, derivePublicKey, domainHash, fromHex, hex, u32le, verifySignature, } from '../crypto/internal.js';
|
|
19
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
19
20
|
const CERT_DOMAIN = new TextEncoder().encode('calimero.device.cert.v1');
|
|
20
21
|
const ACCOUNT_ID_DOMAIN = new TextEncoder().encode('calimero.account.genesis.v1');
|
|
21
22
|
const DEVICE_ID_DOMAIN = new TextEncoder().encode('calimero.device.id.v1');
|
|
@@ -31,6 +32,8 @@ const DEVICE_ID_DOMAIN = new TextEncoder().encode('calimero.device.id.v1');
|
|
|
31
32
|
* only the tag was stale.
|
|
32
33
|
*/
|
|
33
34
|
const ACCOUNT_GENESIS_VERSION = 2;
|
|
35
|
+
/** Byte length of an `AccountProof<DeviceCert>` with an empty handoff chain. */
|
|
36
|
+
const ACCOUNT_PROOF_BYTES = 237;
|
|
34
37
|
/**
|
|
35
38
|
* Mint a device id: `H(DEVICE_ID_DOMAIN, account ‖ nonce)`.
|
|
36
39
|
*
|
|
@@ -104,10 +107,11 @@ export function accountProofBytes(input) {
|
|
|
104
107
|
* than accepting a value it would then have to ignore.
|
|
105
108
|
*/
|
|
106
109
|
export async function signDeviceCert(input) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const signer = await resolveSigner(input.rootSecret, input.signer, 'rootSecret');
|
|
111
|
+
// The signer names its own key, so the account is read off it rather than
|
|
112
|
+
// derived from material this function may not have.
|
|
113
|
+
const rootPublicKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
114
|
+
const account = await accountForRootPublicKey(signer.publicKey);
|
|
111
115
|
const keyEpoch = 0;
|
|
112
116
|
const payload = await deviceCertPayload({
|
|
113
117
|
account,
|
|
@@ -117,8 +121,7 @@ export async function signDeviceCert(input) {
|
|
|
117
121
|
keyEpoch,
|
|
118
122
|
deviceEpoch: input.deviceEpoch,
|
|
119
123
|
});
|
|
120
|
-
const
|
|
121
|
-
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', key, payload));
|
|
124
|
+
const signature = await signer.sign(payload);
|
|
122
125
|
const credential = accountProofBytes({
|
|
123
126
|
rootPublicKey,
|
|
124
127
|
account,
|
|
@@ -131,10 +134,98 @@ export async function signDeviceCert(input) {
|
|
|
131
134
|
});
|
|
132
135
|
return hex(credential);
|
|
133
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Read an `AccountProof<DeviceCert>` back apart — the inverse of
|
|
139
|
+
* {@link accountProofBytes}, and deliberately in the same file as it.
|
|
140
|
+
*
|
|
141
|
+
* A decoder that lives away from its encoder drifts: the layout is only checked
|
|
142
|
+
* by a peer rejecting a signature, which points at the signature rather than at
|
|
143
|
+
* whichever copy went stale.
|
|
144
|
+
*
|
|
145
|
+
* This checks the *shape* and nothing else — it says what the bytes claim, not
|
|
146
|
+
* whether the claim is true. {@link verifyDeviceCredential} is the one that
|
|
147
|
+
* answers that, and it is the one a caller acting on a credential from another
|
|
148
|
+
* origin wants.
|
|
149
|
+
*
|
|
150
|
+
* @throws on a wrong genesis version, a non-empty handoff chain, or trailing
|
|
151
|
+
* bytes. A non-empty chain is refused rather than skipped: verifying a handed-off
|
|
152
|
+
* root means walking the chain, and a decoder that returned the statement while
|
|
153
|
+
* ignoring who was entitled to sign it would hand a verifier the wrong root.
|
|
154
|
+
*/
|
|
155
|
+
export function parseDeviceCredential(credential) {
|
|
156
|
+
const bytes = fromHex(credential, 'credential',
|
|
157
|
+
// Fixed-width with an empty chain: 1 + 32 + 4 + 32*4 + 4 + 4 + 64.
|
|
158
|
+
ACCOUNT_PROOF_BYTES);
|
|
159
|
+
if (bytes[0] !== ACCOUNT_GENESIS_VERSION) {
|
|
160
|
+
throw new Error(`this credential has account genesis version ${bytes[0]}, not ` +
|
|
161
|
+
`${ACCOUNT_GENESIS_VERSION} — it names a different account than its root ` +
|
|
162
|
+
'key derives here, and core would refuse it');
|
|
163
|
+
}
|
|
164
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
165
|
+
const chainLength = view.getUint32(33, true);
|
|
166
|
+
if (chainLength !== 0) {
|
|
167
|
+
throw new Error(`this credential carries a ${chainLength}-handoff root chain, which this ` +
|
|
168
|
+
'SDK cannot verify yet — the signing root is not the genesis root, so ' +
|
|
169
|
+
'checking it means walking the chain');
|
|
170
|
+
}
|
|
171
|
+
const at = (offset, length) => hex(bytes.subarray(offset, offset + length));
|
|
172
|
+
return {
|
|
173
|
+
rootPublicKey: at(1, 32),
|
|
174
|
+
account: at(37, 32),
|
|
175
|
+
device: at(69, 32),
|
|
176
|
+
signPublicKey: at(101, 32),
|
|
177
|
+
kemPublicKey: at(133, 32),
|
|
178
|
+
keyEpoch: view.getUint32(165, true),
|
|
179
|
+
deviceEpoch: view.getUint32(169, true),
|
|
180
|
+
signature: at(173, 64),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Check that a credential says what it claims: the root signed it, and the
|
|
185
|
+
* account it names is the one that root derives.
|
|
186
|
+
*
|
|
187
|
+
* Both halves matter and neither implies the other. The signature says the
|
|
188
|
+
* genesis root consented to these exact keys; the account derivation says the
|
|
189
|
+
* certificate cannot have been re-pointed at a different account while keeping a
|
|
190
|
+
* signature that verifies. A caller that checked only the first would accept a
|
|
191
|
+
* certificate naming an account whose root never signed anything.
|
|
192
|
+
*
|
|
193
|
+
* What it does **not** establish is that the account is one the caller wanted.
|
|
194
|
+
* Anyone can mint a root offline and certify any public key with it, so a
|
|
195
|
+
* credential arriving from elsewhere proves a *consistent* account, not *your*
|
|
196
|
+
* account — see `cloud/enrol-redirect.ts` for the binding that closes that.
|
|
197
|
+
*
|
|
198
|
+
* @throws with which check failed, rather than returning false — every failure
|
|
199
|
+
* here is a credential that must not be stored, and a boolean gets ignored.
|
|
200
|
+
*/
|
|
201
|
+
export async function verifyDeviceCredential(credential) {
|
|
202
|
+
const parsed = parseDeviceCredential(credential);
|
|
203
|
+
const derived = await accountForRootPublicKey(parsed.rootPublicKey);
|
|
204
|
+
if (derived !== parsed.account) {
|
|
205
|
+
throw new Error(`this credential names account ${parsed.account}, but its root key ` +
|
|
206
|
+
`derives ${derived} — the certificate was re-pointed at another account`);
|
|
207
|
+
}
|
|
208
|
+
const signed = await verifySignature(parsed.rootPublicKey, fromHex(parsed.signature, 'signature', 64), await deviceCertPayload(parsed));
|
|
209
|
+
if (!signed) {
|
|
210
|
+
throw new Error(`the root key of account ${parsed.account} did not sign this certificate`);
|
|
211
|
+
}
|
|
212
|
+
return parsed;
|
|
213
|
+
}
|
|
134
214
|
/** The account this root owns — the content address of its genesis. */
|
|
135
215
|
export async function accountForRoot(rootSecret) {
|
|
136
|
-
|
|
216
|
+
return accountForRootPublicKey(hex(await derivePublicKey(rootSecret)));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The same account id, from the root's **public** half.
|
|
220
|
+
*
|
|
221
|
+
* The form a root that cannot be exported needs. It is also the honest shape of
|
|
222
|
+
* the derivation: an account id is a function of the public key, and asking for
|
|
223
|
+
* the secret only ever meant "let me derive the public key first".
|
|
224
|
+
*
|
|
225
|
+
* @param rootPublicKey the root's ed25519 public key, 64 hex
|
|
226
|
+
*/
|
|
227
|
+
export async function accountForRootPublicKey(rootPublicKey) {
|
|
137
228
|
return hex(await domainHash(ACCOUNT_ID_DOMAIN, [
|
|
138
|
-
concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey),
|
|
229
|
+
concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), fromHex(rootPublicKey, 'rootPublicKey', 32)),
|
|
139
230
|
]));
|
|
140
231
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountProofBytes, } from './device-cert.js';
|
|
2
|
-
export type { DeviceCertInput } from './device-cert.js';
|
|
1
|
+
export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountForRootPublicKey, accountProofBytes, parseDeviceCredential, verifyDeviceCredential, } from './device-cert.js';
|
|
2
|
+
export type { DeviceCertInput, DeviceCredential } from './device-cert.js';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Offline device certification — the account-root half of the keyholder story.
|
|
2
|
-
export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountProofBytes, } from './device-cert.js';
|
|
2
|
+
export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountForRootPublicKey, accountProofBytes, parseDeviceCredential, verifyDeviceCredential, } from './device-cert.js';
|
package/dist/events/sse.d.ts
CHANGED
|
@@ -19,9 +19,22 @@ type SseEvent = SseEventData | GroupMembershipEventData | GroupMigrationEventDat
|
|
|
19
19
|
type SseEventHandler = (event: SseEvent) => void;
|
|
20
20
|
type SseConnectHandler = (sessionId: string) => void;
|
|
21
21
|
type SseErrorHandler = (error: Error) => void;
|
|
22
|
+
/**
|
|
23
|
+
* How one request to the SSE endpoints is authenticated.
|
|
24
|
+
*
|
|
25
|
+
* A bearer token is one value reused across requests; a request proof commits to
|
|
26
|
+
* the method, the path and the body, so it must be produced per request. Both
|
|
27
|
+
* reduce to the same thing from this client's point of view — headers — so the
|
|
28
|
+
* stream takes the general form and {@link SseClient} adapts `getAuthToken` to it.
|
|
29
|
+
*/
|
|
30
|
+
export type SseAuthorizer = (request: {
|
|
31
|
+
method: string;
|
|
32
|
+
path: string;
|
|
33
|
+
body?: string;
|
|
34
|
+
}) => Promise<Record<string, string>>;
|
|
22
35
|
export declare class SseClient {
|
|
23
36
|
private baseUrl;
|
|
24
|
-
private
|
|
37
|
+
private authorize;
|
|
25
38
|
private reconnectDelayMs;
|
|
26
39
|
private sessionId;
|
|
27
40
|
private abortController;
|
|
@@ -33,7 +46,14 @@ export declare class SseClient {
|
|
|
33
46
|
private fetchImpl;
|
|
34
47
|
constructor(opts: {
|
|
35
48
|
baseUrl: string;
|
|
36
|
-
|
|
49
|
+
/** A bearer token provider. The historical form, and still the default. */
|
|
50
|
+
getAuthToken?: () => Promise<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Per-request authorization, for a credential that commits to the request —
|
|
53
|
+
* a device cert signing a request proof. Takes precedence over
|
|
54
|
+
* `getAuthToken`; one of the two is required.
|
|
55
|
+
*/
|
|
56
|
+
authorize?: SseAuthorizer;
|
|
37
57
|
reconnectDelayMs?: number;
|
|
38
58
|
fetch?: typeof fetch;
|
|
39
59
|
});
|
package/dist/events/sse.js
CHANGED
|
@@ -42,7 +42,16 @@ export class SseClient {
|
|
|
42
42
|
this.closed = false;
|
|
43
43
|
this.listeners = { connect: [], event: [], error: [] };
|
|
44
44
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
45
|
-
|
|
45
|
+
if (opts.authorize) {
|
|
46
|
+
this.authorize = opts.authorize;
|
|
47
|
+
}
|
|
48
|
+
else if (opts.getAuthToken) {
|
|
49
|
+
const getAuthToken = opts.getAuthToken;
|
|
50
|
+
this.authorize = async () => ({ Authorization: `Bearer ${await getAuthToken()}` });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
throw new Error('SseClient needs either `getAuthToken` or `authorize`');
|
|
54
|
+
}
|
|
46
55
|
this.reconnectDelayMs = opts.reconnectDelayMs ?? 3000;
|
|
47
56
|
// globalThis.fetch must be called as a method on globalThis, not through a
|
|
48
57
|
// variable, or browsers throw "Illegal invocation".
|
|
@@ -123,11 +132,10 @@ export class SseClient {
|
|
|
123
132
|
this.closed = false;
|
|
124
133
|
this.abortController = new AbortController();
|
|
125
134
|
try {
|
|
126
|
-
const token = await this.getAuthToken();
|
|
127
135
|
const url = `${this.baseUrl}/sse`;
|
|
128
136
|
const response = await this.fetchImpl(url, {
|
|
129
137
|
headers: {
|
|
130
|
-
'
|
|
138
|
+
...(await this.authorize({ method: 'GET', path: new URL(url).pathname })),
|
|
131
139
|
'Accept': 'text/event-stream',
|
|
132
140
|
},
|
|
133
141
|
signal: this.abortController.signal,
|
|
@@ -297,23 +305,24 @@ export class SseClient {
|
|
|
297
305
|
}
|
|
298
306
|
async sendSubscription(method, ids) {
|
|
299
307
|
try {
|
|
300
|
-
const token = await this.getAuthToken();
|
|
301
308
|
const params = {};
|
|
302
309
|
if (ids.contextIds && ids.contextIds.length > 0)
|
|
303
310
|
params.contextIds = ids.contextIds;
|
|
304
311
|
if (ids.groupIds && ids.groupIds.length > 0)
|
|
305
312
|
params.groupIds = ids.groupIds;
|
|
306
|
-
const
|
|
313
|
+
const url = `${this.baseUrl}/sse/subscription`;
|
|
314
|
+
// Serialized once and both signed and sent, because a proof commits to the
|
|
315
|
+
// body: re-stringifying for the signature risks two byte sequences that
|
|
316
|
+
// differ in key order, which fails as a refused credential rather than as
|
|
317
|
+
// anything that names the real cause.
|
|
318
|
+
const body = JSON.stringify({ id: this.sessionId, method, params });
|
|
319
|
+
const response = await this.fetchImpl(url, {
|
|
307
320
|
method: 'POST',
|
|
308
321
|
headers: {
|
|
309
|
-
'
|
|
322
|
+
...(await this.authorize({ method: 'POST', path: new URL(url).pathname, body })),
|
|
310
323
|
'Content-Type': 'application/json',
|
|
311
324
|
},
|
|
312
|
-
body
|
|
313
|
-
id: this.sessionId,
|
|
314
|
-
method,
|
|
315
|
-
params,
|
|
316
|
-
}),
|
|
325
|
+
body,
|
|
317
326
|
});
|
|
318
327
|
if (!response.ok) {
|
|
319
328
|
// The subscribe path used to report only the status. On the one failure
|
|
@@ -4,6 +4,24 @@ export interface Transport {
|
|
|
4
4
|
baseUrl: string;
|
|
5
5
|
defaultHeaders?: Record<string, string>;
|
|
6
6
|
getAuthToken?: () => Promise<string | undefined>;
|
|
7
|
+
/**
|
|
8
|
+
* Per-request authorization, for a credential that commits to the request.
|
|
9
|
+
*
|
|
10
|
+
* `getAuthToken` returns one value reused across requests; a request proof
|
|
11
|
+
* signs the method, the path and the body, so it can only be produced where
|
|
12
|
+
* those are known. This is called with exactly that, and whatever it returns
|
|
13
|
+
* is merged into the headers.
|
|
14
|
+
*
|
|
15
|
+
* It runs AFTER `getAuthToken`, so a transport holding both sends both and
|
|
16
|
+
* lets the node decide — which it does by answering a request carrying a token
|
|
17
|
+
* by that token, and consulting a proof only when there is none.
|
|
18
|
+
*/
|
|
19
|
+
authorizeRequest?: (request: {
|
|
20
|
+
method: string;
|
|
21
|
+
/** The path the node will see, query excluded. */
|
|
22
|
+
path: string;
|
|
23
|
+
body?: string | Uint8Array;
|
|
24
|
+
}) => Promise<Record<string, string>>;
|
|
7
25
|
/**
|
|
8
26
|
* Sign each request instead of, or alongside, presenting a session.
|
|
9
27
|
*
|
|
@@ -193,8 +193,28 @@ export class WebHttpClient {
|
|
|
193
193
|
else {
|
|
194
194
|
headersObj = headers;
|
|
195
195
|
}
|
|
196
|
+
const method = init?.method || 'GET';
|
|
197
|
+
// A credential that commits to the request can only be produced here, where
|
|
198
|
+
// the method, the assembled path and the body are all known. The path comes
|
|
199
|
+
// from the URL rather than from `path`, because a base URL may carry a prefix
|
|
200
|
+
// (`…/admin-api`) that the node sees and a bare relative path would not sign.
|
|
201
|
+
if (this.transport.authorizeRequest) {
|
|
202
|
+
const body = init?.body;
|
|
203
|
+
if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
|
|
204
|
+
throw new Error('this transport authorizes each request against its body, so the body must be a string ' +
|
|
205
|
+
'or Uint8Array — a stream cannot be signed, because the signature would have to read ' +
|
|
206
|
+
'bytes the request then cannot send.');
|
|
207
|
+
}
|
|
208
|
+
Object.assign(headersObj, await this.transport.authorizeRequest({
|
|
209
|
+
method,
|
|
210
|
+
path: new URL(url).pathname,
|
|
211
|
+
// Narrowed by the guard above; `BodyInit` is wider than what can be
|
|
212
|
+
// signed.
|
|
213
|
+
body: body,
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
196
216
|
const requestInit = {
|
|
197
|
-
method
|
|
217
|
+
method,
|
|
198
218
|
headers: headersObj,
|
|
199
219
|
};
|
|
200
220
|
// Check if body is a stream (ReadableStream) that can't be reused
|