@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.
@@ -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 there's no per-call parameter.
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` never on `revokeLicense`.
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 mutate `metadata` between calls (e.g. to rotate a session id) and the next request picks it up.
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
- ## Custom stores and device resolvers
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
- You can inject your own `ILicenseStore` and `IDeviceIdResolver` implementations.
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
- Note: the current configuration property name for custom device resolver is `deivceIdResolver`.
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.