@moonbase.sh/licensing 2.0.0 → 3.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.
- package/FINGERPRINT_SPEC.md +414 -0
- package/README.md +133 -6
- package/dist/index.cjs +512 -66
- package/dist/index.d.cts +314 -14
- package/dist/index.d.ts +314 -14
- package/dist/index.js +496 -67
- package/fingerprint-vectors.json +744 -0
- package/package.json +8 -3
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# Moonbase Device Fingerprint Specification
|
|
2
|
+
|
|
3
|
+
**Version:** `v2` · **Material prefix:** `moonbase:fingerprint:v2` · **Device id prefix:** `mbd2_`
|
|
4
|
+
|
|
5
|
+
The language-neutral source of truth for how a Moonbase SDK (JavaScript, C++, .NET) computes a
|
|
6
|
+
machine **device id**. Any two SDKs that conform compute the same id on a given machine, so a
|
|
7
|
+
license bound by one validates in the other. SDKs adopt the spec independently, so conformance is a
|
|
8
|
+
property of a given SDK version, not something to assume.
|
|
9
|
+
|
|
10
|
+
Implement against this document and prove it against
|
|
11
|
+
[`fingerprint-vectors.json`](./fingerprint-vectors.json), the machine-readable conformance suite
|
|
12
|
+
shipped alongside it. If an SDK disagrees with this spec, the SDK is the bug. If this spec
|
|
13
|
+
disagrees with the vectors, **the vectors win**: they are what every SDK can actually execute.
|
|
14
|
+
|
|
15
|
+
## Why it exists
|
|
16
|
+
|
|
17
|
+
A license token carries a `sig` claim equal to the device id. Each SDK recomputes the device id
|
|
18
|
+
locally and compares it to `sig` on every offline validation. If two SDKs compute it differently on
|
|
19
|
+
the same machine, a license activated by one will not validate in the other. This spec removes that
|
|
20
|
+
divergence by defining a byte-exact, deterministic algorithm.
|
|
21
|
+
|
|
22
|
+
## Stability contract
|
|
23
|
+
|
|
24
|
+
The algorithm answers one question: *is this the same machine?* Every parameter choice below must
|
|
25
|
+
satisfy this table, and any proposed change must be argued against it.
|
|
26
|
+
|
|
27
|
+
| Event | The device id must |
|
|
28
|
+
|---|---|
|
|
29
|
+
| Host name / computer rename | **not** change |
|
|
30
|
+
| Locale, language or timezone change | **not** change |
|
|
31
|
+
| IP address, DHCP lease or network change | **not** change |
|
|
32
|
+
| BIOS / UEFI firmware update | **not** change |
|
|
33
|
+
| Running as root/Administrator vs. unprivileged | **not** change |
|
|
34
|
+
| RAM, GPU, disk or NIC added, removed or replaced | **not** change |
|
|
35
|
+
| vCPU count changed on a VM | **not** change |
|
|
36
|
+
| OS minor or major upgrade | **not** change |
|
|
37
|
+
| App sandbox enabled/disabled; container restarted on the same host | **not** change |
|
|
38
|
+
| OS **reinstall** | **may** change on Linux (see below); must not on macOS or Windows |
|
|
39
|
+
| Motherboard replaced | **may** change |
|
|
40
|
+
| Different physical machine | **must** change |
|
|
41
|
+
| VM cloned to a new instance | **must** change on macOS and Windows; **cannot be guaranteed** on Linux (see below) |
|
|
42
|
+
|
|
43
|
+
Three consequences are deliberate:
|
|
44
|
+
|
|
45
|
+
- **Linux is tied to the OS installation, not the hardware.** Every per-unit DMI field
|
|
46
|
+
(`board_serial`, `product_serial`, `product_uuid`, `chassis_serial`) is mode `0400`, root-only. An
|
|
47
|
+
unprivileged process can read only model-level values, identical across every machine of the same
|
|
48
|
+
model. Linux therefore uses `machine-id`, which is world-readable and per-installation. The cost
|
|
49
|
+
is that a Linux OS reinstall requires re-activation.
|
|
50
|
+
- **Firmware versions are never identity.** `bios_date`, `bios_version` and friends describe the
|
|
51
|
+
firmware, not the machine, and change on every BIOS update.
|
|
52
|
+
- **A carelessly cloned Linux VM keeps its device id.** `machine-id(5)` requires an image intended
|
|
53
|
+
for reuse to ship with `/etc/machine-id` empty, so each instance generates its own on first boot.
|
|
54
|
+
When that is done, a clone gets a new id and this spec behaves correctly. When it is not, the
|
|
55
|
+
clone inherits a valid `machine-id`, every other Linux parameter is model-level, and the clone
|
|
56
|
+
fingerprints identically to its source, so a license copied with the disk keeps validating. An SDK
|
|
57
|
+
cannot detect this. The value that would distinguish the instances
|
|
58
|
+
(`/sys/class/dmi/id/product_uuid`, reassigned by the hypervisor) is root-only, and reading it
|
|
59
|
+
would break privilege-invariance for every user. Treat it as a known limit of unprivileged Linux
|
|
60
|
+
fingerprinting, not as something the algorithm can close.
|
|
61
|
+
|
|
62
|
+
## Device id algorithm
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
digest = lowercase_hex( SHA-256( UTF-8( material ) ) )
|
|
66
|
+
device_id = "mbd" + version + source_tag + "_" + digest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For this version: `mbd2_` plus 64 hex characters, 69 in total. It uses only RFC 3986 unreserved
|
|
70
|
+
characters, so it never needs escaping in a URL, JSON body, file name or shell command.
|
|
71
|
+
|
|
72
|
+
`source_tag` is empty for a normal hardware fingerprint, and `n` for the opt-in host-name fallback
|
|
73
|
+
(`mbd2n_`, see [Insufficient identity](#insufficient-identity)).
|
|
74
|
+
|
|
75
|
+
### Why the id is stamped
|
|
76
|
+
|
|
77
|
+
The version once lived only inside the hashed material, which made it unrecoverable from the
|
|
78
|
+
output. Stamping it means:
|
|
79
|
+
|
|
80
|
+
- Supporting more than one version during a migration costs one hardware read, not one per version.
|
|
81
|
+
Parse the stamp on `sig`, compute that version, done.
|
|
82
|
+
- An offline validator can tell an **out-of-date SDK** (binding is v3, it computes v2) from a
|
|
83
|
+
**stale binding** (binding is v1, it computes v2), and say something better than "wrong device".
|
|
84
|
+
- The server, analytics and support can segment and reason about ids without a side channel.
|
|
85
|
+
|
|
86
|
+
> **The stamp does not establish machine continuity.** A version difference says only which
|
|
87
|
+
> algorithm created the binding. An older-version token copied from a different computer has exactly
|
|
88
|
+
> the same version relationship as one created on this machine by an older SDK, so a validator
|
|
89
|
+
> **must not** report an older stamp as proof that this is the same machine. Only recomputing the
|
|
90
|
+
> historical id and finding a match establishes continuity, and when that succeeds validation passes
|
|
91
|
+
> and never reaches an error. An SDK may surface the version difference to point at the right
|
|
92
|
+
> remedy, but must phrase the remedy as conditional.
|
|
93
|
+
|
|
94
|
+
**The stamp version and the material prefix version are always the same number.** Any change to
|
|
95
|
+
collection rules, ordering, canonicalization or encoding that would alter the output for an
|
|
96
|
+
unchanged machine must bump both.
|
|
97
|
+
|
|
98
|
+
Assemble the material from the platform and an ordered list of identity parameters:
|
|
99
|
+
|
|
100
|
+
1. Determine the **platform tag** (see below).
|
|
101
|
+
2. Collect the ordered **identity parameters** for that platform, each a `(name, value)` pair.
|
|
102
|
+
3. **Canonicalize** every value (see below) and **drop** any pair whose value is then empty, or
|
|
103
|
+
whose name is *identifying* and whose value is an [unprogrammed
|
|
104
|
+
placeholder](#identifying-parameters).
|
|
105
|
+
4. If **no** pairs survive, or none of the survivors is an
|
|
106
|
+
[identifying parameter](#identifying-parameters), stop. This is an error, not a device id. See
|
|
107
|
+
[Insufficient identity](#insufficient-identity).
|
|
108
|
+
5. If two surviving pairs share a name, stop. The grammar cannot express it, so this is a
|
|
109
|
+
collection bug.
|
|
110
|
+
6. Assemble the material as lines **joined** by a single LF (`\n`, U+000A):
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
moonbase:fingerprint:v2
|
|
114
|
+
platform=<platform-tag>
|
|
115
|
+
<name>=<value>
|
|
116
|
+
<name>=<value>
|
|
117
|
+
...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
> The LF is a **separator, not a terminator**. The material does **not** end with a newline.
|
|
121
|
+
> Appending `"\n"` after each line is the single most likely way to produce an SDK that looks
|
|
122
|
+
> correct and agrees with nothing. The vectors check this explicitly.
|
|
123
|
+
|
|
124
|
+
7. UTF-8 encode the material, SHA-256 it, lowercase-hex encode the 32-byte digest, and prefix the
|
|
125
|
+
stamp.
|
|
126
|
+
|
|
127
|
+
### Canonicalizing values
|
|
128
|
+
|
|
129
|
+
Apply these steps to every value, in this order:
|
|
130
|
+
|
|
131
|
+
1. **Normalize** to Unicode NFC.
|
|
132
|
+
2. **Drop** every character outside printable ASCII, keeping only U+0020 to U+007E.
|
|
133
|
+
3. **Truncate** to at most 128 characters.
|
|
134
|
+
4. **Trim** spaces from both ends.
|
|
135
|
+
|
|
136
|
+
Interior spaces are preserved. Nothing else is altered: no case folding, no reordering.
|
|
137
|
+
|
|
138
|
+
Step 2 does more work than it looks:
|
|
139
|
+
|
|
140
|
+
- It makes the material grammar unambiguous. A value can no longer contain an LF, so it cannot forge
|
|
141
|
+
an extra `name=value` line, and two different parameter sets can never assemble into the same
|
|
142
|
+
material.
|
|
143
|
+
- It makes the decoding of raw firmware strings irrelevant. SMBIOS strings are nominally ASCII, but
|
|
144
|
+
OEMs ship Latin-1 and worse. An SDK decoding them as Latin-1, one decoding as UTF-8 and one
|
|
145
|
+
keeping raw bytes would otherwise disagree on any non-ASCII byte. Every byte they disagree about
|
|
146
|
+
is discarded, so they cannot.
|
|
147
|
+
- It absorbs the trailing `\n` that sysfs reads and command output carry.
|
|
148
|
+
|
|
149
|
+
### Identifying parameters
|
|
150
|
+
|
|
151
|
+
Most of what a platform collects is **model-level**: vendor, product, board and family names are
|
|
152
|
+
byte-identical across every unit of a product line. A material built only from those would give
|
|
153
|
+
every machine of that model the same device id, and each would validate the others' licenses.
|
|
154
|
+
|
|
155
|
+
Exactly these parameters count as **identifying**, describing the individual machine:
|
|
156
|
+
|
|
157
|
+
| Parameter | Platform |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `ioPlatformUuid` | macOS |
|
|
160
|
+
| `machineId` | Linux |
|
|
161
|
+
| `systemUuid` | Windows |
|
|
162
|
+
| `baseboardSerialNumber` | Windows |
|
|
163
|
+
| `deviceName` | the opt-in host-name fallback only |
|
|
164
|
+
|
|
165
|
+
At least one must survive canonicalization, or the result is
|
|
166
|
+
[insufficient identity](#insufficient-identity). This is not a rare path. A Linux install with no
|
|
167
|
+
`machine-id`, or a cloned VM whose SMBIOS carries an unset UUID and a blank baseboard serial, both
|
|
168
|
+
land here and must be refused rather than fingerprinted as their model.
|
|
169
|
+
|
|
170
|
+
`deviceName` counts only because it is the sole parameter of the host-name fallback. Its weakness is
|
|
171
|
+
signalled by the `mbd2n_` stamp instead.
|
|
172
|
+
|
|
173
|
+
**Unprogrammed placeholders.** An identifying value that is really OEM filler is treated as
|
|
174
|
+
**absent**, for the same reason an all-`FF` SMBIOS UUID is: it is a constant shared by the whole
|
|
175
|
+
product line. Compared case-insensitively against the canonical value:
|
|
176
|
+
|
|
177
|
+
`to be filled by o.e.m.`, `to be filled by oem`, `default string`, `system serial number`,
|
|
178
|
+
`base board serial number`, `chassis serial number`, `not specified`, `not applicable`,
|
|
179
|
+
`not available`, `none`, `unknown`, `invalid`, `n/a`, `0123456789`, `uninitialized`, plus any value
|
|
180
|
+
that is entirely `0`s or entirely `f`/`F`s (a blank UUID field, a zeroed `machine-id`).
|
|
181
|
+
|
|
182
|
+
This applies to **identifying parameters only**. A descriptive field reading `Default string` is
|
|
183
|
+
still a fair description of the model and stays in the material. A serial number reading it is not a
|
|
184
|
+
serial number.
|
|
185
|
+
|
|
186
|
+
### Platform tags
|
|
187
|
+
|
|
188
|
+
| OS family | Tag |
|
|
189
|
+
|---|---|
|
|
190
|
+
| macOS | `mac` |
|
|
191
|
+
| Windows | `windows` |
|
|
192
|
+
| Linux | `linux` |
|
|
193
|
+
| Android | `android` |
|
|
194
|
+
| FreeBSD / OpenBSD / NetBSD | `bsd` |
|
|
195
|
+
| anything else | `unknown` |
|
|
196
|
+
|
|
197
|
+
## Identity parameters per platform
|
|
198
|
+
|
|
199
|
+
Parameters **must** appear in the order listed. Reads are best-effort: a missing or unreadable
|
|
200
|
+
source yields an empty value, which step 3 then drops. A partially-available machine still hashes
|
|
201
|
+
deterministically, and conforming SDKs agree because they apply the same collection rules.
|
|
202
|
+
|
|
203
|
+
### macOS (`mac`)
|
|
204
|
+
|
|
205
|
+
| Order | Name | Identifying | Source |
|
|
206
|
+
|---|---|---|---|
|
|
207
|
+
| 1 | `ioPlatformUuid` | ✅ | IOKit `IOPlatformUUID` of `IOPlatformExpertDevice`, with all `-` removed and **uppercased**. Read via IOKit, or `ioreg -rd1 -c IOPlatformExpertDevice` and match `"IOPlatformUUID" = "…"`. |
|
|
208
|
+
|
|
209
|
+
macOS collects a single parameter, so a read either succeeds or yields insufficient identity.
|
|
210
|
+
|
|
211
|
+
### Linux (`linux`)
|
|
212
|
+
|
|
213
|
+
All five sources are world-readable files, so the result does not depend on privilege, on any
|
|
214
|
+
installed CLI, or on the locale. No subprocess is spawned.
|
|
215
|
+
|
|
216
|
+
| Order | Name | Identifying | Source |
|
|
217
|
+
|---|---|---|---|
|
|
218
|
+
| 1 | `machineId` | ✅ | the first of `/etc/machine-id` and `/var/lib/dbus/machine-id` holding a **valid** id (see below) |
|
|
219
|
+
| 2 | `sysVendor` | | `/sys/class/dmi/id/sys_vendor` |
|
|
220
|
+
| 3 | `productName` | | `/sys/class/dmi/id/product_name` |
|
|
221
|
+
| 4 | `boardVendor` | | `/sys/class/dmi/id/board_vendor` |
|
|
222
|
+
| 5 | `boardName` | | `/sys/class/dmi/id/board_name` |
|
|
223
|
+
|
|
224
|
+
A source counts only if its canonical value matches `^[0-9a-f]{32}$`, the format `machine-id(5)`
|
|
225
|
+
defines, **and** is not an [unprogrammed placeholder](#identifying-parameters). Apply the same
|
|
226
|
+
placeholder rule here as canonicalization does. A check that admits a value canonicalization will
|
|
227
|
+
later discard (an all-`f` id passes a naive hex test) strands the remaining sources.
|
|
228
|
+
|
|
229
|
+
**Validate each source before selecting it**, rather than taking the first non-empty one.
|
|
230
|
+
`/etc/machine-id` legitimately holds the literal marker `uninitialized` in an initrd or a golden
|
|
231
|
+
image awaiting first boot, and every machine deployed from that image reads the same marker.
|
|
232
|
+
Treating it as an id would give them all one device id, and would also stop the fall-through to a
|
|
233
|
+
D-Bus id that may be perfectly valid.
|
|
234
|
+
|
|
235
|
+
`machineId` is the only per-machine value here; the DMI fields are model-level context. On a board
|
|
236
|
+
with no DMI at all (many ARM SBCs) only `machineId` survives, which is correct and still unique.
|
|
237
|
+
|
|
238
|
+
Because it is the only identifying parameter, **a Linux machine with no readable `machine-id` has no
|
|
239
|
+
device identity** and must be refused. Every remaining field is shared by every unit of the model,
|
|
240
|
+
so fingerprinting them would let those machines validate one another's licenses. This is reachable:
|
|
241
|
+
non-systemd installs, minimal containers, and images shipped with an empty `/etc/machine-id`.
|
|
242
|
+
|
|
243
|
+
> Do **not** add `board_serial`, `product_uuid` or any other `0400` file: the id would then depend
|
|
244
|
+
> on whether the process runs as root. Do **not** add `bios_*`: those change on firmware update. Do
|
|
245
|
+
> **not** parse `lscpu`: its labels are translated, so the id would depend on `LANG`, and its values
|
|
246
|
+
> are model-level anyway.
|
|
247
|
+
|
|
248
|
+
### Windows (`windows`)
|
|
249
|
+
|
|
250
|
+
Read the raw SMBIOS structure table, via `GetSystemFirmwareTable('RSMB')` (P/Invoke on .NET, native
|
|
251
|
+
on C++) or WMI `root\wmi` → `MSSmBios_RawSMBiosTables.SMBiosData`.
|
|
252
|
+
|
|
253
|
+
> If you read via `GetSystemFirmwareTable('RSMB')`, skip the leading 8-byte `RawSMBIOSData` header
|
|
254
|
+
> (`Used20CallingMethod`, 3 version bytes, `DWORD Length`); parsing starts at the first structure.
|
|
255
|
+
> WMI's `SMBiosData` already excludes that header.
|
|
256
|
+
|
|
257
|
+
Walk the structures and take the **first** structure of type 1 and the **first** of type 2. Later
|
|
258
|
+
structures of the same type are ignored.
|
|
259
|
+
|
|
260
|
+
| Type | Order | Name | Identifying | Field offset within the structure |
|
|
261
|
+
|---|---|---|---|---|
|
|
262
|
+
| 1 System | 1 | `systemManufacturer` | | `0x04` (string) |
|
|
263
|
+
| | 2 | `systemProductName` | | `0x05` (string) |
|
|
264
|
+
| | 3 | `systemUuid` | ✅ | `0x08` (16 raw bytes) |
|
|
265
|
+
| 2 Baseboard | 4 | `baseboardManufacturer` | | `0x04` (string) |
|
|
266
|
+
| | 5 | `baseboardProduct` | | `0x05` (string) |
|
|
267
|
+
| | 6 | `baseboardSerialNumber` | ✅ | `0x07` (string) |
|
|
268
|
+
|
|
269
|
+
At least one of `systemUuid` and `baseboardSerialNumber` must survive, or the machine has no device
|
|
270
|
+
identity and must be refused. **Both being unusable is the common case on cloned VM images and on
|
|
271
|
+
consumer boards**: an unset (all-`00`/all-`FF`) UUID alongside a baseboard serial that is blank or an
|
|
272
|
+
OEM filler string like `To be filled by O.E.M.` Without this rule every such machine would
|
|
273
|
+
fingerprint as its model and share a binding.
|
|
274
|
+
|
|
275
|
+
Type 4 (Processor) is deliberately **not** collected. Its values are model-level rather than
|
|
276
|
+
per-machine, and the number of type-4 structures tracks the CPU socket / vCPU count, so collecting
|
|
277
|
+
them would change the device id every time a VM is resized.
|
|
278
|
+
|
|
279
|
+
SMBIOS structure walking:
|
|
280
|
+
|
|
281
|
+
- Header: `type` (byte @0x00), `length` (byte @0x01, the size of the **formatted** area including
|
|
282
|
+
the header), `handle` (word @0x02).
|
|
283
|
+
- The **string table** immediately follows the formatted area: NUL-terminated strings ending in a
|
|
284
|
+
double-NUL. A structure with no strings is just the double-NUL.
|
|
285
|
+
- A **string field** in the formatted area holds a **1-based index** into that string table. Index
|
|
286
|
+
`0`, or an index past the end, means "no string" and yields an empty value.
|
|
287
|
+
- **Bound every field read by the structure's own `length`**, not by the size of the table. Older
|
|
288
|
+
(SMBIOS 2.x) structures are shorter than the current layout, and reading past the formatted area
|
|
289
|
+
silently picks up bytes from the string pool and resolves a garbage index.
|
|
290
|
+
- `systemUuid` is the 16 bytes at offset `0x08` formatted as **uppercase hexadecimal, no hyphens, no
|
|
291
|
+
byte reordering**: the raw bytes in order, exactly 32 hex characters. Do **not** apply the
|
|
292
|
+
SMBIOS-canonical little-endian swap of the first three UUID fields. The value will therefore not
|
|
293
|
+
match what `dmidecode`, `wmic csproduct get uuid` or `Win32_ComputerSystemProduct` display. That is
|
|
294
|
+
intentional, and an SDK reading the UUID through WMI must undo the swap.
|
|
295
|
+
- An all-`00` or all-`FF` `systemUuid` means "not set" and is treated as **absent**, so fleets of VMs
|
|
296
|
+
with unset UUIDs cannot collide.
|
|
297
|
+
|
|
298
|
+
### Android (`android`), BSD (`bsd`), other (`unknown`)
|
|
299
|
+
|
|
300
|
+
No identity parameters are defined. These platforms always resolve to
|
|
301
|
+
[insufficient identity](#insufficient-identity).
|
|
302
|
+
|
|
303
|
+
## Insufficient identity
|
|
304
|
+
|
|
305
|
+
An SDK **must** raise an error when no parameter survives canonicalization, or when none of the
|
|
306
|
+
survivors is an [identifying parameter](#identifying-parameters). It must **not** hash the platform
|
|
307
|
+
line alone, **not** hash a model-only parameter set, and **not** silently substitute the host name.
|
|
308
|
+
|
|
309
|
+
Each of those is well-defined but catastrophic in the same way: it hands a whole class of machines
|
|
310
|
+
(every machine on a platform, or every unit of a model) the *same* device id, and a license bound to
|
|
311
|
+
that id then validates on all of them. Substituting the host name is nearly as bad, being
|
|
312
|
+
user-renameable, duplicated across imaged fleets, and regenerated on every container start.
|
|
313
|
+
|
|
314
|
+
An SDK **may** offer an explicit, opt-in host-name fallback for platforms with no defined
|
|
315
|
+
parameters. The material is then the single parameter `deviceName=<host name>`, and the id **must**
|
|
316
|
+
be stamped `mbd2n_` so the weaker binding is visible to the server and to support. If the host name
|
|
317
|
+
is empty too, that is still insufficient identity.
|
|
318
|
+
|
|
319
|
+
## Device name
|
|
320
|
+
|
|
321
|
+
A human-readable label sent alongside the device id at activation. It is **not** part of the
|
|
322
|
+
material (except in the opt-in fallback above), so it can change freely without invalidating a
|
|
323
|
+
license.
|
|
324
|
+
|
|
325
|
+
| Platform | Source |
|
|
326
|
+
|---|---|
|
|
327
|
+
| macOS | host name, with a trailing `.local` removed (case-insensitive) |
|
|
328
|
+
| other | host name |
|
|
329
|
+
|
|
330
|
+
## Worked examples
|
|
331
|
+
|
|
332
|
+
Reproduced by [`fingerprint-vectors.json`](./fingerprint-vectors.json), which contains these and
|
|
333
|
+
many more. Materials are shown with literal newlines and, to repeat, **no trailing newline**.
|
|
334
|
+
|
|
335
|
+
**macOS:**
|
|
336
|
+
|
|
337
|
+
```
|
|
338
|
+
moonbase:fingerprint:v2
|
|
339
|
+
platform=mac
|
|
340
|
+
ioPlatformUuid=0123456789ABCDEF0123456789ABCDEF
|
|
341
|
+
```
|
|
342
|
+
→ `mbd2_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32`
|
|
343
|
+
|
|
344
|
+
**Linux:**
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
moonbase:fingerprint:v2
|
|
348
|
+
platform=linux
|
|
349
|
+
machineId=b08dfa6083e7567a1921a715000001fb
|
|
350
|
+
sysVendor=LENOVO
|
|
351
|
+
productName=20HRCTO1WW
|
|
352
|
+
boardVendor=LENOVO
|
|
353
|
+
boardName=20HRCTO1WW
|
|
354
|
+
```
|
|
355
|
+
→ `mbd2_ba16d78604f90c6c8b00dc1065a70c866884fadfa818ed2db83b2bfe0dc94933`
|
|
356
|
+
|
|
357
|
+
**Windows:**
|
|
358
|
+
|
|
359
|
+
```
|
|
360
|
+
moonbase:fingerprint:v2
|
|
361
|
+
platform=windows
|
|
362
|
+
systemManufacturer=ACME
|
|
363
|
+
systemProductName=Server 9000
|
|
364
|
+
systemUuid=0123456789ABCDEF0123456789ABCDEF
|
|
365
|
+
baseboardManufacturer=ACME
|
|
366
|
+
baseboardProduct=MB-1
|
|
367
|
+
baseboardSerialNumber=BSN-42
|
|
368
|
+
```
|
|
369
|
+
→ `mbd2_fadd75457e44f669e9865caff122b4706a4501089ac9e73b8735139bf57676ad`
|
|
370
|
+
|
|
371
|
+
**Opt-in host-name fallback** (note the `n`):
|
|
372
|
+
|
|
373
|
+
```
|
|
374
|
+
moonbase:fingerprint:v2
|
|
375
|
+
platform=unknown
|
|
376
|
+
deviceName=PC-1
|
|
377
|
+
```
|
|
378
|
+
→ `mbd2n_493978eb157552e60a13694bd6861b2a82d0dba2746431a5f7921951ed460045`
|
|
379
|
+
|
|
380
|
+
## Conformance checklist
|
|
381
|
+
|
|
382
|
+
Run [`fingerprint-vectors.json`](./fingerprint-vectors.json) in your SDK's test suite. It covers
|
|
383
|
+
every item below; this list is what to look at when a vector fails.
|
|
384
|
+
|
|
385
|
+
- [ ] Material prefix is exactly `moonbase:fingerprint:v2`.
|
|
386
|
+
- [ ] Lines are **joined** with a single LF, and the material has **no trailing newline**.
|
|
387
|
+
- [ ] Values are canonicalized NFC → printable-ASCII-only → capped at 128 → space-trimmed, in that
|
|
388
|
+
order; empty values are dropped.
|
|
389
|
+
- [ ] Identifying parameters holding an unprogrammed placeholder are dropped; descriptive ones are
|
|
390
|
+
not.
|
|
391
|
+
- [ ] An empty surviving parameter set raises an error rather than producing a digest.
|
|
392
|
+
- [ ] A surviving set with no identifying parameter raises an error rather than fingerprinting the
|
|
393
|
+
model.
|
|
394
|
+
- [ ] Duplicate parameter names raise an error.
|
|
395
|
+
- [ ] Per-platform params are collected with the exact names and order above.
|
|
396
|
+
- [ ] Linux spawns no subprocess and reads no root-only file, and validates each `machine-id` source
|
|
397
|
+
against `^[0-9a-f]{32}$` and the placeholder rule before selecting it.
|
|
398
|
+
- [ ] Windows takes only the first type-1 and first type-2 structure, ignores type 4, and bounds
|
|
399
|
+
every field read by the structure `length`.
|
|
400
|
+
- [ ] `systemUuid` is uppercase hex, no hyphens, no byte swap; all-`00`/all-`FF` is absent.
|
|
401
|
+
- [ ] Digest is SHA-256 over UTF-8 material, output as 64 lowercase hex characters.
|
|
402
|
+
- [ ] The emitted device id is stamped `mbd2_` (or `mbd2n_` for the opt-in fallback).
|
|
403
|
+
- [ ] A version difference is surfaced without claiming the license came from this machine.
|
|
404
|
+
|
|
405
|
+
## Versioning
|
|
406
|
+
|
|
407
|
+
The material prefix and the device id stamp both carry the version, and they always match. Any
|
|
408
|
+
change to collection rules, ordering, canonicalization or encoding that would alter output for an
|
|
409
|
+
unchanged machine **must** bump both (to `moonbase:fingerprint:v3` and `mbd3_`).
|
|
410
|
+
|
|
411
|
+
Because the version is recoverable from the id, an SDK can validate against several versions during
|
|
412
|
+
a migration while emitting only one. Parse the stamp on the `sig` claim and compute that version. If
|
|
413
|
+
the SDK no longer supports it, say the license needs re-activating rather than reporting the machine
|
|
414
|
+
as wrong.
|
package/README.md
CHANGED
|
@@ -74,14 +74,14 @@ if (localLicense) {
|
|
|
74
74
|
|
|
75
75
|
## Metadata, platform, and app version
|
|
76
76
|
|
|
77
|
-
The SDK can report analytics with every activation, trial request, and validation. These are configured once on the `MoonbaseLicensing` config and sent automatically
|
|
77
|
+
The SDK can report analytics with every activation, trial request, and validation. These are configured once on the `MoonbaseLicensing` config and sent automatically; there's no per-call parameter.
|
|
78
78
|
|
|
79
79
|
- `appVersion` and `platform` are first-class fields and sent as plain query params. `metadata` is for arbitrary customer-defined keys, sent as `meta[key]=value`.
|
|
80
80
|
- `platform` auto-detects from `process.platform` (`darwin` → `Mac`, `win32` → `Windows`, `linux` → `Linux`). Set it explicitly to override, or pass `null` to suppress.
|
|
81
|
-
- All three are sent on `requestActivation`, `requestTrial`, and `validateLicense
|
|
81
|
+
- All three are sent on `requestActivation`, `requestTrial`, and `validateLicense`, never on `revokeLicense`.
|
|
82
82
|
- `metadata` accepts string keys and string values only; empty values are dropped client-side.
|
|
83
83
|
- Server-side, the metadata on the activation is **replaced on every call** (not merged) and surfaced in `LicenseActivatedEvent` / `LicenseValidatedEvent` webhooks.
|
|
84
|
-
- The config object is re-read on every call
|
|
84
|
+
- The config object is re-read on every call, so mutating `metadata` between calls (e.g. to rotate a session id) means the next request picks it up.
|
|
85
85
|
- Keep payloads small; query-string length limits apply.
|
|
86
86
|
|
|
87
87
|
## Revoke a license activation
|
|
@@ -108,9 +108,136 @@ Read and validate raw license bytes (for example from a file):
|
|
|
108
108
|
```ts
|
|
109
109
|
const license = await licensing.readRawLicense(rawLicenseBuffer)
|
|
110
110
|
```
|
|
111
|
+
## Device fingerprint
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
Every license is bound to the machine via a device id, stored in the token's `sig` claim and
|
|
114
|
+
re-checked on each local validation. The default `MoonbaseDeviceIdResolver` computes it from the
|
|
115
|
+
cross-SDK **[device fingerprint spec](./FINGERPRINT_SPEC.md)** (`moonbase:fingerprint:v2`): a
|
|
116
|
+
SHA-256 of stable native hardware identifiers, stamped with the spec version.
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
mbd2_9f3c… // 'mbd' + version + '_' + 64 lowercase hex characters
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Sources are SMBIOS on Windows, `IOPlatformUUID` on macOS, and `machine-id` plus world-readable DMI
|
|
123
|
+
on Linux. The algorithm is language-neutral by design: any Moonbase SDK that implements the spec and
|
|
124
|
+
passes the shipped [`fingerprint-vectors.json`](./fingerprint-vectors.json) computes the same id on a
|
|
125
|
+
given machine, so a license activated by one validates in the others. Adoption is per-SDK, so check
|
|
126
|
+
the version of whichever SDK you are pairing with before relying on it.
|
|
127
|
+
|
|
128
|
+
> Both files ship inside the package. If the links above do not resolve where you are reading this,
|
|
129
|
+
> find them in `node_modules/@moonbase.sh/licensing/`.
|
|
130
|
+
|
|
131
|
+
The id survives a rename, a locale change, a firmware update, a vCPU resize, and running with or
|
|
132
|
+
without elevated privileges. The spec's **stability contract** is the definitive list. Read it
|
|
133
|
+
before shipping, along with the two Linux exceptions, which exist because every per-unit hardware
|
|
134
|
+
serial is root-only there and the id is tied to the OS installation rather than the hardware:
|
|
135
|
+
|
|
136
|
+
- A Linux **OS reinstall** requires re-activation.
|
|
137
|
+
- A Linux **VM cloned without clearing `/etc/machine-id`** keeps its device id, so a license copied
|
|
138
|
+
with the disk keeps validating. `machine-id(5)` requires reusable images to ship that file empty;
|
|
139
|
+
when they do, clones behave correctly. The SDK cannot detect a badly-prepared image, because the
|
|
140
|
+
value that would distinguish the instances is root-only.
|
|
141
|
+
|
|
142
|
+
Because the version is part of the id, a mismatch is diagnosable. `validateLicense` throws
|
|
143
|
+
`ErrorType.LicenseDeviceMismatch` either way, and the message says which case you are in:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
try {
|
|
147
|
+
await licensing.validator.validateLicense(token)
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (err.type === ErrorType.LicenseDeviceMismatch)
|
|
151
|
+
console.error(err.message) // 'not for this device', plus any version difference
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### When there is no hardware identity
|
|
156
|
+
|
|
157
|
+
The resolver throws `InsufficientDeviceIdentityError` rather than falling back to something weak, in
|
|
158
|
+
two cases:
|
|
159
|
+
|
|
160
|
+
- **Nothing readable.** A sandboxed process, or a platform with no defined parameters (Android, BSD).
|
|
161
|
+
- **Only model-level values readable.** Vendor, product and board names are byte-identical across
|
|
162
|
+
every unit of a product line, so fingerprinting them would let those machines validate one
|
|
163
|
+
another's licenses. In practice: a Linux install with no `machine-id`, or a machine whose SMBIOS
|
|
164
|
+
carries an unset UUID *and* a blank or filler baseboard serial, the usual shape of a cloned VM
|
|
165
|
+
image.
|
|
166
|
+
|
|
167
|
+
Opt in explicitly if a weaker id beats none. Those ids are stamped `mbd2n_` so the server can tell
|
|
168
|
+
them apart:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const deviceIdResolver = new MoonbaseDeviceIdResolver({ fallback: 'deviceName' })
|
|
172
|
+
```
|
|
113
173
|
|
|
114
|
-
|
|
174
|
+
### Diagnostics and parity checks
|
|
175
|
+
|
|
176
|
+
`describeDevice()` returns the id, spec version, platform and the *names* of the parameters that
|
|
177
|
+
contributed. It is safe to log or attach to a support ticket, and returns a fresh copy each call so
|
|
178
|
+
editing it cannot disturb the binding.
|
|
179
|
+
|
|
180
|
+
Parameter values are never exposed there, and neither are per-parameter hashes. They are hardware
|
|
181
|
+
serial numbers, and an unsalted per-value digest is no safer to publish than the value, since
|
|
182
|
+
low-entropy values such as host names or sequential serials fall to a dictionary. Which parameters
|
|
183
|
+
contributed is the useful diagnostic; what they read is not.
|
|
184
|
+
|
|
185
|
+
The device id itself is a one-way hash of all of them together, so it discloses no individual
|
|
186
|
+
serial. It is, however, **derived identically for every Moonbase-powered product**. The material
|
|
187
|
+
contains no product- or account-specific input, so the same machine yields the same device id
|
|
188
|
+
everywhere, and merchants receive that string through the integration API and webhooks. Treat it as
|
|
189
|
+
a stable cross-vendor machine identifier. That is more than `machine-id(5)` intends, which asks that
|
|
190
|
+
the Linux machine id only leave the host through an *application-specific keyed* derivation. If that
|
|
191
|
+
matters for your deployment, supply a custom `IDeviceIdResolver` that mixes in a key of your own.
|
|
192
|
+
|
|
193
|
+
The lower-level `buildFingerprintMaterial`, `fingerprintDigest`, `fingerprintDeviceId` and
|
|
194
|
+
`parseDeviceIdStamp` helpers are exported so you can verify cross-SDK parity against the vector
|
|
195
|
+
file.
|
|
196
|
+
|
|
197
|
+
## Migrating from v2.x
|
|
198
|
+
|
|
199
|
+
Device ids computed by v2.x are not compatible with the spec, so **by default every device must
|
|
200
|
+
re-activate once** after you upgrade. That is not free: a new device id consumes a fresh activation
|
|
201
|
+
seat (the old one is not reclaimed) and resets any device-scoped trial. On a license with few seats,
|
|
202
|
+
a fleet-wide upgrade can exhaust them immediately.
|
|
203
|
+
|
|
204
|
+
Three options, in increasing order of effort:
|
|
205
|
+
|
|
206
|
+
**1. Let devices re-activate (default).** Simplest, and the id is correct from then on. Catch
|
|
207
|
+
`ErrorType.LicenseDeviceMismatch` and call `requestActivation()`. Best when seats are generous or the
|
|
208
|
+
install base is small.
|
|
209
|
+
|
|
210
|
+
**2. Accept the old id while binding the new one (recommended for existing fleets).**
|
|
211
|
+
`MigratingDeviceIdResolver` keeps recognising ids this device was previously bound to, without ever
|
|
212
|
+
issuing one:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { LegacyDeviceIdResolver, MigratingDeviceIdResolver, MoonbaseDeviceIdResolver } from '@moonbase.sh/licensing'
|
|
216
|
+
|
|
217
|
+
const licensing = new MoonbaseLicensing({
|
|
218
|
+
// …
|
|
219
|
+
deviceIdResolver: new MigratingDeviceIdResolver(
|
|
220
|
+
new MoonbaseDeviceIdResolver(), // always what a new activation binds
|
|
221
|
+
new LegacyDeviceIdResolver(), // additionally accepted during validation
|
|
222
|
+
),
|
|
223
|
+
})
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Existing licenses keep validating untouched, while anything newly activated binds the current
|
|
227
|
+
fingerprint. The fleet migrates as devices naturally re-activate, with no flag day and no seat
|
|
228
|
+
churn. The legacy id is computed lazily, only when the fast comparison fails, and then memoized, so
|
|
229
|
+
apps on the happy path pay nothing. Drop the wrapper in a later release to finish the migration.
|
|
230
|
+
|
|
231
|
+
**3. Stay on the old id.** Pin `deviceIdResolver: new LegacyDeviceIdResolver()`. Nothing changes, but
|
|
232
|
+
you keep the old algorithm's defects (the machine name is part of the id, so renaming a computer
|
|
233
|
+
invalidates its license) and get no cross-SDK compatibility. Use this only as a short-term hold.
|
|
234
|
+
|
|
235
|
+
> Options 1 and 2 both recompute every accepted id from the machine's own hardware on each call.
|
|
236
|
+
> Nothing about a device binding is ever read from disk, so widening what a validator accepts does
|
|
237
|
+
> not widen what an attacker can assert.
|
|
238
|
+
|
|
239
|
+
## Custom stores and device resolvers
|
|
115
240
|
|
|
116
|
-
|
|
241
|
+
You can inject your own `ILicenseStore` and `IDeviceIdResolver` implementations via the
|
|
242
|
+
`licenseStore` and `deviceIdResolver` configuration options. A custom resolver's id is compared
|
|
243
|
+
literally, so it does not need to follow the `mbd2_` stamp format.
|