@calimero-network/mero-js 22.8.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/admin-api/admin-client.d.ts +17 -1
- package/dist/admin-api/admin-client.js +18 -0
- package/dist/admin-api/admin-types.d.ts +34 -0
- package/dist/device-cert/device-cert.d.ts +11 -0
- package/dist/device-cert/device-cert.js +1 -2
- 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 +2 -0
- package/dist/device-cert/index.js +1 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +177 -19
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.mjs +177 -19
- 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/namespace-op/namespace-op.js +4 -14
- package/package.json +1 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minting a group invitation without a node.
|
|
3
|
+
*
|
|
4
|
+
* An invitation is not a governance op. Nothing is published when one is made:
|
|
5
|
+
* it is a bearer credential, `GroupInvitationFromAdmin` borsh-encoded, hashed
|
|
6
|
+
* with SHA-256 and signed by the inviter's key. A node mints one with its own
|
|
7
|
+
* namespace key; an account with **no node** signs it here with its device key.
|
|
8
|
+
*
|
|
9
|
+
* Every peer that later folds the join resolves `inviter_identity` — the
|
|
10
|
+
* signing key — to an account through the namespace's device bindings, and then
|
|
11
|
+
* checks that account holds admin or `CAN_INVITE_MEMBERS` at the join's causal
|
|
12
|
+
* cut. So the device key must be **bound** in the namespace first. It is when
|
|
13
|
+
* the account joined through an invitation; an account that was added by
|
|
14
|
+
* account (`MemberAdded`) has no binding, and a relay has to carry its device
|
|
15
|
+
* link first — see `AdminApiClient.linkAccountDevice` and `signDeviceScope`.
|
|
16
|
+
* Without that, every peer refuses the join as signed by a key "bound to no
|
|
17
|
+
* account".
|
|
18
|
+
*
|
|
19
|
+
* Derived from core:
|
|
20
|
+
*
|
|
21
|
+
* - `GroupInvitationFromAdmin` / `SignedGroupOpenInvitation` —
|
|
22
|
+
* `crates/context/config/src/types.rs`
|
|
23
|
+
* - the signing — `crates/context/src/handlers/create_group_invitation.rs`:
|
|
24
|
+
* `sign(sha256(borsh(invitation)))`
|
|
25
|
+
* - the admitter default — `NamespaceMembershipService::default_admitters`
|
|
26
|
+
*
|
|
27
|
+
* The unit tests pin bytes and a signature produced by core's own types.
|
|
28
|
+
*/
|
|
29
|
+
import type { GroupInvitationFromAdmin, GroupMember, SignedGroupOpenInvitation } from '../admin-api/admin-types.js';
|
|
30
|
+
import { type Signer } from '../signer/signer.js';
|
|
31
|
+
/**
|
|
32
|
+
* Core's `MAX_INVITATION_VALIDITY_SECS`: one day. Core clamps what a node mints
|
|
33
|
+
* to it, and this does the same, so a leaked invitation is redeemable no longer
|
|
34
|
+
* than one a node would have issued.
|
|
35
|
+
*/
|
|
36
|
+
export declare const MAX_INVITATION_VALIDITY_SECS: number;
|
|
37
|
+
/** `invited_role` values, as core numbers them. */
|
|
38
|
+
export declare const INVITED_ROLE: {
|
|
39
|
+
readonly Admin: 0;
|
|
40
|
+
readonly Member: 1;
|
|
41
|
+
readonly ReadOnly: 2;
|
|
42
|
+
};
|
|
43
|
+
/** Borsh-encode the signed body of an invitation, field for field with core. */
|
|
44
|
+
export declare function encodeGroupInvitation(body: GroupInvitationFromAdmin): Uint8Array;
|
|
45
|
+
/** `sha256(borsh(invitation))`: the 32 bytes the inviter's key signs. */
|
|
46
|
+
export declare function groupInvitationHash(body: GroupInvitationFromAdmin): Promise<Uint8Array>;
|
|
47
|
+
/**
|
|
48
|
+
* The admitters a node names when its caller names none: the group's admins,
|
|
49
|
+
* sorted by account bytes and deduplicated, as core's `BTreeSet` leaves them.
|
|
50
|
+
*
|
|
51
|
+
* Core also adds TEE-admitted accounts, which a member list cannot tell apart
|
|
52
|
+
* from members by role alone; name those in `admitters` explicitly.
|
|
53
|
+
*/
|
|
54
|
+
export declare function defaultAdmitters(members: readonly GroupMember[]): string[];
|
|
55
|
+
/** What {@link signGroupInvitation} needs. */
|
|
56
|
+
export interface GroupInvitationInput {
|
|
57
|
+
/** The group (or namespace) invited to, 64 hex. */
|
|
58
|
+
groupId: string;
|
|
59
|
+
/**
|
|
60
|
+
* The account the inviter acts as, 64 hex. An unsigned bootstrap hint the
|
|
61
|
+
* joiner seeds the group's admin view from; it confers nothing.
|
|
62
|
+
*/
|
|
63
|
+
inviterAccount: string;
|
|
64
|
+
/** The device key as a {@link Signer}. Pass this or {@link deviceSecret}. */
|
|
65
|
+
signer?: Signer;
|
|
66
|
+
/** The device signing secret, 64 hex. Pass this or {@link signer}. */
|
|
67
|
+
deviceSecret?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Accounts permitted to admit a claim, 64 hex each. Signed.
|
|
70
|
+
*
|
|
71
|
+
* Omitted or empty, it defaults to the group's admins, which is what a node
|
|
72
|
+
* does — pass {@link members} (from `listGroupMembers`) to derive them. An
|
|
73
|
+
* empty list would mean *claimable by broadcast*, which publishes the
|
|
74
|
+
* invitation to every subscriber of the namespace topic, so this never
|
|
75
|
+
* produces one.
|
|
76
|
+
*/
|
|
77
|
+
admitters?: readonly string[];
|
|
78
|
+
/** The group's members, to default {@link admitters} from. */
|
|
79
|
+
members?: readonly GroupMember[];
|
|
80
|
+
/**
|
|
81
|
+
* The role granted, as {@link INVITED_ROLE} numbers it. Defaults to Member.
|
|
82
|
+
* Only an admin may invite an admin; peers refuse the join otherwise.
|
|
83
|
+
*/
|
|
84
|
+
invitedRole?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Seconds from now until it expires, clamped to
|
|
87
|
+
* {@link MAX_INVITATION_VALIDITY_SECS} (also the default).
|
|
88
|
+
*/
|
|
89
|
+
validForSecs?: number;
|
|
90
|
+
/** Unix seconds to count from. Defaults to the clock; for tests. */
|
|
91
|
+
now?: number;
|
|
92
|
+
/** The 32-byte uniqueness nonce. Defaults to fresh random bytes. */
|
|
93
|
+
nonce?: Uint8Array;
|
|
94
|
+
/** Unsigned bootstrap: the group's application id, 64 hex. */
|
|
95
|
+
applicationId?: string;
|
|
96
|
+
/** Unsigned bootstrap: the group's `app_key` (core's `bytecode_id`), 64 hex. */
|
|
97
|
+
appKey?: string;
|
|
98
|
+
/** Unsigned bootstrap: multiaddrs for the admitters, `/p2p/<peer>` included. */
|
|
99
|
+
admitterAddrs?: readonly string[];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Sign a group invitation with a device key, returning it in the JSON shape a
|
|
103
|
+
* node's `createGroupInvitation` returns — so it goes to `joinGroup`,
|
|
104
|
+
* `signMemberJoinOp` and `admitJoin` unchanged.
|
|
105
|
+
*/
|
|
106
|
+
export declare function signGroupInvitation(input: GroupInvitationInput): Promise<SignedGroupOpenInvitation>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { concat, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
|
|
2
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
3
|
+
/**
|
|
4
|
+
* Core's `MAX_INVITATION_VALIDITY_SECS`: one day. Core clamps what a node mints
|
|
5
|
+
* to it, and this does the same, so a leaked invitation is redeemable no longer
|
|
6
|
+
* than one a node would have issued.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_INVITATION_VALIDITY_SECS = 24 * 60 * 60;
|
|
9
|
+
/** `invited_role` values, as core numbers them. */
|
|
10
|
+
export const INVITED_ROLE = { Admin: 0, Member: 1, ReadOnly: 2 };
|
|
11
|
+
/** Borsh-encode the signed body of an invitation, field for field with core. */
|
|
12
|
+
export function encodeGroupInvitation(body) {
|
|
13
|
+
const admitters = body.admitters ?? [];
|
|
14
|
+
return concat(
|
|
15
|
+
// `SignerId` and `ContextGroupId` cross JSON as 32 numbers; `AccountId`
|
|
16
|
+
// crosses as hex. Same width, different spelling.
|
|
17
|
+
bytes32(body.inviter_identity, 'inviter_identity'), bytes32(body.group_id, 'group_id'), u64le(body.expiration_timestamp), bytes32(body.secret_salt, 'secret_salt'), new Uint8Array([body.invited_role]), u32le(admitters.length), ...admitters.map((a, i) => fromHex(a, `admitters[${i}]`, 32)));
|
|
18
|
+
}
|
|
19
|
+
/** `sha256(borsh(invitation))`: the 32 bytes the inviter's key signs. */
|
|
20
|
+
export async function groupInvitationHash(body) {
|
|
21
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', encodeGroupInvitation(body)));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The admitters a node names when its caller names none: the group's admins,
|
|
25
|
+
* sorted by account bytes and deduplicated, as core's `BTreeSet` leaves them.
|
|
26
|
+
*
|
|
27
|
+
* Core also adds TEE-admitted accounts, which a member list cannot tell apart
|
|
28
|
+
* from members by role alone; name those in `admitters` explicitly.
|
|
29
|
+
*/
|
|
30
|
+
export function defaultAdmitters(members) {
|
|
31
|
+
const admins = new Set(members.filter((m) => m.role === 'Admin').map((m) => m.identity.toLowerCase()));
|
|
32
|
+
// Lowercase hex sorts as the bytes do.
|
|
33
|
+
return [...admins].sort();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Sign a group invitation with a device key, returning it in the JSON shape a
|
|
37
|
+
* node's `createGroupInvitation` returns — so it goes to `joinGroup`,
|
|
38
|
+
* `signMemberJoinOp` and `admitJoin` unchanged.
|
|
39
|
+
*/
|
|
40
|
+
export async function signGroupInvitation(input) {
|
|
41
|
+
const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
42
|
+
const inviterKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
43
|
+
const groupId = fromHex(input.groupId, 'groupId', 32);
|
|
44
|
+
fromHex(input.inviterAccount, 'inviterAccount', 32);
|
|
45
|
+
let admitters = [...(input.admitters ?? [])].map((a) => a.trim().toLowerCase());
|
|
46
|
+
if (admitters.length === 0) {
|
|
47
|
+
if (!input.members) {
|
|
48
|
+
throw new Error('name the admitters, or pass the group members to default them to its admins: ' +
|
|
49
|
+
'an invitation with no admitters is claimable by broadcast');
|
|
50
|
+
}
|
|
51
|
+
admitters = defaultAdmitters(input.members);
|
|
52
|
+
if (admitters.length === 0) {
|
|
53
|
+
throw new Error('the member list names no admin, so there is nobody to default the admitters to');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
admitters.forEach((a, i) => fromHex(a, `admitters[${i}]`, 32));
|
|
57
|
+
const invitedRole = input.invitedRole ?? INVITED_ROLE.Member;
|
|
58
|
+
if (![0, 1, 2].includes(invitedRole)) {
|
|
59
|
+
throw new Error(`invitedRole must be 0 (Admin), 1 (Member) or 2 (ReadOnly), got ${invitedRole}`);
|
|
60
|
+
}
|
|
61
|
+
const now = input.now ?? Math.floor(Date.now() / 1000);
|
|
62
|
+
const validFor = Math.min(input.validForSecs ?? MAX_INVITATION_VALIDITY_SECS, MAX_INVITATION_VALIDITY_SECS);
|
|
63
|
+
if (!Number.isInteger(validFor) || validFor <= 0) {
|
|
64
|
+
throw new Error(`validForSecs must be a positive whole number, got ${input.validForSecs}`);
|
|
65
|
+
}
|
|
66
|
+
const nonce = input.nonce ?? crypto.getRandomValues(new Uint8Array(32));
|
|
67
|
+
if (nonce.length !== 32) {
|
|
68
|
+
throw new Error(`nonce must be 32 bytes, got ${nonce.length}`);
|
|
69
|
+
}
|
|
70
|
+
const invitation = {
|
|
71
|
+
inviter_identity: Array.from(inviterKey),
|
|
72
|
+
group_id: Array.from(groupId),
|
|
73
|
+
expiration_timestamp: now + validFor,
|
|
74
|
+
secret_salt: Array.from(nonce),
|
|
75
|
+
invited_role: invitedRole,
|
|
76
|
+
admitters,
|
|
77
|
+
};
|
|
78
|
+
const signature = await signer.sign(await groupInvitationHash(invitation));
|
|
79
|
+
if (signature.length !== 64) {
|
|
80
|
+
throw new Error(`signer returned ${signature.length} bytes, expected a 64-byte Ed25519 signature`);
|
|
81
|
+
}
|
|
82
|
+
const signed = {
|
|
83
|
+
invitation,
|
|
84
|
+
inviter_signature: hex(signature),
|
|
85
|
+
inviter_account: input.inviterAccount.trim().toLowerCase(),
|
|
86
|
+
};
|
|
87
|
+
if (input.admitterAddrs?.length)
|
|
88
|
+
signed.admitter_addrs = [...input.admitterAddrs];
|
|
89
|
+
if (input.applicationId) {
|
|
90
|
+
signed.application_id = Array.from(fromHex(input.applicationId, 'applicationId', 32));
|
|
91
|
+
}
|
|
92
|
+
if (input.appKey)
|
|
93
|
+
signed.app_key = Array.from(fromHex(input.appKey, 'appKey', 32));
|
|
94
|
+
// The brand marks a value as one to pass through unchanged. This one is
|
|
95
|
+
// signed, so it qualifies the same way a node's does.
|
|
96
|
+
return signed;
|
|
97
|
+
}
|
|
98
|
+
/** A required `[u8; 32]`, as JSON carries it: 32 byte values. */
|
|
99
|
+
function bytes32(value, label) {
|
|
100
|
+
if (value.length !== 32) {
|
|
101
|
+
throw new Error(`${label} must be 32 bytes, got ${value.length}`);
|
|
102
|
+
}
|
|
103
|
+
return new Uint8Array(value);
|
|
104
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encodeGroupInvitation } from '../invitation/invitation.js';
|
|
1
2
|
import { concat, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
|
|
2
3
|
import { resolveSigner } from '../signer/signer.js';
|
|
3
4
|
/**
|
|
@@ -38,13 +39,6 @@ const ROOT_OP = {
|
|
|
38
39
|
NamespaceCreatedV2: 9,
|
|
39
40
|
MemberJoinedViaTeeAttestation: 10,
|
|
40
41
|
};
|
|
41
|
-
/** A required `[u8; 32]`, as JSON carries it: 32 byte values. */
|
|
42
|
-
function bytes32(value, label) {
|
|
43
|
-
if (value.length !== 32) {
|
|
44
|
-
throw new Error(`${label} must be 32 bytes, got ${value.length}`);
|
|
45
|
-
}
|
|
46
|
-
return new Uint8Array(value);
|
|
47
|
-
}
|
|
48
42
|
/**
|
|
49
43
|
* Borsh `Option<[u8; 32]>` from the byte array JSON carries it as.
|
|
50
44
|
*
|
|
@@ -78,15 +72,11 @@ function borshString(value) {
|
|
|
78
72
|
*/
|
|
79
73
|
export function encodeSignedInvitation(signed) {
|
|
80
74
|
const body = signed.invitation;
|
|
81
|
-
const admitters = body.admitters ?? [];
|
|
82
75
|
const admitterAddrs = signed.admitter_addrs ?? [];
|
|
83
76
|
return concat(
|
|
84
|
-
// GroupInvitationFromAdmin
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
// crosses as hex. Same width, different spelling, and reading either the
|
|
88
|
-
// wrong way yields 32 bytes that encode cleanly and verify as nothing.
|
|
89
|
-
bytes32(body.inviter_identity, 'inviter_identity'), bytes32(body.group_id, 'group_id'), u64le(body.expiration_timestamp), bytes32(body.secret_salt, 'secret_salt'), new Uint8Array([body.invited_role]), u32le(admitters.length), ...admitters.map((a, i) => fromHex(a, `admitters[${i}]`, 32)),
|
|
77
|
+
// GroupInvitationFromAdmin, from the one encoder the SDK's own invitation
|
|
78
|
+
// signer uses: the bytes signed and the bytes carried must not drift.
|
|
79
|
+
encodeGroupInvitation(body),
|
|
90
80
|
// ...then the enclosing SignedGroupOpenInvitation, in declaration order.
|
|
91
81
|
// `inviter_account` sits between the signature and the addresses; putting it
|
|
92
82
|
// anywhere else still encodes 32 bytes and still verifies as nothing.
|