@moonbase.sh/licensing 3.0.0 → 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.
- package/FINGERPRINT_SPEC.md +241 -8
- package/README.md +12 -2
- package/dist/index.cjs +107 -20
- package/dist/index.d.cts +73 -20
- package/dist/index.d.ts +73 -20
- package/dist/index.js +105 -20
- package/fingerprint-vectors.json +204 -0
- package/package.json +1 -1
package/FINGERPRINT_SPEC.md
CHANGED
|
@@ -7,6 +7,13 @@ machine **device id**. Any two SDKs that conform compute the same id on a given
|
|
|
7
7
|
license bound by one validates in the other. SDKs adopt the spec independently, so conformance is a
|
|
8
8
|
property of a given SDK version, not something to assume.
|
|
9
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
|
+
|
|
10
17
|
Implement against this document and prove it against
|
|
11
18
|
[`fingerprint-vectors.json`](./fingerprint-vectors.json), the machine-readable conformance suite
|
|
12
19
|
shipped alongside it. If an SDK disagrees with this spec, the SDK is the bug. If this spec
|
|
@@ -21,8 +28,11 @@ divergence by defining a byte-exact, deterministic algorithm.
|
|
|
21
28
|
|
|
22
29
|
## Stability contract
|
|
23
30
|
|
|
24
|
-
The algorithm answers one question: *is this the same machine?* Every parameter
|
|
25
|
-
satisfy this table, and any proposed change must be argued against it.
|
|
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.
|
|
26
36
|
|
|
27
37
|
| Event | The device id must |
|
|
28
38
|
|---|---|
|
|
@@ -69,8 +79,29 @@ device_id = "mbd" + version + source_tag + "_" + digest
|
|
|
69
79
|
For this version: `mbd2_` plus 64 hex characters, 69 in total. It uses only RFC 3986 unreserved
|
|
70
80
|
characters, so it never needs escaping in a URL, JSON body, file name or shell command.
|
|
71
81
|
|
|
72
|
-
`source_tag`
|
|
73
|
-
|
|
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.
|
|
74
105
|
|
|
75
106
|
### Why the id is stamped
|
|
76
107
|
|
|
@@ -160,6 +191,8 @@ Exactly these parameters count as **identifying**, describing the individual mac
|
|
|
160
191
|
| `machineId` | Linux |
|
|
161
192
|
| `systemUuid` | Windows |
|
|
162
193
|
| `baseboardSerialNumber` | Windows |
|
|
194
|
+
| `identifierForVendor` | iOS ([scoped](#scoped-identity)) |
|
|
195
|
+
| `androidId` | Android ([scoped](#scoped-identity)) |
|
|
163
196
|
| `deviceName` | the opt-in host-name fallback only |
|
|
164
197
|
|
|
165
198
|
At least one must survive canonicalization, or the result is
|
|
@@ -179,6 +212,18 @@ product line. Compared case-insensitively against the canonical value:
|
|
|
179
212
|
`not available`, `none`, `unknown`, `invalid`, `n/a`, `0123456789`, `uninitialized`, plus any value
|
|
180
213
|
that is entirely `0`s or entirely `f`/`F`s (a blank UUID field, a zeroed `machine-id`).
|
|
181
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
|
+
|
|
182
227
|
This applies to **identifying parameters only**. A descriptive field reading `Default string` is
|
|
183
228
|
still a fair description of the model and stays in the material. A serial number reading it is not a
|
|
184
229
|
serial number.
|
|
@@ -188,12 +233,36 @@ serial number.
|
|
|
188
233
|
| OS family | Tag |
|
|
189
234
|
|---|---|
|
|
190
235
|
| macOS | `mac` |
|
|
236
|
+
| iOS, iPadOS, tvOS, watchOS, visionOS | `ios` |
|
|
191
237
|
| Windows | `windows` |
|
|
192
238
|
| Linux | `linux` |
|
|
193
239
|
| Android | `android` |
|
|
194
240
|
| FreeBSD / OpenBSD / NetBSD | `bsd` |
|
|
195
241
|
| anything else | `unknown` |
|
|
196
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
|
+
|
|
197
266
|
## Identity parameters per platform
|
|
198
267
|
|
|
199
268
|
Parameters **must** appear in the order listed. Reads are best-effort: a missing or unreadable
|
|
@@ -295,11 +364,115 @@ SMBIOS structure walking:
|
|
|
295
364
|
- An all-`00` or all-`FF` `systemUuid` means "not set" and is treated as **absent**, so fleets of VMs
|
|
296
365
|
with unset UUIDs cannot collide.
|
|
297
366
|
|
|
298
|
-
###
|
|
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`)
|
|
299
398
|
|
|
300
399
|
No identity parameters are defined. These platforms always resolve to
|
|
301
400
|
[insufficient identity](#insufficient-identity).
|
|
302
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
|
+
|
|
303
476
|
## Insufficient identity
|
|
304
477
|
|
|
305
478
|
An SDK **must** raise an error when no parameter survives canonicalization, or when none of the
|
|
@@ -316,6 +489,19 @@ parameters. The material is then the single parameter `deviceName=<host name>`,
|
|
|
316
489
|
be stamped `mbd2n_` so the weaker binding is visible to the server and to support. If the host name
|
|
317
490
|
is empty too, that is still insufficient identity.
|
|
318
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
|
+
|
|
319
505
|
## Device name
|
|
320
506
|
|
|
321
507
|
A human-readable label sent alongside the device id at activation. It is **not** part of the
|
|
@@ -325,8 +511,15 @@ license.
|
|
|
325
511
|
| Platform | Source |
|
|
326
512
|
|---|---|
|
|
327
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 |
|
|
328
516
|
| other | host name |
|
|
329
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
|
+
|
|
330
523
|
## Worked examples
|
|
331
524
|
|
|
332
525
|
Reproduced by [`fingerprint-vectors.json`](./fingerprint-vectors.json), which contains these and
|
|
@@ -368,6 +561,24 @@ baseboardSerialNumber=BSN-42
|
|
|
368
561
|
```
|
|
369
562
|
→ `mbd2_fadd75457e44f669e9865caff122b4706a4501089ac9e73b8735139bf57676ad`
|
|
370
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
|
+
|
|
371
582
|
**Opt-in host-name fallback** (note the `n`):
|
|
372
583
|
|
|
373
584
|
```
|
|
@@ -380,7 +591,11 @@ deviceName=PC-1
|
|
|
380
591
|
## Conformance checklist
|
|
381
592
|
|
|
382
593
|
Run [`fingerprint-vectors.json`](./fingerprint-vectors.json) in your SDK's test suite. It covers
|
|
383
|
-
every item
|
|
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:**
|
|
384
599
|
|
|
385
600
|
- [ ] Material prefix is exactly `moonbase:fingerprint:v2`.
|
|
386
601
|
- [ ] Lines are **joined** with a single LF, and the material has **no trailing newline**.
|
|
@@ -398,9 +613,27 @@ every item below; this list is what to look at when a vector fails.
|
|
|
398
613
|
- [ ] Windows takes only the first type-1 and first type-2 structure, ignores type 4, and bounds
|
|
399
614
|
every field read by the structure `length`.
|
|
400
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.
|
|
401
618
|
- [ ] Digest is SHA-256 over UTF-8 material, output as 64 lowercase hex characters.
|
|
402
|
-
- [ ] The emitted device id is stamped `mbd2_` (
|
|
403
|
-
|
|
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.
|
|
404
637
|
|
|
405
638
|
## Versioning
|
|
406
639
|
|
package/README.md
CHANGED
|
@@ -116,7 +116,9 @@ cross-SDK **[device fingerprint spec](./FINGERPRINT_SPEC.md)** (`moonbase:finger
|
|
|
116
116
|
SHA-256 of stable native hardware identifiers, stamped with the spec version.
|
|
117
117
|
|
|
118
118
|
```
|
|
119
|
-
mbd2_9f3c… // 'mbd' + version + '_' + 64 lowercase hex characters
|
|
119
|
+
mbd2_9f3c… // 'mbd' + version + source tag + '_' + 64 lowercase hex characters
|
|
120
|
+
mbd2n_9f3c… // 'n': the opt-in host-name fallback (see below)
|
|
121
|
+
mbd2s_9f3c… // 's': an app-scoped id, which only the mobile SDKs produce
|
|
120
122
|
```
|
|
121
123
|
|
|
122
124
|
Sources are SMBIOS on Windows, `IOPlatformUUID` on macOS, and `machine-id` plus world-readable DMI
|
|
@@ -125,6 +127,13 @@ passes the shipped [`fingerprint-vectors.json`](./fingerprint-vectors.json) comp
|
|
|
125
127
|
given machine, so a license activated by one validates in the others. Adoption is per-SDK, so check
|
|
126
128
|
the version of whichever SDK you are pairing with before relying on it.
|
|
127
129
|
|
|
130
|
+
This package **parses** all three forms — including a tag introduced by a newer SDK, which it
|
|
131
|
+
compares literally rather than rejecting — but its built-in readers only ever **produce** `mbd2_` or
|
|
132
|
+
`mbd2n_`. Scoped ids come from iOS and Android, where the OS exposes no identifier an unrelated app
|
|
133
|
+
can read; collecting them needs platform API no Node.js process can call, so that is the C++/.NET
|
|
134
|
+
SDKs' job. A scoped id is stable only within one app scope and must never be compared with one from
|
|
135
|
+
another, which is exactly what the `s` tag exists to announce.
|
|
136
|
+
|
|
128
137
|
> Both files ship inside the package. If the links above do not resolve where you are reading this,
|
|
129
138
|
> find them in `node_modules/@moonbase.sh/licensing/`.
|
|
130
139
|
|
|
@@ -157,7 +166,8 @@ catch (err) {
|
|
|
157
166
|
The resolver throws `InsufficientDeviceIdentityError` rather than falling back to something weak, in
|
|
158
167
|
two cases:
|
|
159
168
|
|
|
160
|
-
- **Nothing readable.** A sandboxed process
|
|
169
|
+
- **Nothing readable.** A sandboxed process; a platform the spec defines no parameters for (BSD, and
|
|
170
|
+
anything unrecognised); or Android, whose parameter this package cannot reach from Node.
|
|
161
171
|
- **Only model-level values readable.** Vendor, product and board names are byte-identical across
|
|
162
172
|
every unit of a product line, so fingerprinting them would let those machines validate one
|
|
163
173
|
another's licenses. In practice: a Linux install with no `machine-id`, or a machine whose SMBIOS
|
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
ActivationMethod: () => ActivationMethod,
|
|
34
|
+
DEVICE_ID_SOURCE_TAGS: () => DEVICE_ID_SOURCE_TAGS,
|
|
34
35
|
ErrorType: () => ErrorType,
|
|
35
36
|
FINGERPRINT_PREFIX: () => FINGERPRINT_PREFIX,
|
|
36
37
|
FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
|
|
@@ -52,6 +53,7 @@ __export(index_exports, {
|
|
|
52
53
|
defaultDeviceIdentityReader: () => defaultDeviceIdentityReader,
|
|
53
54
|
fingerprintDeviceId: () => fingerprintDeviceId,
|
|
54
55
|
fingerprintDigest: () => fingerprintDigest,
|
|
56
|
+
identitySource: () => identitySource,
|
|
55
57
|
parseDeviceIdStamp: () => parseDeviceIdStamp,
|
|
56
58
|
parseIoregPlatformUuid: () => parseIoregPlatformUuid,
|
|
57
59
|
parseSmbiosParams: () => parseSmbiosParams,
|
|
@@ -283,7 +285,20 @@ var FINGERPRINT_VERSION = 2;
|
|
|
283
285
|
var MAX_VALUE_LENGTH = 128;
|
|
284
286
|
var PRINTABLE_ASCII_MIN = 32;
|
|
285
287
|
var PRINTABLE_ASCII_MAX = 126;
|
|
286
|
-
var
|
|
288
|
+
var SOURCE_TAGS = {
|
|
289
|
+
identity: "",
|
|
290
|
+
deviceName: "n",
|
|
291
|
+
scoped: "s"
|
|
292
|
+
};
|
|
293
|
+
var DEVICE_ID_SOURCE_TAGS = Object.freeze({ ...SOURCE_TAGS });
|
|
294
|
+
var SOURCE_BY_TAG = new Map(
|
|
295
|
+
Object.entries(SOURCE_TAGS).map(([source, tag]) => [tag, source])
|
|
296
|
+
);
|
|
297
|
+
var SCOPED_PLATFORMS = /* @__PURE__ */ new Set(["ios", "android"]);
|
|
298
|
+
function identitySource(platform) {
|
|
299
|
+
return SCOPED_PLATFORMS.has(platform) ? "scoped" : "identity";
|
|
300
|
+
}
|
|
301
|
+
var STAMP_PATTERN = /^mbd(\d+)([a-z]*)_([0-9a-f]{64})$/;
|
|
287
302
|
function canonicalizeValue(value) {
|
|
288
303
|
var _a;
|
|
289
304
|
let printable = "";
|
|
@@ -294,6 +309,12 @@ function canonicalizeValue(value) {
|
|
|
294
309
|
}
|
|
295
310
|
return printable.slice(0, MAX_VALUE_LENGTH).replace(/^ +| +$/g, "");
|
|
296
311
|
}
|
|
312
|
+
var PLATFORM_TAGS = /* @__PURE__ */ new Set(
|
|
313
|
+
["mac", "ios", "windows", "android", "linux", "bsd", "unknown"]
|
|
314
|
+
);
|
|
315
|
+
function isPlatformTag(value) {
|
|
316
|
+
return PLATFORM_TAGS.has(value);
|
|
317
|
+
}
|
|
297
318
|
function platformTag(platform = import_node_process.default.platform) {
|
|
298
319
|
switch (platform) {
|
|
299
320
|
case "darwin":
|
|
@@ -309,7 +330,7 @@ function platformTag(platform = import_node_process.default.platform) {
|
|
|
309
330
|
case "netbsd":
|
|
310
331
|
return "bsd";
|
|
311
332
|
default:
|
|
312
|
-
return "unknown";
|
|
333
|
+
return isPlatformTag(platform) ? platform : "unknown";
|
|
313
334
|
}
|
|
314
335
|
}
|
|
315
336
|
var IDENTIFYING_PARAMS = /* @__PURE__ */ new Set([
|
|
@@ -317,6 +338,8 @@ var IDENTIFYING_PARAMS = /* @__PURE__ */ new Set([
|
|
|
317
338
|
"machineId",
|
|
318
339
|
"systemUuid",
|
|
319
340
|
"baseboardSerialNumber",
|
|
341
|
+
"identifierForVendor",
|
|
342
|
+
"androidId",
|
|
320
343
|
"deviceName"
|
|
321
344
|
]);
|
|
322
345
|
var IDENTIFYING_PARAM_NAMES = Object.freeze([...IDENTIFYING_PARAMS]);
|
|
@@ -341,9 +364,35 @@ var NOT_PROGRAMMED_VALUES = /* @__PURE__ */ new Set([
|
|
|
341
364
|
// deployed from such an image reads it, so it is the opposite of an identifier.
|
|
342
365
|
"uninitialized"
|
|
343
366
|
]);
|
|
367
|
+
var IDENTITY_CONSTRAINTS = {
|
|
368
|
+
androidId: {
|
|
369
|
+
// Reading the *static field* `Settings.Secure.ANDROID_ID` instead of calling
|
|
370
|
+
// `getString` with it yields the key name "android_id", identical on every
|
|
371
|
+
// device, so a single activation would unlock a whole Android install base.
|
|
372
|
+
// That string is not hex, so this stops it reaching the material.
|
|
373
|
+
//
|
|
374
|
+
// The bound is 1,16 and not 16 because AOSP before 8.0 generated the value
|
|
375
|
+
// with `Long.toHexString`, which drops leading zeros — a strict 16 would
|
|
376
|
+
// reject legitimate ids on roughly one in sixteen pre-Oreo devices.
|
|
377
|
+
format: /^[0-9a-f]{1,16}$/,
|
|
378
|
+
// A real ANDROID_ID shared by a large batch of 2010-era devices whose
|
|
379
|
+
// `ro.serialno` was unset, seeding the generator identically on every unit.
|
|
380
|
+
// Valid hex, so the format rule cannot catch it.
|
|
381
|
+
rejected: /* @__PURE__ */ new Set(["9774d56d682e549c"])
|
|
382
|
+
}
|
|
383
|
+
};
|
|
344
384
|
function isNotProgrammed(value) {
|
|
345
385
|
return NOT_PROGRAMMED_VALUES.has(value.toLowerCase()) || /^0+$/.test(value) || /^f+$/i.test(value);
|
|
346
386
|
}
|
|
387
|
+
function isUsableIdentity(name, value) {
|
|
388
|
+
var _a, _b, _c;
|
|
389
|
+
if (isNotProgrammed(value))
|
|
390
|
+
return false;
|
|
391
|
+
const constraint = IDENTITY_CONSTRAINTS[name];
|
|
392
|
+
if (!constraint)
|
|
393
|
+
return true;
|
|
394
|
+
return ((_b = (_a = constraint.format) == null ? void 0 : _a.test(value)) != null ? _b : true) && !((_c = constraint.rejected) == null ? void 0 : _c.has(value.toLowerCase()));
|
|
395
|
+
}
|
|
347
396
|
function canonicalizeParams(params) {
|
|
348
397
|
const kept = [];
|
|
349
398
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -351,7 +400,7 @@ function canonicalizeParams(params) {
|
|
|
351
400
|
const value = canonicalizeValue(rawValue);
|
|
352
401
|
if (value.length === 0)
|
|
353
402
|
continue;
|
|
354
|
-
if (IDENTIFYING_PARAMS.has(name) &&
|
|
403
|
+
if (IDENTIFYING_PARAMS.has(name) && !isUsableIdentity(name, value))
|
|
355
404
|
continue;
|
|
356
405
|
if (seen.has(name))
|
|
357
406
|
throw new Error(`Duplicate fingerprint parameter name: ${name}`);
|
|
@@ -364,6 +413,12 @@ function buildFingerprintMaterial(platform, params) {
|
|
|
364
413
|
const kept = canonicalizeParams(params);
|
|
365
414
|
if (kept.length === 0)
|
|
366
415
|
throw new InsufficientDeviceIdentityError(platform, "no identity parameter could be read");
|
|
416
|
+
if (SCOPED_PLATFORMS.has(platform) && kept.some(([name]) => name === "deviceName")) {
|
|
417
|
+
throw new InsufficientDeviceIdentityError(
|
|
418
|
+
platform,
|
|
419
|
+
"the host-name fallback is not available on this platform, where the host name is the same on every device"
|
|
420
|
+
);
|
|
421
|
+
}
|
|
367
422
|
if (!kept.some(([name]) => IDENTIFYING_PARAMS.has(name))) {
|
|
368
423
|
throw new InsufficientDeviceIdentityError(
|
|
369
424
|
platform,
|
|
@@ -379,18 +434,20 @@ function fingerprintDigest(material) {
|
|
|
379
434
|
return (0, import_node_crypto.createHash)("sha256").update(material, "utf8").digest("hex");
|
|
380
435
|
}
|
|
381
436
|
function stampDeviceId(digest, source = "identity") {
|
|
382
|
-
return `mbd${FINGERPRINT_VERSION}${source
|
|
437
|
+
return `mbd${FINGERPRINT_VERSION}${SOURCE_TAGS[source]}_${digest}`;
|
|
383
438
|
}
|
|
384
439
|
function fingerprintDeviceId(material, source = "identity") {
|
|
385
440
|
return stampDeviceId(fingerprintDigest(material), source);
|
|
386
441
|
}
|
|
387
442
|
function parseDeviceIdStamp(deviceId) {
|
|
443
|
+
var _a;
|
|
388
444
|
const match = STAMP_PATTERN.exec(deviceId);
|
|
389
445
|
if (!match)
|
|
390
446
|
return null;
|
|
391
447
|
return {
|
|
392
448
|
version: Number(match[1]),
|
|
393
|
-
|
|
449
|
+
sourceTag: match[2],
|
|
450
|
+
source: (_a = SOURCE_BY_TAG.get(match[2])) != null ? _a : null,
|
|
394
451
|
digest: match[3]
|
|
395
452
|
};
|
|
396
453
|
}
|
|
@@ -519,18 +576,23 @@ function readWindowsIdentity() {
|
|
|
519
576
|
return { params: parseSmbiosParams(readWindowsSmbios()), deviceName: import_node_os.default.hostname() };
|
|
520
577
|
}
|
|
521
578
|
function defaultDeviceIdentityReader(platform = import_node_process.default.platform) {
|
|
579
|
+
const tag = platformTag(platform);
|
|
522
580
|
return {
|
|
523
581
|
read() {
|
|
524
|
-
switch (
|
|
525
|
-
case "
|
|
582
|
+
switch (tag) {
|
|
583
|
+
case "mac":
|
|
526
584
|
return readMacIdentity();
|
|
527
585
|
case "linux":
|
|
528
586
|
return readLinuxIdentity();
|
|
529
|
-
case "
|
|
587
|
+
case "windows":
|
|
530
588
|
return readWindowsIdentity();
|
|
531
|
-
//
|
|
532
|
-
// has no stable hardware source for them, so they resolve to an
|
|
589
|
+
// Everything else yields no identity params here, so it resolves to an
|
|
533
590
|
// insufficient-identity error unless the deviceName fallback is enabled.
|
|
591
|
+
// For bsd and unknown that matches the spec, which defines none. Android
|
|
592
|
+
// is different: the spec *does* define `androidId`, but it comes from
|
|
593
|
+
// `Settings.Secure.getString`, which is Android-framework API that a
|
|
594
|
+
// Node.js process cannot reach. Collecting it is the C++/.NET SDKs' job.
|
|
595
|
+
// A host that can bridge it should supply its own DeviceIdentityReader.
|
|
534
596
|
default:
|
|
535
597
|
return { params: [], deviceName: import_node_os.default.hostname() };
|
|
536
598
|
}
|
|
@@ -582,7 +644,7 @@ var MoonbaseDeviceIdResolver = class {
|
|
|
582
644
|
computeDescription() {
|
|
583
645
|
const { params, deviceName } = this.readIdentity();
|
|
584
646
|
try {
|
|
585
|
-
return this.describe(params,
|
|
647
|
+
return this.describe(params, identitySource(this.platform));
|
|
586
648
|
} catch (err) {
|
|
587
649
|
if (this.fallback !== "deviceName" || !(err instanceof InsufficientDeviceIdentityError))
|
|
588
650
|
throw err;
|
|
@@ -829,25 +891,48 @@ var LicenseValidator = class {
|
|
|
829
891
|
};
|
|
830
892
|
function deviceMismatchError(expected, bound) {
|
|
831
893
|
const detail = "This license is not for this device";
|
|
832
|
-
const
|
|
894
|
+
const stampNote = describeStampDifference(expected, bound);
|
|
833
895
|
return new MoonbaseError(
|
|
834
896
|
"License is for another device",
|
|
835
|
-
|
|
897
|
+
stampNote ? `${detail}. ${stampNote}` : detail,
|
|
836
898
|
"LicenseDeviceMismatch" /* LicenseDeviceMismatch */
|
|
837
899
|
);
|
|
838
900
|
}
|
|
839
|
-
function
|
|
901
|
+
function describeStampDifference(expected, bound) {
|
|
840
902
|
const expectedStamp = parseDeviceIdStamp(expected);
|
|
841
903
|
if (!expectedStamp)
|
|
842
904
|
return null;
|
|
843
905
|
const boundStamp = parseDeviceIdStamp(bound);
|
|
844
|
-
if (boundStamp
|
|
845
|
-
return
|
|
846
|
-
if (boundStamp
|
|
847
|
-
return
|
|
906
|
+
if (!boundStamp || boundStamp.version !== expectedStamp.version)
|
|
907
|
+
return describeVersionDifference(expectedStamp, boundStamp);
|
|
908
|
+
if (boundStamp.sourceTag !== expectedStamp.sourceTag)
|
|
909
|
+
return describeSourceDifference(expectedStamp, boundStamp);
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
function describeVersionDifference(expected, bound) {
|
|
913
|
+
if (bound && bound.version > expected.version) {
|
|
914
|
+
return `The binding was created by device fingerprint v${bound.version}, which is newer than the v${expected.version} this SDK computes \u2014 update the SDK rather than re-activating, which would rebind the device to the older algorithm.`;
|
|
915
|
+
}
|
|
916
|
+
const boundVersion = bound ? `device fingerprint v${bound.version}` : "an SDK predating versioned device fingerprints";
|
|
917
|
+
return `The binding was created by ${boundVersion}, while this SDK computes v${expected.version}, so this may instead be the same machine bound under the older algorithm \u2014 re-activate to find out, or configure a MigratingDeviceIdResolver to keep accepting the previous id.`;
|
|
918
|
+
}
|
|
919
|
+
function describeSourceDifference(expected, bound) {
|
|
920
|
+
if (bound.source === "scoped") {
|
|
921
|
+
return "The binding uses an app-scoped device identity, which cannot be compared with the id this SDK computes \u2014 not even on the same device. Re-activate here to bind this build.";
|
|
922
|
+
}
|
|
923
|
+
if (expected.source === "scoped") {
|
|
924
|
+
return "This SDK computes an app-scoped device identity, which cannot be compared with the one the binding carries \u2014 not even on the same device. Re-activate here to bind this app.";
|
|
925
|
+
}
|
|
926
|
+
if (bound.source === null) {
|
|
927
|
+
return `The binding carries the device identity tag "${bound.sourceTag}", which this SDK does not recognise \u2014 it was created by a newer Moonbase SDK, so update rather than re-activating.`;
|
|
928
|
+
}
|
|
929
|
+
if (expected.source === null) {
|
|
930
|
+
return `The id this SDK computes carries the device identity tag "${expected.sourceTag}", which the spec does not define \u2014 it came from a custom device id resolver, so check that resolver rather than the binding.`;
|
|
931
|
+
}
|
|
932
|
+
if (bound.source === "deviceName") {
|
|
933
|
+
return "The binding was created from the host-name fallback, while this SDK reads hardware identity, so this may instead be the same machine bound while no hardware identity could be read \u2014 re-activate to find out.";
|
|
848
934
|
}
|
|
849
|
-
|
|
850
|
-
return `The binding was created by ${boundVersion}, while this SDK computes v${expectedStamp.version}, so this may instead be the same machine bound under the older algorithm \u2014 re-activate to find out, or configure a MigratingDeviceIdResolver to keep accepting the previous id.`;
|
|
935
|
+
return "The binding was created from hardware identity, while this SDK has fallen back to the host name \u2014 check why hardware identity cannot be read here rather than re-activating, which would rebind the device to the weaker id.";
|
|
851
936
|
}
|
|
852
937
|
|
|
853
938
|
// src/index.ts
|
|
@@ -895,6 +980,7 @@ var MoonbaseLicensing = class {
|
|
|
895
980
|
// Annotate the CommonJS export names for ESM import in node:
|
|
896
981
|
0 && (module.exports = {
|
|
897
982
|
ActivationMethod,
|
|
983
|
+
DEVICE_ID_SOURCE_TAGS,
|
|
898
984
|
ErrorType,
|
|
899
985
|
FINGERPRINT_PREFIX,
|
|
900
986
|
FINGERPRINT_VERSION,
|
|
@@ -916,6 +1002,7 @@ var MoonbaseLicensing = class {
|
|
|
916
1002
|
defaultDeviceIdentityReader,
|
|
917
1003
|
fingerprintDeviceId,
|
|
918
1004
|
fingerprintDigest,
|
|
1005
|
+
identitySource,
|
|
919
1006
|
parseDeviceIdStamp,
|
|
920
1007
|
parseIoregPlatformUuid,
|
|
921
1008
|
parseSmbiosParams,
|