@calimero-network/mero-js 22.8.0 → 23.0.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 +38 -2
- package/dist/admin-api/admin-client.js +56 -2
- package/dist/admin-api/admin-types.d.ts +94 -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 +435 -34
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +8 -2
- package/dist/index.js +7 -1
- package/dist/index.mjs +435 -34
- 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.d.ts +19 -1
- package/dist/namespace-op/namespace-op.js +23 -15
- package/dist/owner-op/index.d.ts +2 -0
- package/dist/owner-op/index.js +3 -0
- package/dist/owner-op/owner-op.d.ts +119 -0
- package/dist/owner-op/owner-op.js +214 -0
- package/dist/warrant/governance-op.d.ts +31 -1
- package/dist/warrant/governance-op.js +30 -1
- package/dist/warrant/index.d.ts +2 -2
- package/dist/warrant/index.js +1 -1
- 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
|
+
}
|
|
@@ -29,8 +29,26 @@ import { type Signer } from '../signer/signer.js';
|
|
|
29
29
|
* 16: a namespace can be founded through a relay (a delegated
|
|
30
30
|
* `NamespaceCreatedV2`), and `GroupOp::FoundingRelayAttested` lets that relay
|
|
31
31
|
* admit itself as the namespace's first TEE. Again no layout this module signs.
|
|
32
|
+
*
|
|
33
|
+
* 17: `RootOp::GroupCreated` carries a `salt`, and its `group_id` must be the
|
|
34
|
+
* one `createdSubgroupId(admin, parentId, restricted, salt)` derives
|
|
35
|
+
* (calimero-network/core#4244), so two concurrent creates for one id can never
|
|
36
|
+
* name different creators. The layout of that variant changed.
|
|
37
|
+
*
|
|
38
|
+
* 18: owner-level ops need the account ROOT, not only a device of it
|
|
39
|
+
* (calimero-network/core, root-guarded owner ops). `GroupOp::RootGuarded` and
|
|
40
|
+
* `RootOp::RootGuarded` are appended and carry a root-signed proof, and the bare
|
|
41
|
+
* `TransferOwnership`, `AdminChanged`, `GroupDelete` and TEE policy ops are
|
|
42
|
+
* refused. No layout this module signs changes; see `../owner-op` for the proof.
|
|
43
|
+
*
|
|
44
|
+
* 19 is reserved for calimero-network/core#4269.
|
|
45
|
+
*
|
|
46
|
+
* 20: no layout change. A delegated `GroupCreated` whose executor is a TEE at
|
|
47
|
+
* the namespace root now seats that TEE in the new subgroup
|
|
48
|
+
* (calimero-network/core#4276), an apply-time rule older peers disagree on.
|
|
49
|
+
* Core signs and checks at 20, so every op this module signs must too.
|
|
32
50
|
*/
|
|
33
|
-
export declare const SIGNED_NAMESPACE_OP_SCHEMA_VERSION =
|
|
51
|
+
export declare const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 20;
|
|
34
52
|
/**
|
|
35
53
|
* Borsh-encode a `SignedGroupOpenInvitation`.
|
|
36
54
|
*
|
|
@@ -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
|
/**
|
|
@@ -14,8 +15,26 @@ import { resolveSigner } from '../signer/signer.js';
|
|
|
14
15
|
* 16: a namespace can be founded through a relay (a delegated
|
|
15
16
|
* `NamespaceCreatedV2`), and `GroupOp::FoundingRelayAttested` lets that relay
|
|
16
17
|
* admit itself as the namespace's first TEE. Again no layout this module signs.
|
|
18
|
+
*
|
|
19
|
+
* 17: `RootOp::GroupCreated` carries a `salt`, and its `group_id` must be the
|
|
20
|
+
* one `createdSubgroupId(admin, parentId, restricted, salt)` derives
|
|
21
|
+
* (calimero-network/core#4244), so two concurrent creates for one id can never
|
|
22
|
+
* name different creators. The layout of that variant changed.
|
|
23
|
+
*
|
|
24
|
+
* 18: owner-level ops need the account ROOT, not only a device of it
|
|
25
|
+
* (calimero-network/core, root-guarded owner ops). `GroupOp::RootGuarded` and
|
|
26
|
+
* `RootOp::RootGuarded` are appended and carry a root-signed proof, and the bare
|
|
27
|
+
* `TransferOwnership`, `AdminChanged`, `GroupDelete` and TEE policy ops are
|
|
28
|
+
* refused. No layout this module signs changes; see `../owner-op` for the proof.
|
|
29
|
+
*
|
|
30
|
+
* 19 is reserved for calimero-network/core#4269.
|
|
31
|
+
*
|
|
32
|
+
* 20: no layout change. A delegated `GroupCreated` whose executor is a TEE at
|
|
33
|
+
* the namespace root now seats that TEE in the new subgroup
|
|
34
|
+
* (calimero-network/core#4276), an apply-time rule older peers disagree on.
|
|
35
|
+
* Core signs and checks at 20, so every op this module signs must too.
|
|
17
36
|
*/
|
|
18
|
-
export const SIGNED_NAMESPACE_OP_SCHEMA_VERSION =
|
|
37
|
+
export const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 20;
|
|
19
38
|
/** Domain prefixed to the signable bytes; core's `NAMESPACE_GOVERNANCE_SIGN_DOMAIN`. */
|
|
20
39
|
const NAMESPACE_SIGN_DOMAIN = new TextEncoder().encode('calimero.namespace.v1');
|
|
21
40
|
/** `NamespaceOp::Root` is the first variant. */
|
|
@@ -38,13 +57,6 @@ const ROOT_OP = {
|
|
|
38
57
|
NamespaceCreatedV2: 9,
|
|
39
58
|
MemberJoinedViaTeeAttestation: 10,
|
|
40
59
|
};
|
|
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
60
|
/**
|
|
49
61
|
* Borsh `Option<[u8; 32]>` from the byte array JSON carries it as.
|
|
50
62
|
*
|
|
@@ -78,15 +90,11 @@ function borshString(value) {
|
|
|
78
90
|
*/
|
|
79
91
|
export function encodeSignedInvitation(signed) {
|
|
80
92
|
const body = signed.invitation;
|
|
81
|
-
const admitters = body.admitters ?? [];
|
|
82
93
|
const admitterAddrs = signed.admitter_addrs ?? [];
|
|
83
94
|
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)),
|
|
95
|
+
// GroupInvitationFromAdmin, from the one encoder the SDK's own invitation
|
|
96
|
+
// signer uses: the bytes signed and the bytes carried must not drift.
|
|
97
|
+
encodeGroupInvitation(body),
|
|
90
98
|
// ...then the enclosing SignedGroupOpenInvitation, in declaration order.
|
|
91
99
|
// `inviter_account` sits between the signature and the addresses; putting it
|
|
92
100
|
// anywhere else still encodes 32 bytes and still verifies as nothing.
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { OWNER_OP_KIND, transferOwnershipOp, groupDeleteOp, adminChangedOp, teeAuthoringPolicyOp, teeAdmissionPolicyOp, teeReleaseAdmissionPolicyOp, ownerOpDigest, ownerOpSigningPayload, signOwnerOpProof, rootGuardedOpBytes, } from './owner-op.js';
|
|
2
|
+
export type { OwnerOp, OwnerOpKind, OwnerOpPlane, OwnerOpProofInput, TeeAdmissionPolicyOpInput, TeeReleaseAdmissionPolicyOpInput, } from './owner-op.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Root proofs for owner-level governance ops: what a nodeless or cold-root
|
|
2
|
+
// account signs offline so a stolen device alone cannot take a group.
|
|
3
|
+
export { OWNER_OP_KIND, transferOwnershipOp, groupDeleteOp, adminChangedOp, teeAuthoringPolicyOp, teeAdmissionPolicyOp, teeReleaseAdmissionPolicyOp, ownerOpDigest, ownerOpSigningPayload, signOwnerOpProof, rootGuardedOpBytes, } from './owner-op.js';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which owner-level op a proof authorises. The value is the byte core signs,
|
|
3
|
+
* its `OwnerOpKind` discriminant.
|
|
4
|
+
*/
|
|
5
|
+
export declare const OWNER_OP_KIND: {
|
|
6
|
+
readonly transferOwnership: 0;
|
|
7
|
+
readonly adminChanged: 1;
|
|
8
|
+
readonly groupDelete: 2;
|
|
9
|
+
readonly teeAdmissionPolicy: 3;
|
|
10
|
+
readonly teeAuthoringPolicy: 4;
|
|
11
|
+
readonly teeReleaseAdmissionPolicy: 5;
|
|
12
|
+
};
|
|
13
|
+
export type OwnerOpKind = keyof typeof OWNER_OP_KIND;
|
|
14
|
+
/** Which plane the op is on: a `GroupOp` on a group, or a `RootOp` on the namespace. */
|
|
15
|
+
export type OwnerOpPlane = 'group' | 'root';
|
|
16
|
+
/** An encoded owner-level op: its borsh bytes, its kind and its plane. */
|
|
17
|
+
export interface OwnerOp {
|
|
18
|
+
kind: OwnerOpKind;
|
|
19
|
+
plane: OwnerOpPlane;
|
|
20
|
+
/** The op's borsh bytes: exactly what the node builds, and what the digest covers. */
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
/** Hand the group to `newOwner` (an account, hex), who must already be an admin. */
|
|
24
|
+
export declare function transferOwnershipOp(newOwner: string): OwnerOp;
|
|
25
|
+
/** The owner-only deletion of a group that holds no contexts. */
|
|
26
|
+
export declare function groupDeleteOp(): OwnerOp;
|
|
27
|
+
/** Repoint the namespace's admin pin at `newAdmin` (an account, hex). A root op. */
|
|
28
|
+
export declare function adminChangedOp(newAdmin: string): OwnerOp;
|
|
29
|
+
/** The TEE authoring policy `PUT`/`DELETE …/settings/tee-authoring-policy` publishes. */
|
|
30
|
+
export declare function teeAuthoringPolicyOp(allowedMrtd: string[]): OwnerOp;
|
|
31
|
+
/** A measurement-list admission policy, as the node publishes it (the V2 form). */
|
|
32
|
+
export interface TeeAdmissionPolicyOpInput {
|
|
33
|
+
allowedMrtd: string[];
|
|
34
|
+
allowedRtmr0: string[];
|
|
35
|
+
allowedRtmr1: string[];
|
|
36
|
+
allowedRtmr2: string[];
|
|
37
|
+
allowedRtmr3: string[];
|
|
38
|
+
allowedTcbStatuses: string[];
|
|
39
|
+
acceptMock: boolean;
|
|
40
|
+
mode?: 'replica' | 'relay';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The measurement-list admission policy `PUT …/settings/tee-admission-policy`
|
|
44
|
+
* publishes. Pass the fields exactly as you send them: the node signs over the
|
|
45
|
+
* op it builds from them, and any difference is a different op.
|
|
46
|
+
*/
|
|
47
|
+
export declare function teeAdmissionPolicyOp(input: TeeAdmissionPolicyOpInput): OwnerOp;
|
|
48
|
+
/** A signed-release admission policy, as the node publishes it (the V2 form). */
|
|
49
|
+
export interface TeeReleaseAdmissionPolicyOpInput {
|
|
50
|
+
allowedProfiles: string[];
|
|
51
|
+
/**
|
|
52
|
+
* The oldest release admitted. Give it in the node's normalised form
|
|
53
|
+
* (`X.Y.Z`, no tag prefix): the node normalises what it is sent before it
|
|
54
|
+
* builds the op, and the proof must be for the op it builds.
|
|
55
|
+
*/
|
|
56
|
+
minReleaseVersion?: string;
|
|
57
|
+
allowedTcbStatuses: string[];
|
|
58
|
+
acceptMock: boolean;
|
|
59
|
+
mode?: 'replica' | 'relay';
|
|
60
|
+
}
|
|
61
|
+
/** The signed-release admission policy `PUT …/settings/tee-admission-policy` publishes. */
|
|
62
|
+
export declare function teeReleaseAdmissionPolicyOp(input: TeeReleaseAdmissionPolicyOpInput): OwnerOp;
|
|
63
|
+
/** core's `OwnerOpAuthorization::op_digest`: the op's bytes, hashed under the body domain. */
|
|
64
|
+
export declare function ownerOpDigest(opBytes: Uint8Array): Promise<Uint8Array>;
|
|
65
|
+
/** What an owner-op proof binds, apart from the op and the key that signs it. */
|
|
66
|
+
export interface OwnerOpProofInput {
|
|
67
|
+
/** The account root's signing secret, 64 hex. Never sent anywhere. */
|
|
68
|
+
rootSecret: string;
|
|
69
|
+
/** The namespace the op is published in (`getGroupInfo(...).namespaceId`), hex. */
|
|
70
|
+
namespaceId: string;
|
|
71
|
+
/**
|
|
72
|
+
* The group the op acts on, hex. For `adminChanged` and the TEE policies,
|
|
73
|
+
* the namespace root.
|
|
74
|
+
*/
|
|
75
|
+
groupId: string;
|
|
76
|
+
/** The op, from one of the encoders here. */
|
|
77
|
+
op: OwnerOp;
|
|
78
|
+
/** The group's current guarded-op counter (`getGroupInfo(groupId).ownerOpCounter`). */
|
|
79
|
+
counter: number | bigint;
|
|
80
|
+
/**
|
|
81
|
+
* The root epoch that signs, 0 for a root that has never been handed off.
|
|
82
|
+
* A later epoch needs `chain`.
|
|
83
|
+
*/
|
|
84
|
+
keyEpoch?: number;
|
|
85
|
+
/**
|
|
86
|
+
* The account's root-key handoffs, epoch 0 upward, each the hex borsh of a
|
|
87
|
+
* `RootKeyHandoff` (account 32, from_epoch u32, new root key 32, signature
|
|
88
|
+
* 64). It must reach at least `keyEpoch`, and at least the epoch the group
|
|
89
|
+
* has recorded for the account, or the node refuses the proof.
|
|
90
|
+
*/
|
|
91
|
+
chain?: string[];
|
|
92
|
+
}
|
|
93
|
+
/** core's `OwnerOpTerms::signing_payload`. */
|
|
94
|
+
export declare function ownerOpSigningPayload(input: {
|
|
95
|
+
account: string;
|
|
96
|
+
namespaceId: string;
|
|
97
|
+
groupId: string;
|
|
98
|
+
kind: OwnerOpKind;
|
|
99
|
+
opDigest: Uint8Array;
|
|
100
|
+
counter: number | bigint;
|
|
101
|
+
keyEpoch: number;
|
|
102
|
+
}): Promise<Uint8Array>;
|
|
103
|
+
/**
|
|
104
|
+
* Sign a root proof for `op` and return it as core's `rootProof`: the hex
|
|
105
|
+
* borsh of an `AccountProof<OwnerOpAuthorization>`.
|
|
106
|
+
*
|
|
107
|
+
* Layout: `AccountGenesis` (version u8 + root pk 32), the handoff chain (u32
|
|
108
|
+
* count + 132 bytes each), then `OwnerOpAuthorization` (account 32, namespace
|
|
109
|
+
* 32, group 32, kind u8, op digest 32, counter u64, key epoch u32, signature
|
|
110
|
+
* 64).
|
|
111
|
+
*/
|
|
112
|
+
export declare function signOwnerOpProof(input: OwnerOpProofInput): Promise<string>;
|
|
113
|
+
/**
|
|
114
|
+
* The `RootGuarded` wrapper core publishes: the op and its proof, as one
|
|
115
|
+
* `GroupOp` or `RootOp`. A client never sends this to a node (the endpoints
|
|
116
|
+
* take the proof and build the wrapper); it is here so the whole encoding is
|
|
117
|
+
* checkable against core's vector.
|
|
118
|
+
*/
|
|
119
|
+
export declare function rootGuardedOpBytes(op: OwnerOp, rootProof: string): Uint8Array;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root proofs for owner-level governance ops (core schema 18).
|
|
3
|
+
*
|
|
4
|
+
* A device key speaks for its account everywhere in governance, so a stolen
|
|
5
|
+
* device used to be enough to take a group from its owner: promote an
|
|
6
|
+
* accomplice, transfer ownership, remove the owner. Core now refuses the ops
|
|
7
|
+
* that can do that unless they carry an authorisation signed by the account's
|
|
8
|
+
* **root** key, which a device does not hold:
|
|
9
|
+
*
|
|
10
|
+
* - `GroupOp::TransferOwnership`, `GroupOp::GroupDelete` and
|
|
11
|
+
* `RootOp::AdminChanged`, which are owner-only;
|
|
12
|
+
* - the TEE admission, release-admission and authoring policies, which stay
|
|
13
|
+
* admin-level but need the signing admin's own root.
|
|
14
|
+
*
|
|
15
|
+
* A node that holds the account root (as `merod init` provisions) signs the
|
|
16
|
+
* proof itself, so a caller on such a node passes nothing. This module is for
|
|
17
|
+
* the other case: a nodeless account, or a root kept cold, signing offline and
|
|
18
|
+
* passing the hex proof as `rootProof`.
|
|
19
|
+
*
|
|
20
|
+
* What the root signs binds the account, the namespace, the group, the kind of
|
|
21
|
+
* op, a digest of the op's own borsh bytes, the root epoch, and the group's
|
|
22
|
+
* guarded-op counter. Read that counter (`ownerOpCounter`, with `namespaceId`)
|
|
23
|
+
* from `getGroupInfo` first: the node applies a proof only while the group is
|
|
24
|
+
* still at that count, and advances it once the op applies, so each proof is
|
|
25
|
+
* single-use. For `AdminChanged` and the TEE policies the group is the
|
|
26
|
+
* namespace root.
|
|
27
|
+
*
|
|
28
|
+
* Byte layouts follow core's `calimero_account::OwnerOpAuthorization` and
|
|
29
|
+
* `calimero_governance_types::{GroupOp, RootOp}`. They are pinned against core's
|
|
30
|
+
* own vectors (`crates/account/src/tests/owner_op.rs`,
|
|
31
|
+
* `root_guarded_vectors_are_stable` in `crates/governance-types/src/tests.rs`)
|
|
32
|
+
* in `owner-op.test.ts`.
|
|
33
|
+
*/
|
|
34
|
+
import { concat, derivePublicKey, domainHash, fromHex, fromHexUnsized, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
|
|
35
|
+
/** core's `OWNER_OP_SIGN_DOMAIN`: what the root signs under. */
|
|
36
|
+
const OWNER_OP_SIGN_DOMAIN = new TextEncoder().encode('calimero.account.owner-op.v1');
|
|
37
|
+
/** core's `OWNER_OP_BODY_DOMAIN`: what the op's bytes are hashed under. */
|
|
38
|
+
const OWNER_OP_BODY_DOMAIN = new TextEncoder().encode('calimero.account.owner-op.body.v1');
|
|
39
|
+
/** core's `ACCOUNT_ID_DOMAIN`. */
|
|
40
|
+
const ACCOUNT_ID_DOMAIN = new TextEncoder().encode('calimero.account.genesis.v1');
|
|
41
|
+
/** core's `ACCOUNT_GENESIS_VERSION`. */
|
|
42
|
+
const ACCOUNT_GENESIS_VERSION = 2;
|
|
43
|
+
/**
|
|
44
|
+
* Which owner-level op a proof authorises. The value is the byte core signs,
|
|
45
|
+
* its `OwnerOpKind` discriminant.
|
|
46
|
+
*/
|
|
47
|
+
export const OWNER_OP_KIND = {
|
|
48
|
+
transferOwnership: 0,
|
|
49
|
+
adminChanged: 1,
|
|
50
|
+
groupDelete: 2,
|
|
51
|
+
teeAdmissionPolicy: 3,
|
|
52
|
+
teeAuthoringPolicy: 4,
|
|
53
|
+
teeReleaseAdmissionPolicy: 5,
|
|
54
|
+
};
|
|
55
|
+
/** `GroupOp` discriminants, by position in core's enum. */
|
|
56
|
+
const GROUP_OP = {
|
|
57
|
+
GroupDelete: 14,
|
|
58
|
+
TransferOwnership: 21,
|
|
59
|
+
TeeAuthoringPolicySet: 33,
|
|
60
|
+
TeeAdmissionPolicySetV2: 37,
|
|
61
|
+
TeeReleaseAdmissionPolicySetV2: 38,
|
|
62
|
+
RootGuarded: 42,
|
|
63
|
+
};
|
|
64
|
+
/** `RootOp` discriminants, by position in core's enum. */
|
|
65
|
+
const ROOT_OP = {
|
|
66
|
+
AdminChanged: 3,
|
|
67
|
+
RootGuarded: 12,
|
|
68
|
+
};
|
|
69
|
+
/** core's `TeeAdmissionMode` discriminants. */
|
|
70
|
+
const TEE_MODE = { replica: 0, relay: 1 };
|
|
71
|
+
const te = new TextEncoder();
|
|
72
|
+
function tag(byte) {
|
|
73
|
+
return new Uint8Array([byte]);
|
|
74
|
+
}
|
|
75
|
+
function borshString(value) {
|
|
76
|
+
const bytes = te.encode(value);
|
|
77
|
+
return concat(u32le(bytes.length), bytes);
|
|
78
|
+
}
|
|
79
|
+
function borshStrings(values) {
|
|
80
|
+
return concat(u32le(values.length), ...values.map(borshString));
|
|
81
|
+
}
|
|
82
|
+
function borshBool(value) {
|
|
83
|
+
return tag(value ? 1 : 0);
|
|
84
|
+
}
|
|
85
|
+
function teeMode(mode) {
|
|
86
|
+
const byte = TEE_MODE[mode ?? 'replica'];
|
|
87
|
+
if (byte === undefined) {
|
|
88
|
+
throw new Error(`mode must be replica or relay, got ${String(mode)}`);
|
|
89
|
+
}
|
|
90
|
+
return tag(byte);
|
|
91
|
+
}
|
|
92
|
+
/** Hand the group to `newOwner` (an account, hex), who must already be an admin. */
|
|
93
|
+
export function transferOwnershipOp(newOwner) {
|
|
94
|
+
return {
|
|
95
|
+
kind: 'transferOwnership',
|
|
96
|
+
plane: 'group',
|
|
97
|
+
bytes: concat(tag(GROUP_OP.TransferOwnership), fromHex(newOwner, 'newOwner', 32)),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** The owner-only deletion of a group that holds no contexts. */
|
|
101
|
+
export function groupDeleteOp() {
|
|
102
|
+
return { kind: 'groupDelete', plane: 'group', bytes: tag(GROUP_OP.GroupDelete) };
|
|
103
|
+
}
|
|
104
|
+
/** Repoint the namespace's admin pin at `newAdmin` (an account, hex). A root op. */
|
|
105
|
+
export function adminChangedOp(newAdmin) {
|
|
106
|
+
return {
|
|
107
|
+
kind: 'adminChanged',
|
|
108
|
+
plane: 'root',
|
|
109
|
+
bytes: concat(tag(ROOT_OP.AdminChanged), fromHex(newAdmin, 'newAdmin', 32)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** The TEE authoring policy `PUT`/`DELETE …/settings/tee-authoring-policy` publishes. */
|
|
113
|
+
export function teeAuthoringPolicyOp(allowedMrtd) {
|
|
114
|
+
return {
|
|
115
|
+
kind: 'teeAuthoringPolicy',
|
|
116
|
+
plane: 'group',
|
|
117
|
+
bytes: concat(tag(GROUP_OP.TeeAuthoringPolicySet), borshStrings(allowedMrtd)),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The measurement-list admission policy `PUT …/settings/tee-admission-policy`
|
|
122
|
+
* publishes. Pass the fields exactly as you send them: the node signs over the
|
|
123
|
+
* op it builds from them, and any difference is a different op.
|
|
124
|
+
*/
|
|
125
|
+
export function teeAdmissionPolicyOp(input) {
|
|
126
|
+
return {
|
|
127
|
+
kind: 'teeAdmissionPolicy',
|
|
128
|
+
plane: 'group',
|
|
129
|
+
bytes: concat(tag(GROUP_OP.TeeAdmissionPolicySetV2), borshStrings(input.allowedMrtd), borshStrings(input.allowedRtmr0), borshStrings(input.allowedRtmr1), borshStrings(input.allowedRtmr2), borshStrings(input.allowedRtmr3), borshStrings(input.allowedTcbStatuses), borshBool(input.acceptMock), teeMode(input.mode)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** The signed-release admission policy `PUT …/settings/tee-admission-policy` publishes. */
|
|
133
|
+
export function teeReleaseAdmissionPolicyOp(input) {
|
|
134
|
+
const minRelease = input.minReleaseVersion === undefined
|
|
135
|
+
? tag(0)
|
|
136
|
+
: concat(tag(1), borshString(input.minReleaseVersion));
|
|
137
|
+
return {
|
|
138
|
+
kind: 'teeReleaseAdmissionPolicy',
|
|
139
|
+
plane: 'group',
|
|
140
|
+
bytes: concat(tag(GROUP_OP.TeeReleaseAdmissionPolicySetV2), borshStrings(input.allowedProfiles), minRelease, borshStrings(input.allowedTcbStatuses), borshBool(input.acceptMock), teeMode(input.mode)),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** core's `OwnerOpAuthorization::op_digest`: the op's bytes, hashed under the body domain. */
|
|
144
|
+
export async function ownerOpDigest(opBytes) {
|
|
145
|
+
return domainHash(OWNER_OP_BODY_DOMAIN, [opBytes]);
|
|
146
|
+
}
|
|
147
|
+
/** core's `OwnerOpTerms::signing_payload`. */
|
|
148
|
+
export async function ownerOpSigningPayload(input) {
|
|
149
|
+
return domainHash(OWNER_OP_SIGN_DOMAIN, [
|
|
150
|
+
fromHex(input.account, 'account', 32),
|
|
151
|
+
fromHex(input.namespaceId, 'namespaceId', 32),
|
|
152
|
+
fromHex(input.groupId, 'groupId', 32),
|
|
153
|
+
tag(kindByte(input.kind)),
|
|
154
|
+
input.opDigest,
|
|
155
|
+
u64le(input.counter),
|
|
156
|
+
u32le(input.keyEpoch),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
function kindByte(kind) {
|
|
160
|
+
const byte = OWNER_OP_KIND[kind];
|
|
161
|
+
if (byte === undefined) {
|
|
162
|
+
throw new Error(`unknown owner-op kind ${String(kind)}`);
|
|
163
|
+
}
|
|
164
|
+
return byte;
|
|
165
|
+
}
|
|
166
|
+
const HANDOFF_BYTES = 32 + 4 + 32 + 64;
|
|
167
|
+
/**
|
|
168
|
+
* Sign a root proof for `op` and return it as core's `rootProof`: the hex
|
|
169
|
+
* borsh of an `AccountProof<OwnerOpAuthorization>`.
|
|
170
|
+
*
|
|
171
|
+
* Layout: `AccountGenesis` (version u8 + root pk 32), the handoff chain (u32
|
|
172
|
+
* count + 132 bytes each), then `OwnerOpAuthorization` (account 32, namespace
|
|
173
|
+
* 32, group 32, kind u8, op digest 32, counter u64, key epoch u32, signature
|
|
174
|
+
* 64).
|
|
175
|
+
*/
|
|
176
|
+
export async function signOwnerOpProof(input) {
|
|
177
|
+
const rootPublicKey = await derivePublicKey(input.rootSecret);
|
|
178
|
+
const genesis = concat(tag(ACCOUNT_GENESIS_VERSION), rootPublicKey);
|
|
179
|
+
const account = hex(await domainHash(ACCOUNT_ID_DOMAIN, [genesis]));
|
|
180
|
+
const keyEpoch = input.keyEpoch ?? 0;
|
|
181
|
+
const chain = (input.chain ?? []).map((handoff, index) => {
|
|
182
|
+
const bytes = fromHexUnsized(handoff, `chain[${index}]`);
|
|
183
|
+
if (bytes.length !== HANDOFF_BYTES) {
|
|
184
|
+
throw new Error(`chain[${index}] must be a ${HANDOFF_BYTES}-byte RootKeyHandoff, got ${bytes.length}`);
|
|
185
|
+
}
|
|
186
|
+
return bytes;
|
|
187
|
+
});
|
|
188
|
+
if (keyEpoch > chain.length) {
|
|
189
|
+
throw new Error(`keyEpoch ${keyEpoch} needs ${keyEpoch} handoffs in chain, got ${chain.length}`);
|
|
190
|
+
}
|
|
191
|
+
const opDigest = await ownerOpDigest(input.op.bytes);
|
|
192
|
+
const payload = await ownerOpSigningPayload({
|
|
193
|
+
account,
|
|
194
|
+
namespaceId: input.namespaceId,
|
|
195
|
+
groupId: input.groupId,
|
|
196
|
+
kind: input.op.kind,
|
|
197
|
+
opDigest,
|
|
198
|
+
counter: input.counter,
|
|
199
|
+
keyEpoch,
|
|
200
|
+
});
|
|
201
|
+
const key = await importSigningKey(input.rootSecret);
|
|
202
|
+
const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', key, payload));
|
|
203
|
+
return hex(concat(genesis, u32le(chain.length), ...chain, fromHex(account, 'account', 32), fromHex(input.namespaceId, 'namespaceId', 32), fromHex(input.groupId, 'groupId', 32), tag(kindByte(input.op.kind)), opDigest, u64le(input.counter), u32le(keyEpoch), signature));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The `RootGuarded` wrapper core publishes: the op and its proof, as one
|
|
207
|
+
* `GroupOp` or `RootOp`. A client never sends this to a node (the endpoints
|
|
208
|
+
* take the proof and build the wrapper); it is here so the whole encoding is
|
|
209
|
+
* checkable against core's vector.
|
|
210
|
+
*/
|
|
211
|
+
export function rootGuardedOpBytes(op, rootProof) {
|
|
212
|
+
const wrapper = op.plane === 'group' ? GROUP_OP.RootGuarded : ROOT_OP.RootGuarded;
|
|
213
|
+
return concat(tag(wrapper), op.bytes, fromHexUnsized(rootProof, 'rootProof'));
|
|
214
|
+
}
|