@moonbase.sh/licensing 2.0.1 → 3.1.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,647 @@
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
+ On iOS and Android that guarantee is narrower, because neither exposes an identifier an unrelated
11
+ app can read. Those platforms use a [scoped identity](#scoped-identity), whose value is tied to the
12
+ app it runs in, so **two apps** on one device compute different ids by design and the `mbd2s_` stamp
13
+ marks it. The algorithm is unchanged: two conforming SDKs embedded in the *same* app still compute
14
+ the same id. What varies is the scope, never the implementation — an SDK that computed something
15
+ different from its peers in the same app would be non-conforming, not scoped.
16
+
17
+ Implement against this document and prove it against
18
+ [`fingerprint-vectors.json`](./fingerprint-vectors.json), the machine-readable conformance suite
19
+ shipped alongside it. If an SDK disagrees with this spec, the SDK is the bug. If this spec
20
+ disagrees with the vectors, **the vectors win**: they are what every SDK can actually execute.
21
+
22
+ ## Why it exists
23
+
24
+ A license token carries a `sig` claim equal to the device id. Each SDK recomputes the device id
25
+ locally and compares it to `sig` on every offline validation. If two SDKs compute it differently on
26
+ the same machine, a license activated by one will not validate in the other. This spec removes that
27
+ divergence by defining a byte-exact, deterministic algorithm.
28
+
29
+ ## Stability contract
30
+
31
+ The algorithm answers one question: *is this the same machine?* Every hardware-identity parameter
32
+ below must satisfy this table, and any proposed change must be argued against it. The two
33
+ [scoped](#scoped-identity) parameters cannot satisfy it and are governed by a
34
+ [weaker contract](#what-scoped-identity-guarantees) instead — that gap is the whole reason they are
35
+ stamped differently.
36
+
37
+ | Event | The device id must |
38
+ |---|---|
39
+ | Host name / computer rename | **not** change |
40
+ | Locale, language or timezone change | **not** change |
41
+ | IP address, DHCP lease or network change | **not** change |
42
+ | BIOS / UEFI firmware update | **not** change |
43
+ | Running as root/Administrator vs. unprivileged | **not** change |
44
+ | RAM, GPU, disk or NIC added, removed or replaced | **not** change |
45
+ | vCPU count changed on a VM | **not** change |
46
+ | OS minor or major upgrade | **not** change |
47
+ | App sandbox enabled/disabled; container restarted on the same host | **not** change |
48
+ | OS **reinstall** | **may** change on Linux (see below); must not on macOS or Windows |
49
+ | Motherboard replaced | **may** change |
50
+ | Different physical machine | **must** change |
51
+ | VM cloned to a new instance | **must** change on macOS and Windows; **cannot be guaranteed** on Linux (see below) |
52
+
53
+ Three consequences are deliberate:
54
+
55
+ - **Linux is tied to the OS installation, not the hardware.** Every per-unit DMI field
56
+ (`board_serial`, `product_serial`, `product_uuid`, `chassis_serial`) is mode `0400`, root-only. An
57
+ unprivileged process can read only model-level values, identical across every machine of the same
58
+ model. Linux therefore uses `machine-id`, which is world-readable and per-installation. The cost
59
+ is that a Linux OS reinstall requires re-activation.
60
+ - **Firmware versions are never identity.** `bios_date`, `bios_version` and friends describe the
61
+ firmware, not the machine, and change on every BIOS update.
62
+ - **A carelessly cloned Linux VM keeps its device id.** `machine-id(5)` requires an image intended
63
+ for reuse to ship with `/etc/machine-id` empty, so each instance generates its own on first boot.
64
+ When that is done, a clone gets a new id and this spec behaves correctly. When it is not, the
65
+ clone inherits a valid `machine-id`, every other Linux parameter is model-level, and the clone
66
+ fingerprints identically to its source, so a license copied with the disk keeps validating. An SDK
67
+ cannot detect this. The value that would distinguish the instances
68
+ (`/sys/class/dmi/id/product_uuid`, reassigned by the hypervisor) is root-only, and reading it
69
+ would break privilege-invariance for every user. Treat it as a known limit of unprivileged Linux
70
+ fingerprinting, not as something the algorithm can close.
71
+
72
+ ## Device id algorithm
73
+
74
+ ```
75
+ digest = lowercase_hex( SHA-256( UTF-8( material ) ) )
76
+ device_id = "mbd" + version + source_tag + "_" + digest
77
+ ```
78
+
79
+ For this version: `mbd2_` plus 64 hex characters, 69 in total. It uses only RFC 3986 unreserved
80
+ characters, so it never needs escaping in a URL, JSON body, file name or shell command.
81
+
82
+ `source_tag` records how the identity was obtained, and therefore what the id may be compared to:
83
+
84
+ | Tag | Form | Meaning |
85
+ |---|---|---|
86
+ | *(empty)* | `mbd2_` | Hardware identity. Comparable across every conforming SDK on that machine. |
87
+ | `n` | `mbd2n_` | The opt-in host-name fallback. See [Insufficient identity](#insufficient-identity). |
88
+ | `s` | `mbd2s_` | [Scoped identity](#scoped-identity): stable for the device within one [scope](#what-the-scope-actually-is), and **not** comparable across scopes. |
89
+
90
+ Those are the tags this version **defines**. The grammar an SDK **accepts** is deliberately wider:
91
+
92
+ ```
93
+ ^mbd(\d+)([a-z]*)_([0-9a-f]{64})$
94
+ ```
95
+
96
+ A parser MUST accept a source tag it does not recognise, treating the id as opaque and comparing it
97
+ literally rather than rejecting it. That is what lets a new tag be introduced without a version bump,
98
+ so it must be possible to *parse* `mbd2x_…` while knowing only that `x` is not a tag this SDK
99
+ defines. An SDK that hard-codes the three defined tags into its pattern cannot do that, and will
100
+ report a perfectly valid id from a newer SDK as "not a Moonbase device id".
101
+
102
+ The tag is `[a-z]*`, not a single optional character, so a future two-letter tag needs no version
103
+ bump either. `_` terminates it, and digits cannot appear in it, so the split from `version` is
104
+ unambiguous.
105
+
106
+ ### Why the id is stamped
107
+
108
+ The version once lived only inside the hashed material, which made it unrecoverable from the
109
+ output. Stamping it means:
110
+
111
+ - Supporting more than one version during a migration costs one hardware read, not one per version.
112
+ Parse the stamp on `sig`, compute that version, done.
113
+ - An offline validator can tell an **out-of-date SDK** (binding is v3, it computes v2) from a
114
+ **stale binding** (binding is v1, it computes v2), and say something better than "wrong device".
115
+ - The server, analytics and support can segment and reason about ids without a side channel.
116
+
117
+ > **The stamp does not establish machine continuity.** A version difference says only which
118
+ > algorithm created the binding. An older-version token copied from a different computer has exactly
119
+ > the same version relationship as one created on this machine by an older SDK, so a validator
120
+ > **must not** report an older stamp as proof that this is the same machine. Only recomputing the
121
+ > historical id and finding a match establishes continuity, and when that succeeds validation passes
122
+ > and never reaches an error. An SDK may surface the version difference to point at the right
123
+ > remedy, but must phrase the remedy as conditional.
124
+
125
+ **The stamp version and the material prefix version are always the same number.** Any change to
126
+ collection rules, ordering, canonicalization or encoding that would alter the output for an
127
+ unchanged machine must bump both.
128
+
129
+ Assemble the material from the platform and an ordered list of identity parameters:
130
+
131
+ 1. Determine the **platform tag** (see below).
132
+ 2. Collect the ordered **identity parameters** for that platform, each a `(name, value)` pair.
133
+ 3. **Canonicalize** every value (see below) and **drop** any pair whose value is then empty, or
134
+ whose name is *identifying* and whose value is an [unprogrammed
135
+ placeholder](#identifying-parameters).
136
+ 4. If **no** pairs survive, or none of the survivors is an
137
+ [identifying parameter](#identifying-parameters), stop. This is an error, not a device id. See
138
+ [Insufficient identity](#insufficient-identity).
139
+ 5. If two surviving pairs share a name, stop. The grammar cannot express it, so this is a
140
+ collection bug.
141
+ 6. Assemble the material as lines **joined** by a single LF (`\n`, U+000A):
142
+
143
+ ```
144
+ moonbase:fingerprint:v2
145
+ platform=<platform-tag>
146
+ <name>=<value>
147
+ <name>=<value>
148
+ ...
149
+ ```
150
+
151
+ > The LF is a **separator, not a terminator**. The material does **not** end with a newline.
152
+ > Appending `"\n"` after each line is the single most likely way to produce an SDK that looks
153
+ > correct and agrees with nothing. The vectors check this explicitly.
154
+
155
+ 7. UTF-8 encode the material, SHA-256 it, lowercase-hex encode the 32-byte digest, and prefix the
156
+ stamp.
157
+
158
+ ### Canonicalizing values
159
+
160
+ Apply these steps to every value, in this order:
161
+
162
+ 1. **Normalize** to Unicode NFC.
163
+ 2. **Drop** every character outside printable ASCII, keeping only U+0020 to U+007E.
164
+ 3. **Truncate** to at most 128 characters.
165
+ 4. **Trim** spaces from both ends.
166
+
167
+ Interior spaces are preserved. Nothing else is altered: no case folding, no reordering.
168
+
169
+ Step 2 does more work than it looks:
170
+
171
+ - It makes the material grammar unambiguous. A value can no longer contain an LF, so it cannot forge
172
+ an extra `name=value` line, and two different parameter sets can never assemble into the same
173
+ material.
174
+ - It makes the decoding of raw firmware strings irrelevant. SMBIOS strings are nominally ASCII, but
175
+ OEMs ship Latin-1 and worse. An SDK decoding them as Latin-1, one decoding as UTF-8 and one
176
+ keeping raw bytes would otherwise disagree on any non-ASCII byte. Every byte they disagree about
177
+ is discarded, so they cannot.
178
+ - It absorbs the trailing `\n` that sysfs reads and command output carry.
179
+
180
+ ### Identifying parameters
181
+
182
+ Most of what a platform collects is **model-level**: vendor, product, board and family names are
183
+ byte-identical across every unit of a product line. A material built only from those would give
184
+ every machine of that model the same device id, and each would validate the others' licenses.
185
+
186
+ Exactly these parameters count as **identifying**, describing the individual machine:
187
+
188
+ | Parameter | Platform |
189
+ |---|---|
190
+ | `ioPlatformUuid` | macOS |
191
+ | `machineId` | Linux |
192
+ | `systemUuid` | Windows |
193
+ | `baseboardSerialNumber` | Windows |
194
+ | `identifierForVendor` | iOS ([scoped](#scoped-identity)) |
195
+ | `androidId` | Android ([scoped](#scoped-identity)) |
196
+ | `deviceName` | the opt-in host-name fallback only |
197
+
198
+ At least one must survive canonicalization, or the result is
199
+ [insufficient identity](#insufficient-identity). This is not a rare path. A Linux install with no
200
+ `machine-id`, or a cloned VM whose SMBIOS carries an unset UUID and a blank baseboard serial, both
201
+ land here and must be refused rather than fingerprinted as their model.
202
+
203
+ `deviceName` counts only because it is the sole parameter of the host-name fallback. Its weakness is
204
+ signalled by the `mbd2n_` stamp instead.
205
+
206
+ **Unprogrammed placeholders.** An identifying value that is really OEM filler is treated as
207
+ **absent**, for the same reason an all-`FF` SMBIOS UUID is: it is a constant shared by the whole
208
+ product line. Compared case-insensitively against the canonical value:
209
+
210
+ `to be filled by o.e.m.`, `to be filled by oem`, `default string`, `system serial number`,
211
+ `base board serial number`, `chassis serial number`, `not specified`, `not applicable`,
212
+ `not available`, `none`, `unknown`, `invalid`, `n/a`, `0123456789`, `uninitialized`, plus any value
213
+ that is entirely `0`s or entirely `f`/`F`s (a blank UUID field, a zeroed `machine-id`).
214
+
215
+ One of those earns its place on mobile: `unknown` is exactly what Android's `Build.SERIAL` returns
216
+ without a privileged permission, so an SDK that reaches for it lands on a fleet-wide constant.
217
+
218
+ **Per-parameter rejections.** A constant that belongs to *one* platform's identifier is rejected for
219
+ that parameter only, never added to the list above. Widening it would change the device id of a
220
+ machine that happens to report the same string as some unrelated field, and any change that alters
221
+ the output for an unchanged machine requires a version bump. Currently there is one:
222
+
223
+ | Parameter | Also rejected | Why |
224
+ |---|---|---|
225
+ | `androidId` | `9774d56d682e549c` | A real `ANDROID_ID` shared by a large batch of 2010-era devices whose `ro.serialno` was unset, seeding the generator identically on every unit. It is valid hex, so the format rule cannot catch it. |
226
+
227
+ This applies to **identifying parameters only**. A descriptive field reading `Default string` is
228
+ still a fair description of the model and stays in the material. A serial number reading it is not a
229
+ serial number.
230
+
231
+ ### Platform tags
232
+
233
+ | OS family | Tag |
234
+ |---|---|
235
+ | macOS | `mac` |
236
+ | iOS, iPadOS, tvOS, watchOS, visionOS | `ios` |
237
+ | Windows | `windows` |
238
+ | Linux | `linux` |
239
+ | Android | `android` |
240
+ | FreeBSD / OpenBSD / NetBSD | `bsd` |
241
+ | anything else | `unknown` |
242
+
243
+ Every Apple platform other than macOS maps to `ios`, because they all offer the same single
244
+ identifier and nothing else (watchOS via `WKInterfaceDevice`, the rest via `UIDevice`). Giving them
245
+ one tag is what keeps two SDKs from disagreeing: the tag is hashed into the material, so an SDK that
246
+ mapped tvOS to `unknown` while another mapped it to `ios` would compute different ids on one device.
247
+
248
+ > **The tag follows the OS the process is running on, not the SDK it was built against.** One Apple
249
+ > binary can run in three ways, and the obvious tests (`#if targetEnvironment(macCatalyst)`,
250
+ > `#if os(iOS)`, `UIDevice.systemName`) all get it wrong — a Mac Catalyst build compiles with
251
+ > `os(iOS)` true and reports `systemName` as `iPadOS` while running on macOS. Use the runtime pair:
252
+ >
253
+ > | `isMacCatalystApp` | `isiOSAppOnMac` | Running as | Tag |
254
+ > |---|---|---|---|
255
+ > | `false` | `false` | a real iPhone / iPad | `ios` |
256
+ > | `true` | `false` | Mac Catalyst | `mac` |
257
+ > | `true` | `true` | an iOS app on Apple silicon | `ios` |
258
+ >
259
+ > Mac Catalyst is the case that matters: it can read **both** `identifierForVendor` and IOKit
260
+ > `IOPlatformUUID` (the macOS App Sandbox does not deny IOKit property reads), so without a rule two
261
+ > SDKs on one Mac would disagree about which one to use. Hardware identity wins, per
262
+ > [Scoped identity](#scoped-identity). An unmodified iOS app on Apple silicon cannot reach IOKit, so
263
+ > it stays on the scoped path and its id is not comparable with the Catalyst one — which the `mbd2s_`
264
+ > stamp already says.
265
+
266
+ ## Identity parameters per platform
267
+
268
+ Parameters **must** appear in the order listed. Reads are best-effort: a missing or unreadable
269
+ source yields an empty value, which step 3 then drops. A partially-available machine still hashes
270
+ deterministically, and conforming SDKs agree because they apply the same collection rules.
271
+
272
+ ### macOS (`mac`)
273
+
274
+ | Order | Name | Identifying | Source |
275
+ |---|---|---|---|
276
+ | 1 | `ioPlatformUuid` | ✅ | IOKit `IOPlatformUUID` of `IOPlatformExpertDevice`, with all `-` removed and **uppercased**. Read via IOKit, or `ioreg -rd1 -c IOPlatformExpertDevice` and match `"IOPlatformUUID" = "…"`. |
277
+
278
+ macOS collects a single parameter, so a read either succeeds or yields insufficient identity.
279
+
280
+ ### Linux (`linux`)
281
+
282
+ All five sources are world-readable files, so the result does not depend on privilege, on any
283
+ installed CLI, or on the locale. No subprocess is spawned.
284
+
285
+ | Order | Name | Identifying | Source |
286
+ |---|---|---|---|
287
+ | 1 | `machineId` | ✅ | the first of `/etc/machine-id` and `/var/lib/dbus/machine-id` holding a **valid** id (see below) |
288
+ | 2 | `sysVendor` | | `/sys/class/dmi/id/sys_vendor` |
289
+ | 3 | `productName` | | `/sys/class/dmi/id/product_name` |
290
+ | 4 | `boardVendor` | | `/sys/class/dmi/id/board_vendor` |
291
+ | 5 | `boardName` | | `/sys/class/dmi/id/board_name` |
292
+
293
+ A source counts only if its canonical value matches `^[0-9a-f]{32}$`, the format `machine-id(5)`
294
+ defines, **and** is not an [unprogrammed placeholder](#identifying-parameters). Apply the same
295
+ placeholder rule here as canonicalization does. A check that admits a value canonicalization will
296
+ later discard (an all-`f` id passes a naive hex test) strands the remaining sources.
297
+
298
+ **Validate each source before selecting it**, rather than taking the first non-empty one.
299
+ `/etc/machine-id` legitimately holds the literal marker `uninitialized` in an initrd or a golden
300
+ image awaiting first boot, and every machine deployed from that image reads the same marker.
301
+ Treating it as an id would give them all one device id, and would also stop the fall-through to a
302
+ D-Bus id that may be perfectly valid.
303
+
304
+ `machineId` is the only per-machine value here; the DMI fields are model-level context. On a board
305
+ with no DMI at all (many ARM SBCs) only `machineId` survives, which is correct and still unique.
306
+
307
+ Because it is the only identifying parameter, **a Linux machine with no readable `machine-id` has no
308
+ device identity** and must be refused. Every remaining field is shared by every unit of the model,
309
+ so fingerprinting them would let those machines validate one another's licenses. This is reachable:
310
+ non-systemd installs, minimal containers, and images shipped with an empty `/etc/machine-id`.
311
+
312
+ > Do **not** add `board_serial`, `product_uuid` or any other `0400` file: the id would then depend
313
+ > on whether the process runs as root. Do **not** add `bios_*`: those change on firmware update. Do
314
+ > **not** parse `lscpu`: its labels are translated, so the id would depend on `LANG`, and its values
315
+ > are model-level anyway.
316
+
317
+ ### Windows (`windows`)
318
+
319
+ Read the raw SMBIOS structure table, via `GetSystemFirmwareTable('RSMB')` (P/Invoke on .NET, native
320
+ on C++) or WMI `root\wmi` → `MSSmBios_RawSMBiosTables.SMBiosData`.
321
+
322
+ > If you read via `GetSystemFirmwareTable('RSMB')`, skip the leading 8-byte `RawSMBIOSData` header
323
+ > (`Used20CallingMethod`, 3 version bytes, `DWORD Length`); parsing starts at the first structure.
324
+ > WMI's `SMBiosData` already excludes that header.
325
+
326
+ Walk the structures and take the **first** structure of type 1 and the **first** of type 2. Later
327
+ structures of the same type are ignored.
328
+
329
+ | Type | Order | Name | Identifying | Field offset within the structure |
330
+ |---|---|---|---|---|
331
+ | 1 System | 1 | `systemManufacturer` | | `0x04` (string) |
332
+ | | 2 | `systemProductName` | | `0x05` (string) |
333
+ | | 3 | `systemUuid` | ✅ | `0x08` (16 raw bytes) |
334
+ | 2 Baseboard | 4 | `baseboardManufacturer` | | `0x04` (string) |
335
+ | | 5 | `baseboardProduct` | | `0x05` (string) |
336
+ | | 6 | `baseboardSerialNumber` | ✅ | `0x07` (string) |
337
+
338
+ At least one of `systemUuid` and `baseboardSerialNumber` must survive, or the machine has no device
339
+ identity and must be refused. **Both being unusable is the common case on cloned VM images and on
340
+ consumer boards**: an unset (all-`00`/all-`FF`) UUID alongside a baseboard serial that is blank or an
341
+ OEM filler string like `To be filled by O.E.M.` Without this rule every such machine would
342
+ fingerprint as its model and share a binding.
343
+
344
+ Type 4 (Processor) is deliberately **not** collected. Its values are model-level rather than
345
+ per-machine, and the number of type-4 structures tracks the CPU socket / vCPU count, so collecting
346
+ them would change the device id every time a VM is resized.
347
+
348
+ SMBIOS structure walking:
349
+
350
+ - Header: `type` (byte @0x00), `length` (byte @0x01, the size of the **formatted** area including
351
+ the header), `handle` (word @0x02).
352
+ - The **string table** immediately follows the formatted area: NUL-terminated strings ending in a
353
+ double-NUL. A structure with no strings is just the double-NUL.
354
+ - A **string field** in the formatted area holds a **1-based index** into that string table. Index
355
+ `0`, or an index past the end, means "no string" and yields an empty value.
356
+ - **Bound every field read by the structure's own `length`**, not by the size of the table. Older
357
+ (SMBIOS 2.x) structures are shorter than the current layout, and reading past the formatted area
358
+ silently picks up bytes from the string pool and resolves a garbage index.
359
+ - `systemUuid` is the 16 bytes at offset `0x08` formatted as **uppercase hexadecimal, no hyphens, no
360
+ byte reordering**: the raw bytes in order, exactly 32 hex characters. Do **not** apply the
361
+ SMBIOS-canonical little-endian swap of the first three UUID fields. The value will therefore not
362
+ match what `dmidecode`, `wmic csproduct get uuid` or `Win32_ComputerSystemProduct` display. That is
363
+ intentional, and an SDK reading the UUID through WMI must undo the swap.
364
+ - An all-`00` or all-`FF` `systemUuid` means "not set" and is treated as **absent**, so fleets of VMs
365
+ with unset UUIDs cannot collide.
366
+
367
+ ### iOS (`ios`) and Android (`android`) — scoped
368
+
369
+ Both platforms deliberately removed every device identifier that unrelated applications can read.
370
+ An SDK on them MAY emit a [scoped identity](#scoped-identity); it has nothing else to offer.
371
+
372
+ | Platform | Order | Name | Identifying | Source |
373
+ |---|---|---|---|---|
374
+ | iOS | 1 | `identifierForVendor` | ✅ | `[[UIDevice currentDevice] identifierForVendor].UUIDString`, with all `-` removed and **uppercased**, matching `ioPlatformUuid`. On watchOS, `[[WKInterfaceDevice currentDevice] identifierForVendor]` |
375
+ | Android | 1 | `androidId` | ✅ | `Settings.Secure.getString(contentResolver, ANDROID_ID)`, lowercased. Must match `^[0-9a-f]{1,16}$` once canonicalized, or it is treated as **absent** |
376
+
377
+ > The Android value must come from `Settings.Secure.getString`. Reading the static field
378
+ > `Settings.Secure.ANDROID_ID` yields the string constant `"android_id"`, which is the *key name* and
379
+ > is identical on every device. An SDK that hashes it gives its entire Android install base one
380
+ > device id, so a single activation unlocks every device. This is not hypothetical: JUCE's
381
+ > `SystemStats::getUniqueDeviceID()` reads the static field via `GetStaticObjectField`, never
382
+ > touching a `ContentResolver`, and still does so in 9.0.0 — so every JUCE Android app returns the
383
+ > same value. Its `jassert` that the result is non-empty never fires, because the hash of a constant
384
+ > is not empty. The defect is silent.
385
+
386
+ The `^[0-9a-f]{1,16}$` rule is what makes that mistake *mechanically* impossible rather than merely
387
+ documented: `"android_id"` is not hex, so it never reaches the material. The bound is `1,16` and not
388
+ `16` because AOSP before 8.0 generated the value with `Long.toHexString`, which drops leading zeros —
389
+ a strict 16 would reject legitimate ids on roughly one in sixteen pre-Oreo devices.
390
+
391
+ Either value **may be absent**, and then resolves to
392
+ [insufficient identity](#insufficient-identity) rather than to a constant. Apple gives "after the
393
+ device has been restarted but before the user has unlocked it" as *an example* of when
394
+ `identifierForVendor` is `nil`, not an exhaustive list; on Android the value is generated lazily and
395
+ `getString` can return null. Treat absence as normal and retry later rather than assuming a cause.
396
+
397
+ ### BSD (`bsd`), other (`unknown`)
398
+
399
+ No identity parameters are defined. These platforms always resolve to
400
+ [insufficient identity](#insufficient-identity).
401
+
402
+ ## Scoped identity
403
+
404
+ A **scoped** device id is stable for a given device within one *scope*, and carries no meaning
405
+ outside it. It exists because some platforms provide nothing better. The unscoped alternatives are
406
+ gone: iOS has not exposed a hardware serial since iOS 7, Android `Build.SERIAL` returns `unknown`
407
+ without a privileged permission from Android 10, IMEI requires `READ_PRIVILEGED_PHONE_STATE`, and
408
+ MAC addresses are randomised.
409
+
410
+ ### What the scope actually is
411
+
412
+ "One publisher" is a useful shorthand and a poor rule, because neither platform scopes by publisher.
413
+ Be precise, because the difference is observable:
414
+
415
+ | Platform | Scope key |
416
+ |---|---|
417
+ | iOS | The **vendor**: determined by App Store data, and for apps installed any other way, every component of the reverse-DNS bundle id *except the last*. Not the Team ID. |
418
+ | Android, API 26+ | The **app signing key**, per OS user, per device. |
419
+ | Android, before API 26 | The **device and OS user** only. Every app on the device reads the same value. |
420
+
421
+ So `com.example.editor` and `com.example.player` share an iOS scope, while the same publisher's two
422
+ Android apps signed with different keys do **not** share an Android one on API 26 or later. An SDK
423
+ must never assume that "same publisher" means "same scope".
424
+
425
+ > Per-signing-key scoping arrived in Android 8.0. Older devices are still in scope for this spec —
426
+ > the `^[0-9a-f]{1,16}$` rule below deliberately accepts the shorter ids they generate — and on them
427
+ > `ANDROID_ID` is a single per-device value that every installed app can read. That makes the scope
428
+ > *wider* than the table's first Android row, never narrower, so treating those ids as scoped is
429
+ > conservative rather than unsound: the rules below forbid correlating them, which is still correct
430
+ > when they happen to be correlatable. It does mean two unrelated apps on one pre-Oreo device compute
431
+ > the **same** scoped id, so a server must not infer distinct devices from distinct ids, nor one
432
+ > device from one id.
433
+
434
+ ### What scoped identity guarantees
435
+
436
+ Scoped ids are stamped `mbd2s_` so the limitation travels with the value.
437
+
438
+ **The stamp follows the platform, not the call site.** Any id built from the identity parameters of a
439
+ scoped platform is scoped, whatever collected them: an SDK's own reader, a host bridging
440
+ `identifierForVendor` through platform API, an embedder assembling the material itself. Derive the
441
+ source from the platform tag rather than letting a caller pass it, because the one thing that cannot
442
+ happen is a scoped value going out stamped `mbd2_`. That would tell the server it is a hardware
443
+ fingerprint comparable across every app on the device, and the server would then be entitled to
444
+ correlate exactly the ids the rules below forbid correlating.
445
+
446
+ Those rules are what the stamp promises:
447
+
448
+ - Two scoped ids from **different scopes are not comparable at all**. Equal values do not imply the
449
+ same device, and different values do not imply different devices. A validator, a server and an
450
+ analytics pipeline must all refuse to correlate them.
451
+ - A scoped id and an unscoped one are likewise never comparable, so a machine that could produce
452
+ both must not be given a scoped id. See the Mac Catalyst rule under [Platform tags](#platform-tags).
453
+ - Everything else is unchanged: same canonicalization, same material grammar, same digest.
454
+
455
+ Within one scope the [stability contract](#stability-contract) holds for every hardware event in it —
456
+ renames, network changes, OS upgrades. But scoped values also move for reasons no hardware
457
+ identifier does, and this table, not that one, is what `mbd2s_` promises:
458
+
459
+ | Event | The device id may |
460
+ |---|---|
461
+ | App reinstalled, iOS, at least one other app from the vendor still installed | **not** change |
462
+ | App reinstalled, Android, same signing key | **not** change |
463
+ | **Every** app from that vendor deleted, then one reinstalled (iOS) | change |
464
+ | Installed by Xcode or ad-hoc distribution rather than the App Store (iOS) | change |
465
+ | App signing key rotated between uninstall and reinstall (Android, API 26+) | change |
466
+ | Device factory reset | change |
467
+ | A different OS user on the same device (Android) | change |
468
+ | App transferred to another App Store team (iOS) | change |
469
+
470
+ Every "change" row costs the user a re-activation. That is the price of the platform, not a defect
471
+ to be engineered around — the only way to avoid it is an identifier neither platform offers.
472
+
473
+ Scoped identity is a floor, not a preference. An SDK MUST use hardware identity where the platform
474
+ provides it, and MAY use a scoped identity only where it does not.
475
+
476
+ ## Insufficient identity
477
+
478
+ An SDK **must** raise an error when no parameter survives canonicalization, or when none of the
479
+ survivors is an [identifying parameter](#identifying-parameters). It must **not** hash the platform
480
+ line alone, **not** hash a model-only parameter set, and **not** silently substitute the host name.
481
+
482
+ Each of those is well-defined but catastrophic in the same way: it hands a whole class of machines
483
+ (every machine on a platform, or every unit of a model) the *same* device id, and a license bound to
484
+ that id then validates on all of them. Substituting the host name is nearly as bad, being
485
+ user-renameable, duplicated across imaged fleets, and regenerated on every container start.
486
+
487
+ An SDK **may** offer an explicit, opt-in host-name fallback for platforms with no defined
488
+ parameters. The material is then the single parameter `deviceName=<host name>`, and the id **must**
489
+ be stamped `mbd2n_` so the weaker binding is visible to the server and to support. If the host name
490
+ is empty too, that is still insufficient identity.
491
+
492
+ > **The host-name fallback MUST NOT be offered on `ios` or `android`.** On those platforms the host
493
+ > name is not weak identity, it is not identity at all: since iOS 17 `gethostname()` and
494
+ > `utsname.nodename` return the literal `localhost` on every device, and since iOS 16
495
+ > `UIDevice.name` returns the model name — `"iPhone"` — regardless of which SDK the app was built
496
+ > against. The entitlement that restores the user-assigned name is granted only to apps that do not
497
+ > use it for fingerprinting, so it is closed to licensing by policy as well as by API.
498
+ >
499
+ > An iOS SDK that fell through to this fallback when `identifierForVendor` was momentarily absent
500
+ > would hand its **entire install base one device id**, and a single activation would unlock every
501
+ > device — exactly the catastrophe this section exists to prevent, reached by following the section
502
+ > above it. On those platforms the ladder is [scoped identity](#scoped-identity), then insufficient
503
+ > identity, and nothing else. Absence is transient: raise the error and retry later.
504
+
505
+ ## Device name
506
+
507
+ A human-readable label sent alongside the device id at activation. It is **not** part of the
508
+ material (except in the opt-in fallback above), so it can change freely without invalidating a
509
+ license.
510
+
511
+ | Platform | Source |
512
+ |---|---|
513
+ | macOS | host name, with a trailing `.local` removed (case-insensitive) |
514
+ | iOS | `UIDevice.name` (the model name on iOS 16+), or the empty string |
515
+ | Android | `Settings.Global.DEVICE_NAME`, falling back to `Build.MODEL`, or the empty string |
516
+ | other | host name |
517
+
518
+ On iOS and Android this label is close to worthless for telling two devices apart — it is the model
519
+ name on most modern devices. That is tolerable *because it is only a label*: it never enters the
520
+ material on those platforms, since the host-name fallback is forbidden there. An empty value is fine;
521
+ the server treats the label as decoration, not identity.
522
+
523
+ ## Worked examples
524
+
525
+ Reproduced by [`fingerprint-vectors.json`](./fingerprint-vectors.json), which contains these and
526
+ many more. Materials are shown with literal newlines and, to repeat, **no trailing newline**.
527
+
528
+ **macOS:**
529
+
530
+ ```
531
+ moonbase:fingerprint:v2
532
+ platform=mac
533
+ ioPlatformUuid=0123456789ABCDEF0123456789ABCDEF
534
+ ```
535
+ → `mbd2_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32`
536
+
537
+ **Linux:**
538
+
539
+ ```
540
+ moonbase:fingerprint:v2
541
+ platform=linux
542
+ machineId=b08dfa6083e7567a1921a715000001fb
543
+ sysVendor=LENOVO
544
+ productName=20HRCTO1WW
545
+ boardVendor=LENOVO
546
+ boardName=20HRCTO1WW
547
+ ```
548
+ → `mbd2_ba16d78604f90c6c8b00dc1065a70c866884fadfa818ed2db83b2bfe0dc94933`
549
+
550
+ **Windows:**
551
+
552
+ ```
553
+ moonbase:fingerprint:v2
554
+ platform=windows
555
+ systemManufacturer=ACME
556
+ systemProductName=Server 9000
557
+ systemUuid=0123456789ABCDEF0123456789ABCDEF
558
+ baseboardManufacturer=ACME
559
+ baseboardProduct=MB-1
560
+ baseboardSerialNumber=BSN-42
561
+ ```
562
+ → `mbd2_fadd75457e44f669e9865caff122b4706a4501089ac9e73b8735139bf57676ad`
563
+
564
+ **iOS** — scoped, note the `s`:
565
+
566
+ ```
567
+ moonbase:fingerprint:v2
568
+ platform=ios
569
+ identifierForVendor=0123456789ABCDEF0123456789ABCDEF
570
+ ```
571
+ → `mbd2s_298ced47f8d983939db1d5fce6d4b4f2f8766aa19e3e17536fcd1604a81febf1`
572
+
573
+ **Android** — also scoped:
574
+
575
+ ```
576
+ moonbase:fingerprint:v2
577
+ platform=android
578
+ androidId=a1b2c3d4e5f60718
579
+ ```
580
+ → `mbd2s_ca988ecf5c529964bfaa80734da3dbe070dd41881aa43f8c472f3a5d512b4eff`
581
+
582
+ **Opt-in host-name fallback** (note the `n`):
583
+
584
+ ```
585
+ moonbase:fingerprint:v2
586
+ platform=unknown
587
+ deviceName=PC-1
588
+ ```
589
+ → `mbd2n_493978eb157552e60a13694bd6861b2a82d0dba2746431a5f7921951ed460045`
590
+
591
+ ## Conformance checklist
592
+
593
+ Run [`fingerprint-vectors.json`](./fingerprint-vectors.json) in your SDK's test suite. It covers
594
+ every item in the first list; that list is what to look at when a vector fails. The second list is
595
+ about *behaviour around* the id rather than its computation, so no vector can settle it — review it
596
+ by hand.
597
+
598
+ **Covered by the vectors:**
599
+
600
+ - [ ] Material prefix is exactly `moonbase:fingerprint:v2`.
601
+ - [ ] Lines are **joined** with a single LF, and the material has **no trailing newline**.
602
+ - [ ] Values are canonicalized NFC → printable-ASCII-only → capped at 128 → space-trimmed, in that
603
+ order; empty values are dropped.
604
+ - [ ] Identifying parameters holding an unprogrammed placeholder are dropped; descriptive ones are
605
+ not.
606
+ - [ ] An empty surviving parameter set raises an error rather than producing a digest.
607
+ - [ ] A surviving set with no identifying parameter raises an error rather than fingerprinting the
608
+ model.
609
+ - [ ] Duplicate parameter names raise an error.
610
+ - [ ] Per-platform params are collected with the exact names and order above.
611
+ - [ ] Linux spawns no subprocess and reads no root-only file, and validates each `machine-id` source
612
+ against `^[0-9a-f]{32}$` and the placeholder rule before selecting it.
613
+ - [ ] Windows takes only the first type-1 and first type-2 structure, ignores type 4, and bounds
614
+ every field read by the structure `length`.
615
+ - [ ] `systemUuid` is uppercase hex, no hyphens, no byte swap; all-`00`/all-`FF` is absent.
616
+ - [ ] `androidId` comes from `Settings.Secure.getString` and matches `^[0-9a-f]{1,16}$`; the literal
617
+ `"android_id"` never reaches the material.
618
+ - [ ] Digest is SHA-256 over UTF-8 material, output as 64 lowercase hex characters.
619
+ - [ ] The emitted device id is stamped `mbd2_` (`mbd2n_` for the opt-in fallback, `mbd2s_` for a
620
+ scoped identity).
621
+ - [ ] A source tag the SDK does not define still **parses**, so the id can be compared literally
622
+ rather than rejected as "not a Moonbase id". A tag that happens to name a built-in of the
623
+ implementation language resolves the same way as any other unknown tag.
624
+ - [ ] An id built from a scoped platform's parameters is stamped `mbd2s_` no matter which code path
625
+ collected them.
626
+
627
+ **Review by hand:**
628
+
629
+ - [ ] The platform tag follows the OS the process runs on. A Mac Catalyst build uses hardware
630
+ identity, not the scoped path.
631
+ - [ ] The host-name fallback is not offered on `ios` or `android`.
632
+ - [ ] A scoped id is never compared against one from another [scope](#what-the-scope-actually-is) —
633
+ in the SDK, on the server, and in analytics. Note that the last two live outside this
634
+ repository, so the vectors could not check them even in principle.
635
+ - [ ] A version or source-tag difference is surfaced without claiming the license came from this
636
+ machine.
637
+
638
+ ## Versioning
639
+
640
+ The material prefix and the device id stamp both carry the version, and they always match. Any
641
+ change to collection rules, ordering, canonicalization or encoding that would alter output for an
642
+ unchanged machine **must** bump both (to `moonbase:fingerprint:v3` and `mbd3_`).
643
+
644
+ Because the version is recoverable from the id, an SDK can validate against several versions during
645
+ a migration while emitting only one. Parse the stamp on the `sig` claim and compute that version. If
646
+ the SDK no longer supports it, say the license needs re-activating rather than reporting the machine
647
+ as wrong.