@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.
- package/dist/admin-api/admin-client.d.ts +19 -1
- package/dist/admin-api/admin-client.js +20 -0
- package/dist/admin-api/admin-types.d.ts +63 -0
- package/dist/crypto/internal.d.ts +30 -0
- package/dist/crypto/internal.js +93 -0
- package/dist/device-cert/device-cert.d.ts +68 -0
- package/dist/device-cert/device-cert.js +110 -0
- package/dist/device-cert/index.d.ts +2 -0
- package/dist/device-cert/index.js +2 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +126 -21
- 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 -21
- package/dist/index.mjs.map +3 -3
- package/dist/warrant/warrant.d.ts +1 -0
- package/dist/warrant/warrant.js +1 -79
- package/package.json +1 -1
|
@@ -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
|
+
}
|