@calimero-network/mero-js 15.0.0 → 15.2.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.
@@ -1,5 +1,5 @@
1
1
  import { HttpClient } from '../http-client/index.js';
2
- import type { HealthStatus, AdminAuthStatus, InstallApplicationRequest, InstallApplicationResponseData, InstallDevApplicationRequest, UninstallApplicationResponseData, ListApplicationsResponseData, GetApplicationResponseData, ApplicationVersionEntry, GetApplicationAbiResponseData, GetLatestVersionResponseData, ListPackagesResponseData, ListVersionsResponseData, CreateContextRequest, CreateContextResponseData, DeleteContextRequest, DeleteContextResponseData, GetContextsResponseData, Context, GenerateContextIdentityResponseData, GetContextIdentitiesResponseData, JoinContextResponseData, JoinSubgroupInheritanceResponseData, ContextGroupResponseData, ContextStorageResponseData, InviteSpecializedNodeRequest, InviteSpecializedNodeResponseData, UpdateContextApplicationRequest, ResyncContextRequest, ResyncContextResponseData, ContextsWithExecutorsResponseData, UploadBlobRequest, UploadBlobResponseData, DeleteBlobResponseData, ListBlobsResponseData, GetBlobInfoResponseData, CreateContextAliasRequest, CreateApplicationAliasRequest, CreateAliasResponseData, LookupAliasResponseData, DeleteAliasResponseData, ListAliasesResponseData, ListNamespacesResponseData, CreateNamespaceRequest, CreateNamespaceResponseData, DeleteNamespaceRequest, DeleteNamespaceResponseData, CreateNamespaceInvitationRequest, CreateNamespaceInvitationResponseData, CreateRecursiveInvitationResponseData, JoinNamespaceRequest, JoinNamespaceResponseData, CreateGroupInNamespaceRequest, CreateGroupInNamespaceResponseData, SubgroupEntry, Namespace, NamespaceIdentity, NodeIdentity, GroupInfoResponseData, DeleteGroupRequest, DeleteGroupResponseData, ListGroupMembersResponseData, ListGroupContextsResponseData, AddGroupMembersRequest, RemoveGroupMembersRequest, UpdateMemberRoleRequest, MemberCapabilities, SetMemberCapabilitiesRequest, SetDefaultCapabilitiesRequest, SetSubgroupVisibilityRequest, SetTeeAdmissionPolicyRequest, GetTeeAdmissionPolicyResponseData, SetGroupMetadataRequest, SetMemberMetadataRequest, SetContextMetadataRequest, MetadataRecord, SyncGroupRequest, SyncGroupResponseData, UpgradeGroupRequest, UpgradeGroupResponseData, GroupUpgradeStatusResponseData, MigrationStatus, CascadeStatusEntry, RetryGroupUpgradeRequest, RetryGroupUpgradeResponseData, ReparentGroupRequest, ReparentGroupResponseData, DetachContextFromGroupRequest, CreateGroupInvitationRequest, CreateGroupInvitationResponseData, CreateRecursiveGroupInvitationResponseData, JoinGroupRequest, JoinGroupResponseData, TeeInfoResponseData, TeeAttestRequest, TeeAttestResponseData, TeeVerifyQuoteRequest, TeeVerifyQuoteResponseData, PerformIntentRequest, PerformIntentResponseData } from './admin-types.js';
2
+ import type { HealthStatus, AdminAuthStatus, InstallApplicationRequest, InstallApplicationResponseData, InstallDevApplicationRequest, UninstallApplicationResponseData, ListApplicationsResponseData, GetApplicationResponseData, ApplicationVersionEntry, GetApplicationAbiResponseData, GetLatestVersionResponseData, ListPackagesResponseData, ListVersionsResponseData, CreateContextRequest, CreateContextResponseData, DeleteContextRequest, DeleteContextResponseData, GetContextsResponseData, Context, GenerateContextIdentityResponseData, GetContextIdentitiesResponseData, JoinContextResponseData, JoinSubgroupInheritanceResponseData, ContextGroupResponseData, ContextStorageResponseData, InviteSpecializedNodeRequest, InviteSpecializedNodeResponseData, UpdateContextApplicationRequest, ResyncContextRequest, ResyncContextResponseData, ContextsWithExecutorsResponseData, UploadBlobRequest, UploadBlobResponseData, DeleteBlobResponseData, ListBlobsResponseData, GetBlobInfoResponseData, CreateContextAliasRequest, CreateApplicationAliasRequest, CreateAliasResponseData, LookupAliasResponseData, DeleteAliasResponseData, ListAliasesResponseData, ListNamespacesResponseData, CreateNamespaceRequest, CreateNamespaceResponseData, DeleteNamespaceRequest, DeleteNamespaceResponseData, CreateNamespaceInvitationRequest, CreateNamespaceInvitationResponseData, CreateRecursiveInvitationResponseData, JoinNamespaceRequest, JoinNamespaceResponseData, CreateGroupInNamespaceRequest, CreateGroupInNamespaceResponseData, SubgroupEntry, Namespace, NamespaceIdentity, NodeIdentity, GroupInfoResponseData, DeleteGroupRequest, DeleteGroupResponseData, ListGroupMembersResponseData, ListGroupContextsResponseData, AddGroupMembersRequest, RemoveGroupMembersRequest, UpdateMemberRoleRequest, MemberCapabilities, SetMemberCapabilitiesRequest, SetDefaultCapabilitiesRequest, SetSubgroupVisibilityRequest, SetTeeAdmissionPolicyRequest, GetTeeAdmissionPolicyResponseData, SetGroupMetadataRequest, SetMemberMetadataRequest, SetContextMetadataRequest, MetadataRecord, SyncGroupRequest, SyncGroupResponseData, UpgradeGroupRequest, UpgradeGroupResponseData, GroupUpgradeStatusResponseData, MigrationStatus, CascadeStatusEntry, RetryGroupUpgradeRequest, RetryGroupUpgradeResponseData, ReparentGroupRequest, ReparentGroupResponseData, DetachContextFromGroupRequest, CreateGroupInvitationRequest, CreateGroupInvitationResponseData, CreateRecursiveGroupInvitationResponseData, JoinGroupRequest, JoinGroupResponseData, TeeInfoResponseData, TeeAttestRequest, TeeAttestResponseData, TeeVerifyQuoteRequest, TeeVerifyQuoteResponseData, PerformIntentRequest, PerformIntentResponseData, AdmitJoinRequest, AdmitJoinResponseData } from './admin-types.js';
3
3
  /**
4
4
  * Compare two version strings by SemVer 2.0 precedence, ascending: negative if
5
5
  * `a < b`, positive if `a > b`, `0` if equal. Build metadata (`+...`) is stripped
@@ -159,6 +159,24 @@ export declare class AdminApiClient {
159
159
  createNamespace(request: CreateNamespaceRequest): Promise<CreateNamespaceResponseData>;
160
160
  deleteNamespace(namespaceId: string, request?: DeleteNamespaceRequest): Promise<DeleteNamespaceResponseData>;
161
161
  createNamespaceInvitation(namespaceId: string, request?: CreateNamespaceInvitationRequest): Promise<CreateNamespaceInvitationResponseData | CreateRecursiveInvitationResponseData>;
162
+ /**
163
+ * Present a join this caller signed to a node the inviter named as an
164
+ * admitter.
165
+ *
166
+ * For a member with no node. `joinNamespace` publishes the membership op from
167
+ * the node making the call; this hands an already-signed op to somebody else's
168
+ * node to publish. The distinction matters because a keyholder has nowhere to
169
+ * publish from.
170
+ *
171
+ * The op must be signed by the **device key** in the credential it carries —
172
+ * every peer checks that when applying a join. So the admitter cannot author
173
+ * this, only carry it: a hostile one can refuse, and nothing else. It cannot
174
+ * admit a different account, change the group, or grant a role.
175
+ *
176
+ * @param namespaceId the namespace being joined, 64 hex
177
+ * @param request the invitation and the hex-encoded signed op
178
+ */
179
+ admitJoin(namespaceId: string, request: AdmitJoinRequest): Promise<AdmitJoinResponseData>;
162
180
  joinNamespace(namespaceId: string, request: JoinNamespaceRequest): Promise<JoinNamespaceResponseData>;
163
181
  createGroupInNamespace(namespaceId: string, request?: CreateGroupInNamespaceRequest): Promise<CreateGroupInNamespaceResponseData>;
164
182
  listNamespaceGroups(namespaceId: string): Promise<SubgroupEntry[]>;
@@ -466,6 +466,26 @@ export class AdminApiClient {
466
466
  async createNamespaceInvitation(namespaceId, request) {
467
467
  return unwrap(await this.httpClient.post(`/admin-api/namespaces/${namespaceId}/invite`, request ?? {}));
468
468
  }
469
+ /**
470
+ * Present a join this caller signed to a node the inviter named as an
471
+ * admitter.
472
+ *
473
+ * For a member with no node. `joinNamespace` publishes the membership op from
474
+ * the node making the call; this hands an already-signed op to somebody else's
475
+ * node to publish. The distinction matters because a keyholder has nowhere to
476
+ * publish from.
477
+ *
478
+ * The op must be signed by the **device key** in the credential it carries —
479
+ * every peer checks that when applying a join. So the admitter cannot author
480
+ * this, only carry it: a hostile one can refuse, and nothing else. It cannot
481
+ * admit a different account, change the group, or grant a role.
482
+ *
483
+ * @param namespaceId the namespace being joined, 64 hex
484
+ * @param request the invitation and the hex-encoded signed op
485
+ */
486
+ async admitJoin(namespaceId, request) {
487
+ return unwrap(await this.httpClient.post(`/admin-api/namespaces/${namespaceId}/admit`, request, { timeoutMs: 65000 }));
488
+ }
469
489
  async joinNamespace(namespaceId, request) {
470
490
  const data = unwrap(await this.httpClient.post(`/admin-api/namespaces/${namespaceId}/join`, request, { timeoutMs: 65000 }));
471
491
  // core 0.11.0-rc.25 renamed this response's `groupId` to `namespaceId`
@@ -240,6 +240,18 @@ export interface GroupInvitationFromAdmin {
240
240
  /** Per-invitation nonce; core keeps the legacy `secret_salt` wire name. */
241
241
  readonly secret_salt: number[];
242
242
  readonly invited_role: number;
243
+ /**
244
+ * Accounts permitted to admit a claim of this invitation, 64 hex characters
245
+ * each — the same spelling every other account-addressing field uses.
246
+ *
247
+ * Empty means admission by broadcast: the joiner announces itself on the
248
+ * namespace topic and any ready peer answers — which publishes the invitation
249
+ * to every subscriber of that topic. Naming admitters keeps it off the topic,
250
+ * and the joiner presents it to one of them directly.
251
+ *
252
+ * Inside the inviter's signature, so it cannot be redirected.
253
+ */
254
+ readonly admitters?: readonly string[];
243
255
  }
244
256
  /**
245
257
  * An invitation blob the node signed. **Opaque: pass it through unchanged.**
@@ -260,6 +272,22 @@ export interface GroupInvitationFromAdmin {
260
272
  export interface SignedGroupOpenInvitation {
261
273
  readonly invitation: GroupInvitationFromAdmin;
262
274
  readonly inviter_signature: string;
275
+ /**
276
+ * Where to reach the accounts in {@link GroupInvitationFromAdmin.admitters}.
277
+ *
278
+ * Outside the signature, like the other bootstrap hints: an account is reached
279
+ * through a peer id, and a joiner that has synced nothing cannot resolve one.
280
+ * A wrong hint costs a failed connection — the admitting node still has to be
281
+ * in the signed list for its admission to count.
282
+ *
283
+ * `{ multiaddr }` for a joiner that runs a node, `{ url }` for one that holds
284
+ * only a key and reaches the admin API over HTTPS.
285
+ */
286
+ readonly admitter_hints?: ReadonlyArray<{
287
+ readonly multiaddr: string;
288
+ } | {
289
+ readonly url: string;
290
+ }>;
263
291
  /**
264
292
  * The account the inviter acts as, as 64 hex characters — not the
265
293
  * `inviter_identity` key inside the signed body is written in. Governance
@@ -798,6 +826,18 @@ export interface CreateGroupInvitationRequest {
798
826
  requester?: string;
799
827
  expirationTimestamp?: number;
800
828
  recursive?: boolean;
829
+ /**
830
+ * Accounts permitted to admit a claim of this invitation, 64 hex each.
831
+ *
832
+ * Omitted or empty leaves admission open to broadcast, which publishes the
833
+ * invitation to every subscriber of the namespace topic. Naming admitters
834
+ * keeps it off the topic: the joiner presents the claim to one of them and
835
+ * every other peer refuses to answer for it.
836
+ *
837
+ * Core signs this list into the invitation, so it cannot be redirected after
838
+ * the fact.
839
+ */
840
+ admitters?: string[];
801
841
  }
802
842
  export interface CreateGroupInvitationResponseData {
803
843
  invitation: SignedGroupOpenInvitation;
@@ -884,3 +924,26 @@ export interface AdminApiClientConfig {
884
924
  getAuthToken?: () => Promise<string | undefined>;
885
925
  timeoutMs?: number;
886
926
  }
927
+ /** A join signed by its subject, handed to an admitter to publish. */
928
+ export interface AdmitJoinRequest {
929
+ /** The invitation being claimed. Must name the admitter in `admitters`. */
930
+ readonly invitation: SignedGroupOpenInvitation;
931
+ /**
932
+ * The signed `SignedNamespaceOp`, borsh-encoded and hex.
933
+ *
934
+ * Signed by the joiner's device key, which is what stops an admitter
935
+ * substituting a different member: peers check the op's signer against the
936
+ * credential the op carries.
937
+ */
938
+ readonly signedOp: string;
939
+ }
940
+ /** What the admitter did with it. */
941
+ export interface AdmitJoinResponseData {
942
+ /**
943
+ * Whether the op reached the namespace topic.
944
+ *
945
+ * Not "joined" — membership lands when peers apply the op, which the admitter
946
+ * neither performs nor waits for.
947
+ */
948
+ readonly published: boolean;
949
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Byte-level primitives shared by everything in this package that signs.
3
+ *
4
+ * Extracted from `warrant/warrant.ts` when the device-cert signer arrived and
5
+ * needed the same `domainHash`, hex handling and ed25519 import. Keeping one
6
+ * copy is the point: these have to stay byte-identical with core, and two
7
+ * copies drifting apart is exactly the failure the byte-parity e2e exists to
8
+ * catch. Internal — not exported from the package root.
9
+ */
10
+ /** Little-endian u32, for borsh fixed-width fields and epoch counters. */
11
+ export declare function u32le(value: number): Uint8Array;
12
+ export declare function hex(bytes: Uint8Array): string;
13
+ export declare function fromHex(value: string, label: string, expectedBytes: number): Uint8Array;
14
+ export declare function u64le(value: number | bigint): Uint8Array;
15
+ export declare function concat(...parts: Uint8Array[]): Uint8Array;
16
+ /**
17
+ * core's `domain_hash`: SHA-256 over the length-prefixed domain, then each
18
+ * length-prefixed part. The lengths are what stop two different field splits from
19
+ * hashing alike.
20
+ */
21
+ export declare function domainHash(domain: Uint8Array, parts: Uint8Array[]): Promise<Uint8Array>;
22
+ export declare function importSigningKey(secretHex: string): Promise<CryptoKey>;
23
+ /**
24
+ * The public half of an ed25519 seed.
25
+ *
26
+ * Derived rather than taken as an argument, for the reason core does the same: a
27
+ * caller able to name a key it does not hold could produce a warrant it cannot
28
+ * sign, and the field would stop meaning "who authorised this".
29
+ */
30
+ export declare function derivePublicKey(secretHex: string): Promise<Uint8Array>;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Byte-level primitives shared by everything in this package that signs.
3
+ *
4
+ * Extracted from `warrant/warrant.ts` when the device-cert signer arrived and
5
+ * needed the same `domainHash`, hex handling and ed25519 import. Keeping one
6
+ * copy is the point: these have to stay byte-identical with core, and two
7
+ * copies drifting apart is exactly the failure the byte-parity e2e exists to
8
+ * catch. Internal — not exported from the package root.
9
+ */
10
+ const PKCS8_ED25519_PREFIX = new Uint8Array([
11
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04,
12
+ 0x22, 0x04, 0x20,
13
+ ]);
14
+ /** Little-endian u32, for borsh fixed-width fields and epoch counters. */
15
+ export function u32le(value) {
16
+ const out = new Uint8Array(4);
17
+ new DataView(out.buffer).setUint32(0, value, true);
18
+ return out;
19
+ }
20
+ export function hex(bytes) {
21
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
22
+ }
23
+ export function fromHex(value, label, expectedBytes) {
24
+ const clean = value.trim();
25
+ if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length !== expectedBytes * 2) {
26
+ throw new Error(`${label} must be ${expectedBytes * 2} hex characters, got ${clean.length}`);
27
+ }
28
+ // Indexed rather than `match(/../g)!`: the length is already validated above,
29
+ // so the assertion would only be telling the compiler what the guard proved,
30
+ // and this needs no assertion at all.
31
+ const bytes = new Uint8Array(expectedBytes);
32
+ for (let i = 0; i < expectedBytes; i += 1) {
33
+ bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
34
+ }
35
+ return bytes;
36
+ }
37
+ export function u64le(value) {
38
+ const out = new Uint8Array(8);
39
+ new DataView(out.buffer).setBigUint64(0, BigInt(value), true);
40
+ return out;
41
+ }
42
+ export function concat(...parts) {
43
+ const out = new Uint8Array(parts.reduce((total, p) => total + p.length, 0));
44
+ let offset = 0;
45
+ for (const part of parts) {
46
+ out.set(part, offset);
47
+ offset += part.length;
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * core's `domain_hash`: SHA-256 over the length-prefixed domain, then each
53
+ * length-prefixed part. The lengths are what stop two different field splits from
54
+ * hashing alike.
55
+ */
56
+ export async function domainHash(domain, parts) {
57
+ const chunks = [u64le(domain.length), domain];
58
+ for (const part of parts) {
59
+ chunks.push(u64le(part.length), part);
60
+ }
61
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', concat(...chunks)));
62
+ }
63
+ export async function importSigningKey(secretHex) {
64
+ const seed = fromHex(secretHex, 'deviceSecret', 32);
65
+ const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
66
+ try {
67
+ return await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
68
+ }
69
+ catch (cause) {
70
+ // The original message is folded in rather than passed as `cause`: this
71
+ // package targets ES2020, where `Error` has no `cause` option.
72
+ const detail = cause instanceof Error ? `: ${cause.message}` : '';
73
+ throw new Error('this runtime has no WebCrypto Ed25519, so a warrant cannot be signed ' +
74
+ 'here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it; ' +
75
+ 'otherwise mint the warrant with `merod account warrant`' +
76
+ detail);
77
+ }
78
+ }
79
+ /**
80
+ * The public half of an ed25519 seed.
81
+ *
82
+ * Derived rather than taken as an argument, for the reason core does the same: a
83
+ * caller able to name a key it does not hold could produce a warrant it cannot
84
+ * sign, and the field would stop meaning "who authorised this".
85
+ */
86
+ export async function derivePublicKey(secretHex) {
87
+ const seed = fromHex(secretHex, 'deviceSecret', 32);
88
+ const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
89
+ const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
90
+ const jwk = await crypto.subtle.exportKey('jwk', key);
91
+ const raw = jwk.x;
92
+ return Uint8Array.from(atob(raw.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
93
+ }
@@ -0,0 +1,68 @@
1
+ /** What a root certifies about one device. */
2
+ export interface DeviceCertInput {
3
+ /** The account root's signing secret, 64 hex. Signs the certificate. */
4
+ rootSecret: string;
5
+ /** The device being certified, 64 hex — see {@link mintDeviceId}. */
6
+ device: string;
7
+ /** The key that device signs with, 64 hex. */
8
+ signPublicKey: string;
9
+ /** The key the group key is delivered to, 64 hex (X25519). */
10
+ kemPublicKey: string;
11
+ /**
12
+ * Must strictly exceed any epoch already folded for this device.
13
+ *
14
+ * The projection refuses a link that does not advance it, so a re-issued
15
+ * certificate reusing an epoch is inert rather than a rollback.
16
+ */
17
+ deviceEpoch: number;
18
+ }
19
+ /**
20
+ * Mint a device id: `H(DEVICE_ID_DOMAIN, account ‖ nonce)`.
21
+ *
22
+ * Derived from the account and a fresh nonce rather than from the device's keys,
23
+ * so rotating a keypair keeps the replica identity — and with it the counter
24
+ * slots and HLC lineage — intact.
25
+ */
26
+ export declare function mintDeviceId(account: string, nonce: Uint8Array): Promise<string>;
27
+ /**
28
+ * The 32 bytes a root signs to certify a device.
29
+ *
30
+ * Both keys are covered, so neither the signing key nor the delivery key can be
31
+ * substituted into a certificate that otherwise verifies — which is what stops a
32
+ * relay carrying someone's credential from making itself the reader.
33
+ */
34
+ export declare function deviceCertPayload(input: {
35
+ account: string;
36
+ device: string;
37
+ signPublicKey: string;
38
+ kemPublicKey: string;
39
+ keyEpoch: number;
40
+ deviceEpoch: number;
41
+ }): Promise<Uint8Array>;
42
+ /**
43
+ * Certify a device, returning the hex credential `merod account sign-cert`
44
+ * prints — a borsh-encoded `AccountProof<DeviceCert>`.
45
+ *
46
+ * The layout, field for field, is core's:
47
+ *
48
+ * ```
49
+ * AccountProof { genesis: AccountGenesis, chain: Vec<RootKeyHandoff>, statement: DeviceCert }
50
+ * AccountGenesis { version: u8, root_sign_pk: [u8; 32] }
51
+ * chain → u32-LE count, then that many handoffs
52
+ * DeviceCert { account, device, sign_pk, kem_pk: [u8; 32] x4,
53
+ * key_epoch: u32, device_epoch: u32, signature: [u8; 64] }
54
+ * ```
55
+ *
56
+ * Borsh for plain fixed-width data is concatenation; the only variable part is
57
+ * the chain's length prefix. `chain` is empty here for the same reason it is in
58
+ * `sign-cert`: a root that has never been handed off signs directly, and a
59
+ * handoff chain is only needed once it has.
60
+ *
61
+ * `keyEpoch` is 0 for the same reason — it names the root epoch that signed, and
62
+ * an un-rotated root is at 0. Re-signing after a handoff is a separate flow that
63
+ * needs the chain, so this deliberately does not take it as an argument rather
64
+ * than accepting a value it would then have to ignore.
65
+ */
66
+ export declare function signDeviceCert(input: DeviceCertInput): Promise<string>;
67
+ /** The account this root owns — the content address of its genesis. */
68
+ export declare function accountForRoot(rootSecret: string): Promise<string>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Minting and certifying a device offline, with a key that never reaches a node.
3
+ *
4
+ * This is the second half of the keyholder story. `signWarrant` lets an account
5
+ * with no node *write*; this lets one exist in the first place — an account root
6
+ * certifies a device, and the resulting credential is what a joiner presents.
7
+ *
8
+ * As with the warrant signer, the risk is that this reproduces core's encoding
9
+ * and has to keep doing so. The answer is the same: a golden vector here, and an
10
+ * e2e that diffs this output against `merod account sign-cert` byte for byte.
11
+ *
12
+ * Everything below is derived from core:
13
+ *
14
+ * - `DeviceCert::signing_payload` — `crates/account/src/device.rs`
15
+ * - `DeviceId::mint` — `crates/primitives/src/identity.rs`
16
+ * - `AccountProof` / `AccountGenesis` borsh layout — `crates/account/src/{signed,account}.rs`
17
+ */
18
+ import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, } from '../crypto/internal.js';
19
+ const CERT_DOMAIN = new TextEncoder().encode('calimero.device.cert.v1');
20
+ const ACCOUNT_ID_DOMAIN = new TextEncoder().encode('calimero.account.genesis.v1');
21
+ const DEVICE_ID_DOMAIN = new TextEncoder().encode('calimero.device.id.v1');
22
+ /** `AccountGenesis::version`, which the credential's borsh encoding leads with. */
23
+ const ACCOUNT_GENESIS_VERSION = 1;
24
+ /**
25
+ * Mint a device id: `H(DEVICE_ID_DOMAIN, account ‖ nonce)`.
26
+ *
27
+ * Derived from the account and a fresh nonce rather than from the device's keys,
28
+ * so rotating a keypair keeps the replica identity — and with it the counter
29
+ * slots and HLC lineage — intact.
30
+ */
31
+ export async function mintDeviceId(account, nonce) {
32
+ if (nonce.length !== 16) {
33
+ throw new Error(`nonce must be 16 bytes, got ${nonce.length}`);
34
+ }
35
+ const accountBytes = fromHex(account, 'account', 32);
36
+ return hex(await domainHash(DEVICE_ID_DOMAIN, [accountBytes, nonce]));
37
+ }
38
+ /**
39
+ * The 32 bytes a root signs to certify a device.
40
+ *
41
+ * Both keys are covered, so neither the signing key nor the delivery key can be
42
+ * substituted into a certificate that otherwise verifies — which is what stops a
43
+ * relay carrying someone's credential from making itself the reader.
44
+ */
45
+ export async function deviceCertPayload(input) {
46
+ return domainHash(CERT_DOMAIN, [
47
+ fromHex(input.account, 'account', 32),
48
+ fromHex(input.device, 'device', 32),
49
+ fromHex(input.signPublicKey, 'signPublicKey', 32),
50
+ fromHex(input.kemPublicKey, 'kemPublicKey', 32),
51
+ u32le(input.keyEpoch),
52
+ u32le(input.deviceEpoch),
53
+ ]);
54
+ }
55
+ /**
56
+ * Certify a device, returning the hex credential `merod account sign-cert`
57
+ * prints — a borsh-encoded `AccountProof<DeviceCert>`.
58
+ *
59
+ * The layout, field for field, is core's:
60
+ *
61
+ * ```
62
+ * AccountProof { genesis: AccountGenesis, chain: Vec<RootKeyHandoff>, statement: DeviceCert }
63
+ * AccountGenesis { version: u8, root_sign_pk: [u8; 32] }
64
+ * chain → u32-LE count, then that many handoffs
65
+ * DeviceCert { account, device, sign_pk, kem_pk: [u8; 32] x4,
66
+ * key_epoch: u32, device_epoch: u32, signature: [u8; 64] }
67
+ * ```
68
+ *
69
+ * Borsh for plain fixed-width data is concatenation; the only variable part is
70
+ * the chain's length prefix. `chain` is empty here for the same reason it is in
71
+ * `sign-cert`: a root that has never been handed off signs directly, and a
72
+ * handoff chain is only needed once it has.
73
+ *
74
+ * `keyEpoch` is 0 for the same reason — it names the root epoch that signed, and
75
+ * an un-rotated root is at 0. Re-signing after a handoff is a separate flow that
76
+ * needs the chain, so this deliberately does not take it as an argument rather
77
+ * than accepting a value it would then have to ignore.
78
+ */
79
+ export async function signDeviceCert(input) {
80
+ const rootPublicKey = await derivePublicKey(input.rootSecret);
81
+ const account = hex(await domainHash(ACCOUNT_ID_DOMAIN, [
82
+ concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey),
83
+ ]));
84
+ const keyEpoch = 0;
85
+ const payload = await deviceCertPayload({
86
+ account,
87
+ device: input.device,
88
+ signPublicKey: input.signPublicKey,
89
+ kemPublicKey: input.kemPublicKey,
90
+ keyEpoch,
91
+ deviceEpoch: input.deviceEpoch,
92
+ });
93
+ const key = await importSigningKey(input.rootSecret);
94
+ const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', key, payload));
95
+ const credential = concat(
96
+ // AccountGenesis
97
+ new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey,
98
+ // chain: Vec<RootKeyHandoff>, empty
99
+ u32le(0),
100
+ // DeviceCert
101
+ fromHex(account, 'account', 32), fromHex(input.device, 'device', 32), fromHex(input.signPublicKey, 'signPublicKey', 32), fromHex(input.kemPublicKey, 'kemPublicKey', 32), u32le(keyEpoch), u32le(input.deviceEpoch), signature);
102
+ return hex(credential);
103
+ }
104
+ /** The account this root owns — the content address of its genesis. */
105
+ export async function accountForRoot(rootSecret) {
106
+ const rootPublicKey = await derivePublicKey(rootSecret);
107
+ return hex(await domainHash(ACCOUNT_ID_DOMAIN, [
108
+ concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey),
109
+ ]));
110
+ }
@@ -0,0 +1,2 @@
1
+ export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, } from './device-cert.js';
2
+ export type { DeviceCertInput } from './device-cert.js';
@@ -0,0 +1,2 @@
1
+ // Offline device certification — the account-root half of the keyholder story.
2
+ export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, } from './device-cert.js';