@calimero-network/mero-js 15.1.0 → 15.3.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 { signMemberJoinOp, encodeSignedInvitation, SIGNED_NAMESPACE_OP_SCHEMA_VERSION, } from './namespace-op.js';
2
+ export type { SignMemberJoinInput } from './namespace-op.js';
@@ -0,0 +1,3 @@
1
+ // Signing a namespace governance op from the SDK — the membership op a keyholder
2
+ // presents to an admitter, which it could not produce before.
3
+ export { signMemberJoinOp, encodeSignedInvitation, SIGNED_NAMESPACE_OP_SCHEMA_VERSION, } from './namespace-op.js';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Sign a namespace governance op from the SDK — specifically the membership op a
3
+ * keyholder needs to join without running a node.
4
+ *
5
+ * `admitJoin` carries an already-signed op to a node the inviter named as an
6
+ * admitter. Producing that op is the piece a keyholder could not do before: the
7
+ * op must be signed by the device key in the credential it carries, because every
8
+ * peer checks `signer == credential.statement.sign_pk` when applying a join. An
9
+ * admitter can relay it and can never author it.
10
+ *
11
+ * The encoding is borsh, hand-written to match core's structs. Correctness is not
12
+ * asserted against bytes recorded here — a layout transcribed by eye is exactly
13
+ * how a golden ends up confidently wrong. It is asserted by a node accepting the
14
+ * op, which is what the e2e does.
15
+ */
16
+ import type { SignedGroupOpenInvitation } from '../admin-api/admin-types.js';
17
+ /** Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value. */
18
+ export declare const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 7;
19
+ /**
20
+ * Borsh-encode a `SignedGroupOpenInvitation`.
21
+ *
22
+ * Re-encoded rather than passed through, because it has to go inside the op that
23
+ * gets signed. That makes this function version-locked to core: a field added to
24
+ * the invitation and omitted here produces an op whose inner invitation no longer
25
+ * matches the inviter's signature over it. That exact drift, in client-py, broke
26
+ * 93 scenarios — the failure surfaces as `invalid invitation signature` and points
27
+ * nowhere near the encoder.
28
+ */
29
+ export declare function encodeSignedInvitation(signed: SignedGroupOpenInvitation): Uint8Array;
30
+ export interface SignMemberJoinInput {
31
+ /** The namespace being joined, 64 hex. */
32
+ readonly namespaceId: string;
33
+ /** The account joining, 64 hex — the subject of the credential. */
34
+ readonly member: string;
35
+ /** The invitation, as the admin API returned it. */
36
+ readonly invitation: SignedGroupOpenInvitation;
37
+ /**
38
+ * The joiner's `AccountProof<DeviceCert>`, hex — from `signDeviceCert`.
39
+ *
40
+ * Its `sign_pk` must be the public key of `deviceSecret`; a peer checks that
41
+ * when applying the join and refuses otherwise.
42
+ */
43
+ readonly credential: string;
44
+ /** The device signing secret, 64 hex. Must match the credential's `sign_pk`. */
45
+ readonly deviceSecret: string;
46
+ /**
47
+ * Heads this op is written against. Empty means "signed against an empty head"
48
+ * — which is NOT the same as genesis, and a node with a non-empty head will
49
+ * reject it as stale rather than treat it as a first op.
50
+ */
51
+ readonly parentOpHashes?: readonly string[];
52
+ /** Anti-replay nonce; a node keeps a window per signer. */
53
+ readonly nonce: number | bigint;
54
+ /**
55
+ * Use `MemberJoinedAt` instead of `MemberJoined`. The `At` form pins the
56
+ * membership to a position in the log.
57
+ */
58
+ readonly at?: boolean;
59
+ }
60
+ /**
61
+ * Sign a `MemberJoined` (or `MemberJoinedAt`) namespace op and return it
62
+ * borsh-encoded and hex — the `signedOp` that `admitJoin` takes.
63
+ */
64
+ export declare function signMemberJoinOp(input: SignMemberJoinInput): Promise<string>;
@@ -0,0 +1,109 @@
1
+ import { concat, derivePublicKey, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
2
+ /** Core's `SIGNED_NAMESPACE_OP_SCHEMA_VERSION`. A node refuses any other value. */
3
+ export const SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 7;
4
+ /** Domain prefixed to the signable bytes; core's `NAMESPACE_GOVERNANCE_SIGN_DOMAIN`. */
5
+ const NAMESPACE_SIGN_DOMAIN = new TextEncoder().encode('calimero.namespace.v1');
6
+ /** `NamespaceOp::Root` is the first variant. */
7
+ const NAMESPACE_OP_ROOT = 0;
8
+ /**
9
+ * `RootOp` discriminants, in declaration order. Only the two join variants are
10
+ * emitted here; the rest are listed so the numbering is checkable against core
11
+ * rather than inferred from the two in use.
12
+ */
13
+ const ROOT_OP = {
14
+ GroupCreated: 0,
15
+ GroupReparented: 1,
16
+ GroupDeleted: 2,
17
+ AdminChanged: 3,
18
+ PolicyUpdated: 4,
19
+ MemberJoined: 5,
20
+ KeyDelivery: 6,
21
+ MemberJoinedOpen: 7,
22
+ MemberJoinedAt: 8,
23
+ NamespaceCreated: 9,
24
+ MemberJoinedViaTeeAttestation: 10,
25
+ };
26
+ /** A required `[u8; 32]`, as JSON carries it: 32 byte values. */
27
+ function bytes32(value, label) {
28
+ if (value.length !== 32) {
29
+ throw new Error(`${label} must be 32 bytes, got ${value.length}`);
30
+ }
31
+ return new Uint8Array(value);
32
+ }
33
+ /**
34
+ * Borsh `Option<[u8; 32]>` from the byte array JSON carries it as.
35
+ *
36
+ * `[u8; 32]` crosses JSON as 32 numbers, not as hex — the hex spelling belongs to
37
+ * `AccountId`, which has its own representation. Encoding one of these from a hex
38
+ * string would silently produce the wrong 32 bytes.
39
+ */
40
+ function optionalBytes32(value, label) {
41
+ if (value === null || value === undefined) {
42
+ return new Uint8Array([0]);
43
+ }
44
+ if (value.length !== 32) {
45
+ throw new Error(`${label} must be 32 bytes, got ${value.length}`);
46
+ }
47
+ return concat(new Uint8Array([1]), new Uint8Array(value));
48
+ }
49
+ /** Borsh `String`: u32 length then UTF-8 bytes. */
50
+ function borshString(value) {
51
+ const bytes = new TextEncoder().encode(value);
52
+ return concat(u32le(bytes.length), bytes);
53
+ }
54
+ function encodeAdmitterHint(hint) {
55
+ if ('multiaddr' in hint) {
56
+ return concat(new Uint8Array([0]), borshString(hint.multiaddr));
57
+ }
58
+ return concat(new Uint8Array([1]), borshString(hint.url));
59
+ }
60
+ /**
61
+ * Borsh-encode a `SignedGroupOpenInvitation`.
62
+ *
63
+ * Re-encoded rather than passed through, because it has to go inside the op that
64
+ * gets signed. That makes this function version-locked to core: a field added to
65
+ * the invitation and omitted here produces an op whose inner invitation no longer
66
+ * matches the inviter's signature over it. That exact drift, in client-py, broke
67
+ * 93 scenarios — the failure surfaces as `invalid invitation signature` and points
68
+ * nowhere near the encoder.
69
+ */
70
+ export function encodeSignedInvitation(signed) {
71
+ const body = signed.invitation;
72
+ const admitters = body.admitters ?? [];
73
+ const hints = signed.admitter_hints ?? [];
74
+ return concat(
75
+ // GroupInvitationFromAdmin.
76
+ //
77
+ // `SignerId` and `ContextGroupId` cross JSON as 32 numbers; `AccountId`
78
+ // crosses as hex. Same width, different spelling, and reading either the
79
+ // wrong way yields 32 bytes that encode cleanly and verify as nothing.
80
+ 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)),
81
+ // ...then the enclosing SignedGroupOpenInvitation, in declaration order.
82
+ // `inviter_account` sits between the signature and the hints; putting it
83
+ // anywhere else still encodes 32 bytes and still verifies as nothing.
84
+ borshString(signed.inviter_signature), signed.inviter_account
85
+ ? concat(new Uint8Array([1]), fromHex(signed.inviter_account, 'inviter_account', 32))
86
+ : new Uint8Array([0]), u32le(hints.length), ...hints.map(encodeAdmitterHint), optionalBytes32(signed.application_id, 'application_id'),
87
+ // Borsh order follows core's field order, where this is `bytecode_id`; the
88
+ // JSON key is `app_key`, a rename covering both directions that core pins in
89
+ // a test. Encoding by the Rust name would read `undefined` and emit a None
90
+ // where the inviter signed a Some.
91
+ optionalBytes32(signed.app_key, 'app_key'));
92
+ }
93
+ /**
94
+ * Sign a `MemberJoined` (or `MemberJoinedAt`) namespace op and return it
95
+ * borsh-encoded and hex — the `signedOp` that `admitJoin` takes.
96
+ */
97
+ export async function signMemberJoinOp(input) {
98
+ const parents = input.parentOpHashes ?? [];
99
+ const credential = fromHex(input.credential, 'credential', input.credential.length / 2);
100
+ const rootOp = concat(new Uint8Array([input.at ? ROOT_OP.MemberJoinedAt : ROOT_OP.MemberJoined]), fromHex(input.member, 'member', 32), encodeSignedInvitation(input.invitation), credential);
101
+ const op = concat(new Uint8Array([NAMESPACE_OP_ROOT]), rootOp);
102
+ const key = await importSigningKey(input.deviceSecret);
103
+ const signer = await derivePublicKey(input.deviceSecret);
104
+ const signable = concat(new Uint8Array([SIGNED_NAMESPACE_OP_SCHEMA_VERSION]), fromHex(input.namespaceId, 'namespaceId', 32), u32le(parents.length), ...parents.map((p, i) => fromHex(p, `parentOpHashes[${i}]`, 32)), signer, u64le(input.nonce), op);
105
+ const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', key, concat(NAMESPACE_SIGN_DOMAIN, signable)));
106
+ // The signed struct repeats the signable fields, then the signature. The domain
107
+ // is a signing prefix only and is never part of the encoded op.
108
+ return hex(concat(signable, signature));
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calimero-network/mero-js",
3
- "version": "15.1.0",
3
+ "version": "15.3.0",
4
4
  "description": "Pure JavaScript SDK for Calimero",
5
5
  "type": "module",
6
6
  "exports": {