smart-home-engine 1.53.3 → 2.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,192 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Matter groups — the pure half and the store (roadmap M23): she's groups (name, members, which
5
+ * key set), the key sets (16-byte epoch keys, kept encrypted at rest with the secrets store's key,
6
+ * never logged or shown), and the helpers the controller uses to provision a member device.
7
+ *
8
+ * Matter's group model: a group ID (application range 0x0001 … 0xFEFF) is tied to a group key
9
+ * set in every member's Group Key Management cluster (`groupKeyMap`), the key set itself is
10
+ * written with `KeySetWrite`, the member endpoint joins with Groups `AddGroup`, and an ACL entry
11
+ * with auth mode Group grants the group the Operate privilege on the endpoint. The controller
12
+ * registers the same key set and map in its own fabric so it can send group messages.
13
+ */
14
+
15
+ const crypto = require('crypto');
16
+ const path = require('path');
17
+
18
+ const GROUP_ID_MIN = 0x0001;
19
+ const GROUP_ID_MAX = 0xfeff;
20
+ const KEY_SET_ID_MIN = 1; // 0 is the IPK
21
+ const KEY_SET_ID_MAX = 0xffff;
22
+ const SHARED_KEY_SET_ID = 1;
23
+ const GROUP_NAME_MAX = 16; // Groups cluster: GroupName is max 16 bytes
24
+
25
+ const SECURITY_POLICY = { trustFirst: 0, cacheAndSync: 1 };
26
+ const MULTICAST_POLICY = { perGroupId: 0, allNodes: 1 };
27
+
28
+ /** Defaults the specification guarantees when a device does not report its limits. */
29
+ const DEFAULT_LIMITS = { maxGroupsPerFabric: 4, maxGroupKeysPerFabric: 3 };
30
+
31
+ function validName(name) {
32
+ return typeof name === 'string' && name.trim().length > 0 && Buffer.byteLength(name.trim(), 'utf8') <= GROUP_NAME_MAX;
33
+ }
34
+
35
+ function nextGroupId(groups) {
36
+ const used = new Set((groups ?? []).map((g) => Number(g.id)));
37
+ for (let id = GROUP_ID_MIN; id <= GROUP_ID_MAX; id++) if (!used.has(id)) return id;
38
+ throw new Error('no free group id');
39
+ }
40
+
41
+ function nextKeySetId(keySets) {
42
+ const used = new Set(Object.values(keySets ?? {}).map((k) => Number(k.id)));
43
+ for (let id = KEY_SET_ID_MIN; id <= KEY_SET_ID_MAX; id++) if (!used.has(id)) return id;
44
+ throw new Error('no free key set id');
45
+ }
46
+
47
+ /** A fresh key set: one epoch key, Unix-epoch microseconds start time, TrustFirst. */
48
+ function newKeySet(id, { name = '', shared = false } = {}) {
49
+ return {
50
+ id: Number(id),
51
+ name,
52
+ shared: !!shared,
53
+ epochKey: crypto.randomBytes(16).toString('hex'),
54
+ epochStartTime: (BigInt(Date.now()) * 1000n).toString(),
55
+ securityPolicy: SECURITY_POLICY.trustFirst,
56
+ multicastPolicy: MULTICAST_POLICY.perGroupId,
57
+ created: Date.now(),
58
+ };
59
+ }
60
+
61
+ /** The GroupKeySetStruct for KeySetWrite and for the controller fabric's registration. */
62
+ function groupKeySetStruct(ks) {
63
+ return {
64
+ groupKeySetId: Number(ks.id),
65
+ groupKeySecurityPolicy: Number(ks.securityPolicy ?? SECURITY_POLICY.trustFirst),
66
+ epochKey0: Buffer.from(ks.epochKey, 'hex'),
67
+ epochStartTime0: BigInt(ks.epochStartTime),
68
+ epochKey1: null,
69
+ epochStartTime1: null,
70
+ epochKey2: null,
71
+ epochStartTime2: null,
72
+ groupKeyMulticastPolicy: Number(ks.multicastPolicy ?? MULTICAST_POLICY.perGroupId),
73
+ };
74
+ }
75
+
76
+ /** What the API and the UI may see of a key set: never the key. */
77
+ function publicKeySet(ks) {
78
+ return { id: Number(ks.id), name: ks.name ?? '', shared: !!ks.shared, created: ks.created ?? null };
79
+ }
80
+
81
+ function sameId(a, b) {
82
+ return a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b);
83
+ }
84
+
85
+ /** Add `{ groupId, groupKeySetId }` to a device's groupKeyMap list unless present; returns the new list. */
86
+ function groupKeyMapAdd(list, { groupId, keySetId, fabricIndex }) {
87
+ const out = (list ?? []).map((e) => ({ ...e }));
88
+ if (out.some((e) => sameId(e.groupId, groupId))) {
89
+ return out.map((e) => (sameId(e.groupId, groupId) ? { ...e, groupKeySetId: Number(keySetId) } : e));
90
+ }
91
+ const entry = { groupId: Number(groupId), groupKeySetId: Number(keySetId) };
92
+ if (fabricIndex !== undefined) entry.fabricIndex = fabricIndex;
93
+ out.push(entry);
94
+ return out;
95
+ }
96
+
97
+ /** Remove the group's entry from a groupKeyMap list; returns the new list. */
98
+ function groupKeyMapRemove(list, groupId) {
99
+ return (list ?? []).filter((e) => !sameId(e.groupId, groupId));
100
+ }
101
+
102
+ /** Is the key set still referenced by an entry of the map (another group on that device)? */
103
+ function keySetStillMapped(list, keySetId) {
104
+ return (list ?? []).some((e) => sameId(e.groupKeySetId, keySetId));
105
+ }
106
+
107
+ function effectiveLimits(limits) {
108
+ const out = { ...DEFAULT_LIMITS };
109
+ for (const k of Object.keys(DEFAULT_LIMITS)) {
110
+ const v = Number(limits?.[k]);
111
+ if (Number.isFinite(v) && v > 0) out[k] = v;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Check a device's limits before provisioning: the key set count (the IPK counts) and the group
118
+ * count of she's fabric must stay within what the device reports.
119
+ * @param {{ keySetsOnDevice: number, keySetIsNew: boolean, groupsOnDevice: number, groupIsNew: boolean, limits?: object }} o
120
+ */
121
+ function assertWithinLimits({ keySetsOnDevice, keySetIsNew, groupsOnDevice, groupIsNew, limits }) {
122
+ const lim = effectiveLimits(limits);
123
+ if (keySetIsNew && keySetsOnDevice + 1 > lim.maxGroupKeysPerFabric) {
124
+ throw new Error(`the device holds at most ${lim.maxGroupKeysPerFabric} group key sets per fabric (the fabric's IPK counts) and all are in use — use the shared key set`);
125
+ }
126
+ if (groupIsNew && groupsOnDevice + 1 > lim.maxGroupsPerFabric) {
127
+ throw new Error(`the device holds at most ${lim.maxGroupsPerFabric} groups per fabric and all are in use`);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * The group's state from its members' attributes: on (all / some / none), the average level.
133
+ * @param {{ onOff?: boolean|null, level?: number|null }[]} states
134
+ */
135
+ function derivedState(states) {
136
+ const on = states.map((s) => s.onOff).filter((v) => typeof v === 'boolean');
137
+ const levels = states.map((s) => s.level).filter((v) => typeof v === 'number' && Number.isFinite(v));
138
+ let onState = null;
139
+ if (on.length) onState = on.every(Boolean) ? 'all' : on.some(Boolean) ? 'some' : 'none';
140
+ return {
141
+ on: onState,
142
+ onCount: on.filter(Boolean).length,
143
+ memberCount: states.length,
144
+ level: levels.length ? Math.round(levels.reduce((a, b) => a + b, 0) / levels.length) : null,
145
+ };
146
+ }
147
+
148
+ // ── The store ─────────────────────────────────────────────────────────────────
149
+
150
+ const EMPTY = () => ({ v: 1, keySets: {}, groups: [] });
151
+
152
+ function fileFor(storageDir) {
153
+ return path.join(storageDir, 'groups.enc');
154
+ }
155
+
156
+ /** Read the store; a missing file is an empty store. Throws when the file cannot be decrypted. */
157
+ function readStore(storageDir) {
158
+ const { readEncryptedFile } = require('../lib/secrets');
159
+ const data = readEncryptedFile(fileFor(storageDir));
160
+ if (!data || typeof data !== 'object') return EMPTY();
161
+ return { v: 1, keySets: data.keySets ?? {}, groups: Array.isArray(data.groups) ? data.groups : [] };
162
+ }
163
+
164
+ function writeStore(storageDir, store) {
165
+ const { writeEncryptedFile } = require('../lib/secrets');
166
+ writeEncryptedFile(fileFor(storageDir), { v: 1, keySets: store.keySets, groups: store.groups });
167
+ }
168
+
169
+ module.exports = {
170
+ GROUP_ID_MIN,
171
+ GROUP_ID_MAX,
172
+ SHARED_KEY_SET_ID,
173
+ GROUP_NAME_MAX,
174
+ SECURITY_POLICY,
175
+ MULTICAST_POLICY,
176
+ DEFAULT_LIMITS,
177
+ validName,
178
+ nextGroupId,
179
+ nextKeySetId,
180
+ newKeySet,
181
+ groupKeySetStruct,
182
+ publicKeySet,
183
+ groupKeyMapAdd,
184
+ groupKeyMapRemove,
185
+ keySetStillMapped,
186
+ assertWithinLimits,
187
+ derivedState,
188
+ readStore,
189
+ writeStore,
190
+ fileFor,
191
+ EMPTY,
192
+ };