@calimero-network/mero-js 15.2.0 → 15.3.1
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-types.d.ts +6 -0
- package/dist/device-cert/device-cert.d.ts +24 -0
- package/dist/device-cert/device-cert.js +27 -7
- package/dist/device-cert/index.d.ts +1 -1
- package/dist/device-cert/index.js +1 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +126 -13
- package/dist/index.cjs.map +3 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +126 -13
- package/dist/index.mjs.map +3 -3
- package/dist/namespace-op/index.d.ts +2 -0
- package/dist/namespace-op/index.js +3 -0
- package/dist/namespace-op/namespace-op.d.ts +64 -0
- package/dist/namespace-op/namespace-op.js +109 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|