@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.
@@ -0,0 +1,2 @@
1
+ export { signGroupInvitation, encodeGroupInvitation, groupInvitationHash, defaultAdmitters, MAX_INVITATION_VALIDITY_SECS, INVITED_ROLE, } from './invitation.js';
2
+ export type { GroupInvitationInput } from './invitation.js';
@@ -0,0 +1,2 @@
1
+ // Minting a group invitation off-node, with a device key.
2
+ export { signGroupInvitation, encodeGroupInvitation, groupInvitationHash, defaultAdmitters, MAX_INVITATION_VALIDITY_SECS, INVITED_ROLE, } from './invitation.js';
@@ -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 = 16;
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 = 16;
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
- // `SignerId` and `ContextGroupId` cross JSON as 32 numbers; `AccountId`
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
+ }