@calimero-network/mero-js 22.7.0 → 22.9.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 +154 -2
- package/dist/admin-api/admin-client.js +255 -1
- package/dist/admin-api/admin-types.d.ts +193 -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 +96 -2
- package/dist/device-cert/device-cert.js +101 -11
- package/dist/device-cert/device-scope.d.ts +58 -0
- package/dist/device-cert/device-scope.js +76 -0
- package/dist/device-cert/index.d.ts +4 -2
- package/dist/device-cert/index.js +2 -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 +1955 -496
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +21 -2
- package/dist/index.mjs +1955 -496
- package/dist/index.mjs.map +4 -4
- package/dist/invitation/index.d.ts +2 -0
- package/dist/invitation/index.js +2 -0
- package/dist/invitation/invitation.d.ts +106 -0
- package/dist/invitation/invitation.js +104 -0
- 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 +13 -19
- 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,36 @@
|
|
|
1
|
+
import { type Signer } from '../signer/signer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Core's `ACCOUNT_GENESIS_VERSION`. It is part of the `AccountId` preimage, so
|
|
4
|
+
* this value decides which account a root key names — a mismatch does not fail
|
|
5
|
+
* as a version error at the edge, it silently derives a different account and
|
|
6
|
+
* core refuses the credential as unverifiable.
|
|
7
|
+
*
|
|
8
|
+
* `2` since the genesis dropped its per-namespace nonce. This was `1`, which
|
|
9
|
+
* produced credentials current core rejects outright; the shape was already v2,
|
|
10
|
+
* only the tag was stale.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ACCOUNT_GENESIS_VERSION = 2;
|
|
1
13
|
/** What a root certifies about one device. */
|
|
2
14
|
export interface DeviceCertInput {
|
|
3
|
-
/**
|
|
4
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The account root's signing secret, 64 hex. Signs the certificate.
|
|
17
|
+
*
|
|
18
|
+
* Mutually exclusive with {@link DeviceCertInput.signer}, and the weaker of
|
|
19
|
+
* the two in a browser: this is the one key in the system with nothing above
|
|
20
|
+
* it to revoke it, so a page that can read it can take the account for good.
|
|
21
|
+
* Prefer `signer` anywhere the root is held rather than loaded.
|
|
22
|
+
*/
|
|
23
|
+
rootSecret?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The account root's signer — use instead of {@link DeviceCertInput.rootSecret}
|
|
26
|
+
* when the root is a key this page may use and cannot read.
|
|
27
|
+
*
|
|
28
|
+
* This is what lets the certifying page keep its root as a non-extractable
|
|
29
|
+
* `CryptoKey`: {@link accountRootSignerFromPhrase} hands one back, and script
|
|
30
|
+
* injected into the page can then mint certificates only while the page is
|
|
31
|
+
* open, rather than walking off with the account.
|
|
32
|
+
*/
|
|
33
|
+
signer?: Signer;
|
|
5
34
|
/** The device being certified, 64 hex — see {@link mintDeviceId}. */
|
|
6
35
|
device: string;
|
|
7
36
|
/** The key that device signs with, 64 hex. */
|
|
@@ -88,5 +117,70 @@ export declare function accountProofBytes(input: {
|
|
|
88
117
|
* than accepting a value it would then have to ignore.
|
|
89
118
|
*/
|
|
90
119
|
export declare function signDeviceCert(input: DeviceCertInput): Promise<string>;
|
|
120
|
+
/** A credential read back apart, field for field. */
|
|
121
|
+
export interface DeviceCredential {
|
|
122
|
+
/** The account root that signed, 64 hex — `AccountGenesis::root_sign_pk`. */
|
|
123
|
+
rootPublicKey: string;
|
|
124
|
+
/** The account the certificate names, 64 hex. */
|
|
125
|
+
account: string;
|
|
126
|
+
/** The device it certifies, 64 hex. */
|
|
127
|
+
device: string;
|
|
128
|
+
/** That device's ed25519 signing key, 64 hex. */
|
|
129
|
+
signPublicKey: string;
|
|
130
|
+
/** That device's X25519 key-delivery key, 64 hex. */
|
|
131
|
+
kemPublicKey: string;
|
|
132
|
+
keyEpoch: number;
|
|
133
|
+
deviceEpoch: number;
|
|
134
|
+
/** The root's signature over {@link deviceCertPayload}, 128 hex. */
|
|
135
|
+
signature: string;
|
|
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 declare function parseDeviceCredential(credential: string): DeviceCredential;
|
|
156
|
+
/**
|
|
157
|
+
* Check that a credential says what it claims: the root signed it, and the
|
|
158
|
+
* account it names is the one that root derives.
|
|
159
|
+
*
|
|
160
|
+
* Both halves matter and neither implies the other. The signature says the
|
|
161
|
+
* genesis root consented to these exact keys; the account derivation says the
|
|
162
|
+
* certificate cannot have been re-pointed at a different account while keeping a
|
|
163
|
+
* signature that verifies. A caller that checked only the first would accept a
|
|
164
|
+
* certificate naming an account whose root never signed anything.
|
|
165
|
+
*
|
|
166
|
+
* What it does **not** establish is that the account is one the caller wanted.
|
|
167
|
+
* Anyone can mint a root offline and certify any public key with it, so a
|
|
168
|
+
* credential arriving from elsewhere proves a *consistent* account, not *your*
|
|
169
|
+
* account — see `cloud/enrol-redirect.ts` for the binding that closes that.
|
|
170
|
+
*
|
|
171
|
+
* @throws with which check failed, rather than returning false — every failure
|
|
172
|
+
* here is a credential that must not be stored, and a boolean gets ignored.
|
|
173
|
+
*/
|
|
174
|
+
export declare function verifyDeviceCredential(credential: string): Promise<DeviceCredential>;
|
|
91
175
|
/** The account this root owns — the content address of its genesis. */
|
|
92
176
|
export declare function accountForRoot(rootSecret: string): Promise<string>;
|
|
177
|
+
/**
|
|
178
|
+
* The same account id, from the root's **public** half.
|
|
179
|
+
*
|
|
180
|
+
* The form a root that cannot be exported needs. It is also the honest shape of
|
|
181
|
+
* the derivation: an account id is a function of the public key, and asking for
|
|
182
|
+
* the secret only ever meant "let me derive the public key first".
|
|
183
|
+
*
|
|
184
|
+
* @param rootPublicKey the root's ed25519 public key, 64 hex
|
|
185
|
+
*/
|
|
186
|
+
export declare function accountForRootPublicKey(rootPublicKey: string): Promise<string>;
|
|
@@ -15,11 +15,11 @@
|
|
|
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');
|
|
22
|
-
/** `AccountGenesis::version`, which the credential's borsh encoding leads with. */
|
|
23
23
|
/**
|
|
24
24
|
* Core's `ACCOUNT_GENESIS_VERSION`. It is part of the `AccountId` preimage, so
|
|
25
25
|
* this value decides which account a root key names — a mismatch does not fail
|
|
@@ -30,7 +30,9 @@ const DEVICE_ID_DOMAIN = new TextEncoder().encode('calimero.device.id.v1');
|
|
|
30
30
|
* produced credentials current core rejects outright; the shape was already v2,
|
|
31
31
|
* only the tag was stale.
|
|
32
32
|
*/
|
|
33
|
-
const ACCOUNT_GENESIS_VERSION = 2;
|
|
33
|
+
export const ACCOUNT_GENESIS_VERSION = 2;
|
|
34
|
+
/** Byte length of an `AccountProof<DeviceCert>` with an empty handoff chain. */
|
|
35
|
+
const ACCOUNT_PROOF_BYTES = 237;
|
|
34
36
|
/**
|
|
35
37
|
* Mint a device id: `H(DEVICE_ID_DOMAIN, account ‖ nonce)`.
|
|
36
38
|
*
|
|
@@ -104,10 +106,11 @@ export function accountProofBytes(input) {
|
|
|
104
106
|
* than accepting a value it would then have to ignore.
|
|
105
107
|
*/
|
|
106
108
|
export async function signDeviceCert(input) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
const signer = await resolveSigner(input.rootSecret, input.signer, 'rootSecret');
|
|
110
|
+
// The signer names its own key, so the account is read off it rather than
|
|
111
|
+
// derived from material this function may not have.
|
|
112
|
+
const rootPublicKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
113
|
+
const account = await accountForRootPublicKey(signer.publicKey);
|
|
111
114
|
const keyEpoch = 0;
|
|
112
115
|
const payload = await deviceCertPayload({
|
|
113
116
|
account,
|
|
@@ -117,8 +120,7 @@ export async function signDeviceCert(input) {
|
|
|
117
120
|
keyEpoch,
|
|
118
121
|
deviceEpoch: input.deviceEpoch,
|
|
119
122
|
});
|
|
120
|
-
const
|
|
121
|
-
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', key, payload));
|
|
123
|
+
const signature = await signer.sign(payload);
|
|
122
124
|
const credential = accountProofBytes({
|
|
123
125
|
rootPublicKey,
|
|
124
126
|
account,
|
|
@@ -131,10 +133,98 @@ export async function signDeviceCert(input) {
|
|
|
131
133
|
});
|
|
132
134
|
return hex(credential);
|
|
133
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Read an `AccountProof<DeviceCert>` back apart — the inverse of
|
|
138
|
+
* {@link accountProofBytes}, and deliberately in the same file as it.
|
|
139
|
+
*
|
|
140
|
+
* A decoder that lives away from its encoder drifts: the layout is only checked
|
|
141
|
+
* by a peer rejecting a signature, which points at the signature rather than at
|
|
142
|
+
* whichever copy went stale.
|
|
143
|
+
*
|
|
144
|
+
* This checks the *shape* and nothing else — it says what the bytes claim, not
|
|
145
|
+
* whether the claim is true. {@link verifyDeviceCredential} is the one that
|
|
146
|
+
* answers that, and it is the one a caller acting on a credential from another
|
|
147
|
+
* origin wants.
|
|
148
|
+
*
|
|
149
|
+
* @throws on a wrong genesis version, a non-empty handoff chain, or trailing
|
|
150
|
+
* bytes. A non-empty chain is refused rather than skipped: verifying a handed-off
|
|
151
|
+
* root means walking the chain, and a decoder that returned the statement while
|
|
152
|
+
* ignoring who was entitled to sign it would hand a verifier the wrong root.
|
|
153
|
+
*/
|
|
154
|
+
export function parseDeviceCredential(credential) {
|
|
155
|
+
const bytes = fromHex(credential, 'credential',
|
|
156
|
+
// Fixed-width with an empty chain: 1 + 32 + 4 + 32*4 + 4 + 4 + 64.
|
|
157
|
+
ACCOUNT_PROOF_BYTES);
|
|
158
|
+
if (bytes[0] !== ACCOUNT_GENESIS_VERSION) {
|
|
159
|
+
throw new Error(`this credential has account genesis version ${bytes[0]}, not ` +
|
|
160
|
+
`${ACCOUNT_GENESIS_VERSION} — it names a different account than its root ` +
|
|
161
|
+
'key derives here, and core would refuse it');
|
|
162
|
+
}
|
|
163
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
164
|
+
const chainLength = view.getUint32(33, true);
|
|
165
|
+
if (chainLength !== 0) {
|
|
166
|
+
throw new Error(`this credential carries a ${chainLength}-handoff root chain, which this ` +
|
|
167
|
+
'SDK cannot verify yet — the signing root is not the genesis root, so ' +
|
|
168
|
+
'checking it means walking the chain');
|
|
169
|
+
}
|
|
170
|
+
const at = (offset, length) => hex(bytes.subarray(offset, offset + length));
|
|
171
|
+
return {
|
|
172
|
+
rootPublicKey: at(1, 32),
|
|
173
|
+
account: at(37, 32),
|
|
174
|
+
device: at(69, 32),
|
|
175
|
+
signPublicKey: at(101, 32),
|
|
176
|
+
kemPublicKey: at(133, 32),
|
|
177
|
+
keyEpoch: view.getUint32(165, true),
|
|
178
|
+
deviceEpoch: view.getUint32(169, true),
|
|
179
|
+
signature: at(173, 64),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Check that a credential says what it claims: the root signed it, and the
|
|
184
|
+
* account it names is the one that root derives.
|
|
185
|
+
*
|
|
186
|
+
* Both halves matter and neither implies the other. The signature says the
|
|
187
|
+
* genesis root consented to these exact keys; the account derivation says the
|
|
188
|
+
* certificate cannot have been re-pointed at a different account while keeping a
|
|
189
|
+
* signature that verifies. A caller that checked only the first would accept a
|
|
190
|
+
* certificate naming an account whose root never signed anything.
|
|
191
|
+
*
|
|
192
|
+
* What it does **not** establish is that the account is one the caller wanted.
|
|
193
|
+
* Anyone can mint a root offline and certify any public key with it, so a
|
|
194
|
+
* credential arriving from elsewhere proves a *consistent* account, not *your*
|
|
195
|
+
* account — see `cloud/enrol-redirect.ts` for the binding that closes that.
|
|
196
|
+
*
|
|
197
|
+
* @throws with which check failed, rather than returning false — every failure
|
|
198
|
+
* here is a credential that must not be stored, and a boolean gets ignored.
|
|
199
|
+
*/
|
|
200
|
+
export async function verifyDeviceCredential(credential) {
|
|
201
|
+
const parsed = parseDeviceCredential(credential);
|
|
202
|
+
const derived = await accountForRootPublicKey(parsed.rootPublicKey);
|
|
203
|
+
if (derived !== parsed.account) {
|
|
204
|
+
throw new Error(`this credential names account ${parsed.account}, but its root key ` +
|
|
205
|
+
`derives ${derived} — the certificate was re-pointed at another account`);
|
|
206
|
+
}
|
|
207
|
+
const signed = await verifySignature(parsed.rootPublicKey, fromHex(parsed.signature, 'signature', 64), await deviceCertPayload(parsed));
|
|
208
|
+
if (!signed) {
|
|
209
|
+
throw new Error(`the root key of account ${parsed.account} did not sign this certificate`);
|
|
210
|
+
}
|
|
211
|
+
return parsed;
|
|
212
|
+
}
|
|
134
213
|
/** The account this root owns — the content address of its genesis. */
|
|
135
214
|
export async function accountForRoot(rootSecret) {
|
|
136
|
-
|
|
215
|
+
return accountForRootPublicKey(hex(await derivePublicKey(rootSecret)));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The same account id, from the root's **public** half.
|
|
219
|
+
*
|
|
220
|
+
* The form a root that cannot be exported needs. It is also the honest shape of
|
|
221
|
+
* the derivation: an account id is a function of the public key, and asking for
|
|
222
|
+
* the secret only ever meant "let me derive the public key first".
|
|
223
|
+
*
|
|
224
|
+
* @param rootPublicKey the root's ed25519 public key, 64 hex
|
|
225
|
+
*/
|
|
226
|
+
export async function accountForRootPublicKey(rootPublicKey) {
|
|
137
227
|
return hex(await domainHash(ACCOUNT_ID_DOMAIN, [
|
|
138
|
-
concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey),
|
|
228
|
+
concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), fromHex(rootPublicKey, 'rootPublicKey', 32)),
|
|
139
229
|
]));
|
|
140
230
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Signer } from '../signer/signer.js';
|
|
2
|
+
/** What a root scopes one device to. */
|
|
3
|
+
export interface DeviceScopeInput {
|
|
4
|
+
/**
|
|
5
|
+
* The account root's signing secret, 64 hex. Pass this or {@link signer}.
|
|
6
|
+
*/
|
|
7
|
+
rootSecret?: string;
|
|
8
|
+
/**
|
|
9
|
+
* The account root as a {@link Signer} — for a root that cannot be exported.
|
|
10
|
+
* Its `publicKey` is the genesis key the proof carries, and the account is
|
|
11
|
+
* derived from it.
|
|
12
|
+
*/
|
|
13
|
+
signer?: Signer;
|
|
14
|
+
/** The device being scoped, 64 hex — the one its certificate names. */
|
|
15
|
+
device: string;
|
|
16
|
+
/**
|
|
17
|
+
* Application ids the device may speak for, 64 hex each. **Empty means all of
|
|
18
|
+
* them**, which is also what the default is.
|
|
19
|
+
*
|
|
20
|
+
* A namespace binds the device only when its application is listed here (or
|
|
21
|
+
* the list is empty), so a scope naming the wrong application yields a relay
|
|
22
|
+
* refusal of `400`, not a binding.
|
|
23
|
+
*/
|
|
24
|
+
applications?: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* Orders scopes for this device: only a higher one supersedes. 0 for a
|
|
27
|
+
* device's first scope.
|
|
28
|
+
*/
|
|
29
|
+
scopeEpoch?: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The 32 bytes a root signs to scope a device: `domain_hash` over account,
|
|
33
|
+
* device, `scope_epoch` (u32 LE), `key_epoch` (u32 LE), then each application id
|
|
34
|
+
* as its own length-prefixed part.
|
|
35
|
+
*/
|
|
36
|
+
export declare function deviceScopePayload(input: {
|
|
37
|
+
account: string;
|
|
38
|
+
device: string;
|
|
39
|
+
applications: readonly string[];
|
|
40
|
+
scopeEpoch: number;
|
|
41
|
+
keyEpoch: number;
|
|
42
|
+
}): Promise<Uint8Array>;
|
|
43
|
+
/**
|
|
44
|
+
* Scope a device, returning the hex `AccountProof<DeviceScope>` a relay's
|
|
45
|
+
* `linkAccountDevice` takes beside the credential.
|
|
46
|
+
*
|
|
47
|
+
* ```
|
|
48
|
+
* AccountProof { genesis: AccountGenesis, chain: Vec<RootKeyHandoff>, statement: DeviceScope }
|
|
49
|
+
* AccountGenesis { version: u8, root_sign_pk: [u8; 32] }
|
|
50
|
+
* chain → u32-LE count (0 here)
|
|
51
|
+
* DeviceScope { account, device: [u8; 32] x2, applications: Vec<[u8; 32]>,
|
|
52
|
+
* scope_epoch: u32, key_epoch: u32, signature: [u8; 64] }
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* `chain` is empty and `key_epoch` 0 for the reason they are in
|
|
56
|
+
* {@link signDeviceCert}: a root that was never handed off signs directly.
|
|
57
|
+
*/
|
|
58
|
+
export declare function signDeviceScope(input: DeviceScopeInput): Promise<string>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoping a device offline: which applications a device of an account may speak
|
|
3
|
+
* for, signed by the account root.
|
|
4
|
+
*
|
|
5
|
+
* The certificate from {@link signDeviceCert} says *this key is a device of the
|
|
6
|
+
* account*. It does not say where: that is this statement, and a namespace binds
|
|
7
|
+
* a device only when both arrive together. A node that pairs a device signs the
|
|
8
|
+
* scope for you; an account with **no node** has to sign it itself, next to the
|
|
9
|
+
* certificate, and hand both to a relay's `linkAccountDevice`.
|
|
10
|
+
*
|
|
11
|
+
* Derived from core:
|
|
12
|
+
*
|
|
13
|
+
* - `DeviceScope` and `DeviceScope::signing_payload` — `crates/account/src/scope.rs`
|
|
14
|
+
* - `DEVICE_SCOPE_SIGN_DOMAIN` — `crates/account/src/domain.rs`
|
|
15
|
+
* - `AccountProof` / `AccountGenesis` borsh layout — `crates/account/src/{signed,account}.rs`
|
|
16
|
+
*
|
|
17
|
+
* The unit tests pin vectors produced by core's own `DeviceScope::sign`.
|
|
18
|
+
*/
|
|
19
|
+
import { concat, domainHash, fromHex, hex, u32le } from '../crypto/internal.js';
|
|
20
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
21
|
+
import { accountForRootPublicKey, ACCOUNT_GENESIS_VERSION } from './device-cert.js';
|
|
22
|
+
const SCOPE_DOMAIN = new TextEncoder().encode('calimero.device.scope.v1');
|
|
23
|
+
/**
|
|
24
|
+
* The 32 bytes a root signs to scope a device: `domain_hash` over account,
|
|
25
|
+
* device, `scope_epoch` (u32 LE), `key_epoch` (u32 LE), then each application id
|
|
26
|
+
* as its own length-prefixed part.
|
|
27
|
+
*/
|
|
28
|
+
export async function deviceScopePayload(input) {
|
|
29
|
+
return domainHash(SCOPE_DOMAIN, [
|
|
30
|
+
fromHex(input.account, 'account', 32),
|
|
31
|
+
fromHex(input.device, 'device', 32),
|
|
32
|
+
u32le(input.scopeEpoch),
|
|
33
|
+
u32le(input.keyEpoch),
|
|
34
|
+
...input.applications.map((a, i) => fromHex(a, `applications[${i}]`, 32)),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Scope a device, returning the hex `AccountProof<DeviceScope>` a relay's
|
|
39
|
+
* `linkAccountDevice` takes beside the credential.
|
|
40
|
+
*
|
|
41
|
+
* ```
|
|
42
|
+
* AccountProof { genesis: AccountGenesis, chain: Vec<RootKeyHandoff>, statement: DeviceScope }
|
|
43
|
+
* AccountGenesis { version: u8, root_sign_pk: [u8; 32] }
|
|
44
|
+
* chain → u32-LE count (0 here)
|
|
45
|
+
* DeviceScope { account, device: [u8; 32] x2, applications: Vec<[u8; 32]>,
|
|
46
|
+
* scope_epoch: u32, key_epoch: u32, signature: [u8; 64] }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* `chain` is empty and `key_epoch` 0 for the reason they are in
|
|
50
|
+
* {@link signDeviceCert}: a root that was never handed off signs directly.
|
|
51
|
+
*/
|
|
52
|
+
export async function signDeviceScope(input) {
|
|
53
|
+
const signer = await resolveSigner(input.rootSecret, input.signer, 'rootSecret');
|
|
54
|
+
const rootPublicKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
55
|
+
const account = await accountForRootPublicKey(hex(rootPublicKey));
|
|
56
|
+
const applications = input.applications ?? [];
|
|
57
|
+
const scopeEpoch = input.scopeEpoch ?? 0;
|
|
58
|
+
// `u32le` would wrap a negative or oversized value into a different epoch
|
|
59
|
+
// rather than refuse it, and the scope would then order itself wrongly.
|
|
60
|
+
if (!Number.isInteger(scopeEpoch) || scopeEpoch < 0 || scopeEpoch > 4294967295) {
|
|
61
|
+
throw new Error(`scopeEpoch must be a u32, got ${scopeEpoch}`);
|
|
62
|
+
}
|
|
63
|
+
const keyEpoch = 0;
|
|
64
|
+
const payload = await deviceScopePayload({
|
|
65
|
+
account,
|
|
66
|
+
device: input.device,
|
|
67
|
+
applications,
|
|
68
|
+
scopeEpoch,
|
|
69
|
+
keyEpoch,
|
|
70
|
+
});
|
|
71
|
+
const signature = await signer.sign(payload);
|
|
72
|
+
if (signature.length !== 64) {
|
|
73
|
+
throw new Error(`signer returned ${signature.length} bytes, expected a 64-byte Ed25519 signature`);
|
|
74
|
+
}
|
|
75
|
+
return hex(concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey, u32le(0), fromHex(account, 'account', 32), fromHex(input.device, 'device', 32), u32le(applications.length), ...applications.map((a, i) => fromHex(a, `applications[${i}]`, 32)), u32le(scopeEpoch), u32le(keyEpoch), signature));
|
|
76
|
+
}
|
|
@@ -1,2 +1,4 @@
|
|
|
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';
|
|
3
|
+
export { signDeviceScope, deviceScopePayload } from './device-scope.js';
|
|
4
|
+
export type { DeviceScopeInput } from './device-scope.js';
|
|
@@ -1,2 +1,3 @@
|
|
|
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';
|
|
3
|
+
export { signDeviceScope, deviceScopePayload } from './device-scope.js';
|