Skip to content

Latest commit

 

History

History
655 lines (523 loc) · 35.1 KB

File metadata and controls

655 lines (523 loc) · 35.1 KB

Moonbase Device Fingerprint Specification

Version: v2 · Material prefix: moonbase:fingerprint:v2 · Device id prefix: mbd2_

The language-neutral source of truth for how a Moonbase SDK (JavaScript, C++, .NET) computes a machine device id. Any two SDKs that conform compute the same id on a given machine, so a license bound by one validates in the other. SDKs adopt the spec independently, so conformance is a property of a given SDK version, not something to assume.

On iOS and Android that guarantee is narrower, because neither exposes an identifier an unrelated app can read. Those platforms use a scoped identity, whose value is tied to the app it runs in, so two apps on one device compute different ids by design and the mbd2s_ stamp marks it. The algorithm is unchanged: two conforming SDKs embedded in the same app still compute the same id. What varies is the scope, never the implementation — an SDK that computed something different from its peers in the same app would be non-conforming, not scoped.

Implement against this document and prove it against fingerprint-vectors.json, the machine-readable conformance suite shipped alongside it. If an SDK disagrees with this spec, the SDK is the bug. If this spec disagrees with the vectors, the vectors win: they are what every SDK can actually execute.

Why it exists

A license token carries a sig claim equal to the device id. Each SDK recomputes the device id locally and compares it to sig on every offline validation. If two SDKs compute it differently on the same machine, a license activated by one will not validate in the other. This spec removes that divergence by defining a byte-exact, deterministic algorithm.

Stability contract

The algorithm answers one question: is this the same machine? Every hardware-identity parameter below must satisfy this table, and any proposed change must be argued against it. The two scoped parameters cannot satisfy it and are governed by a weaker contract instead — that gap is the whole reason they are stamped differently.

Event The device id must
Host name / computer rename not change
Locale, language or timezone change not change
IP address, DHCP lease or network change not change
BIOS / UEFI firmware update not change
Running as root/Administrator vs. unprivileged not change
RAM, GPU, disk or NIC added, removed or replaced not change
vCPU count changed on a VM not change
OS minor or major upgrade not change
App sandbox enabled/disabled; container restarted on the same host not change
OS reinstall may change on Linux (see below); must not on macOS or Windows
Motherboard replaced may change
Different physical machine must change
VM cloned to a new instance must change on macOS and Windows; cannot be guaranteed on Linux (see below)

Three consequences are deliberate:

  • Linux is tied to the OS installation, not the hardware. Every per-unit DMI field (board_serial, product_serial, product_uuid, chassis_serial) is mode 0400, root-only. An unprivileged process can read only model-level values, identical across every machine of the same model. Linux therefore uses machine-id, which is world-readable and per-installation. The cost is that a Linux OS reinstall requires re-activation.
  • Firmware versions are never identity. bios_date, bios_version and friends describe the firmware, not the machine, and change on every BIOS update.
  • A carelessly cloned Linux VM keeps its device id. machine-id(5) requires an image intended for reuse to ship with /etc/machine-id empty, so each instance generates its own on first boot. When that is done, a clone gets a new id and this spec behaves correctly. When it is not, the clone inherits a valid machine-id, every other Linux parameter is model-level, and the clone fingerprints identically to its source, so a license copied with the disk keeps validating. An SDK cannot detect this. The value that would distinguish the instances (/sys/class/dmi/id/product_uuid, reassigned by the hypervisor) is root-only, and reading it would break privilege-invariance for every user. Treat it as a known limit of unprivileged Linux fingerprinting, not as something the algorithm can close.

Device id algorithm

digest    = lowercase_hex( SHA-256( UTF-8( material ) ) )
device_id = "mbd" + version + source_tag + "_" + digest

For this version: mbd2_ plus 64 hex characters, 69 in total. It uses only RFC 3986 unreserved characters, so it never needs escaping in a URL, JSON body, file name or shell command.

source_tag records how the identity was obtained, and therefore what the id may be compared to:

Tag Form Meaning
(empty) mbd2_ Hardware identity. Comparable across every conforming SDK on that machine.
n mbd2n_ The opt-in host-name fallback. See Insufficient identity.
s mbd2s_ Scoped identity: stable for the device within one scope, and not comparable across scopes.

Those are the tags this version defines. The grammar an SDK accepts is deliberately wider:

^mbd(\d+)([a-z]*)_([0-9a-f]{64})$

A parser MUST accept a source tag it does not recognise, treating the id as opaque and comparing it literally rather than rejecting it. That is what lets a new tag be introduced without a version bump, so it must be possible to parse mbd2x_… while knowing only that x is not a tag this SDK defines. An SDK that hard-codes the three defined tags into its pattern cannot do that, and will report a perfectly valid id from a newer SDK as "not a Moonbase device id".

The tag is [a-z]*, not a single optional character, so a future two-letter tag needs no version bump either. _ terminates it, and digits cannot appear in it, so the split from version is unambiguous.

Why the id is stamped

The version once lived only inside the hashed material, which made it unrecoverable from the output. Stamping it means:

  • Supporting more than one version during a migration costs one hardware read, not one per version. Parse the stamp on sig, compute that version, done.
  • An offline validator can tell an out-of-date SDK (binding is v3, it computes v2) from a stale binding (binding is v1, it computes v2), and say something better than "wrong device".
  • The server, analytics and support can segment and reason about ids without a side channel.

The stamp does not establish machine continuity. A version difference says only which algorithm created the binding. An older-version token copied from a different computer has exactly the same version relationship as one created on this machine by an older SDK, so a validator must not report an older stamp as proof that this is the same machine. Only recomputing the historical id and finding a match establishes continuity, and when that succeeds validation passes and never reaches an error. An SDK may surface the version difference to point at the right remedy, but must phrase the remedy as conditional.

The stamp version and the material prefix version are always the same number. Any change to collection rules, ordering, canonicalization or encoding that would alter the output for an unchanged machine must bump both.

Assemble the material from the platform and an ordered list of identity parameters:

  1. Determine the platform tag (see below).

  2. Collect the ordered identity parameters for that platform, each a (name, value) pair.

  3. Canonicalize every value (see below) and drop any pair whose value is then empty, or whose name is identifying and whose value is an unprogrammed placeholder.

  4. If no pairs survive, or none of the survivors is an identifying parameter, stop. This is an error, not a device id. See Insufficient identity.

  5. If two surviving pairs share a name, stop. The grammar cannot express it, so this is a collection bug.

  6. Assemble the material as lines joined by a single LF (\n, U+000A):

    moonbase:fingerprint:v2
    platform=<platform-tag>
    <name>=<value>
    <name>=<value>
    ...
    

    The LF is a separator, not a terminator. The material does not end with a newline. Appending "\n" after each line is the single most likely way to produce an SDK that looks correct and agrees with nothing. The vectors check this explicitly.

  7. UTF-8 encode the material, SHA-256 it, lowercase-hex encode the 32-byte digest, and prefix the stamp.

Canonicalizing values

Apply these steps to every value, in this order:

  1. Normalize to Unicode NFC.
  2. Drop every character outside printable ASCII, keeping only U+0020 to U+007E.
  3. Truncate to at most 128 characters.
  4. Trim spaces from both ends.

Interior spaces are preserved. Nothing else is altered: no case folding, no reordering.

Step 2 does more work than it looks:

  • It makes the material grammar unambiguous. A value can no longer contain an LF, so it cannot forge an extra name=value line, and two different parameter sets can never assemble into the same material.
  • It makes the decoding of raw firmware strings irrelevant. SMBIOS strings are nominally ASCII, but OEMs ship Latin-1 and worse. An SDK decoding them as Latin-1, one decoding as UTF-8 and one keeping raw bytes would otherwise disagree on any non-ASCII byte. Every byte they disagree about is discarded, so they cannot.
  • It absorbs the trailing \n that sysfs reads and command output carry.

Identifying parameters

Most of what a platform collects is model-level: vendor, product, board and family names are byte-identical across every unit of a product line. A material built only from those would give every machine of that model the same device id, and each would validate the others' licenses.

Exactly these parameters count as identifying, describing the individual machine:

Parameter Platform
ioPlatformUuid macOS
machineId Linux
systemUuid Windows
baseboardSerialNumber Windows
identifierForVendor iOS (scoped)
androidId Android (scoped)
deviceName the opt-in host-name fallback only

At least one must survive canonicalization, or the result is insufficient identity. This is not a rare path. A Linux install with no machine-id, or a cloned VM whose SMBIOS carries an unset UUID and a blank baseboard serial, both land here and must be refused rather than fingerprinted as their model.

deviceName counts only because it is the sole parameter of the host-name fallback. Its weakness is signalled by the mbd2n_ stamp instead.

Unprogrammed placeholders. An identifying value that is really OEM filler is treated as absent, for the same reason an all-FF SMBIOS UUID is: it is a constant shared by the whole product line. Compared case-insensitively against the canonical value:

to be filled by o.e.m., to be filled by oem, default string, system serial number, base board serial number, chassis serial number, not specified, not applicable, not available, none, unknown, invalid, n/a, 0123456789, uninitialized, plus any value that is entirely 0s or entirely f/Fs (a blank UUID field, a zeroed machine-id).

One of those earns its place on mobile: unknown is exactly what Android's Build.SERIAL returns without a privileged permission, so an SDK that reaches for it lands on a fleet-wide constant.

Per-parameter rejections. A constant that belongs to one platform's identifier is rejected for that parameter only, never added to the list above. Widening it would change the device id of a machine that happens to report the same string as some unrelated field, and any change that alters the output for an unchanged machine requires a version bump. Currently there is one:

Parameter Also rejected Why
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.

This applies to identifying parameters only. A descriptive field reading Default string is still a fair description of the model and stays in the material. A serial number reading it is not a serial number.

Platform tags

OS family Tag
macOS mac
iOS, iPadOS, tvOS, watchOS, visionOS ios
Windows windows
Linux linux
Android android
FreeBSD / OpenBSD / NetBSD bsd
anything else unknown

Every Apple platform other than macOS maps to ios, because they all offer the same single identifier and nothing else (watchOS via WKInterfaceDevice, the rest via UIDevice). Giving them one tag is what keeps two SDKs from disagreeing: the tag is hashed into the material, so an SDK that mapped tvOS to unknown while another mapped it to ios would compute different ids on one device.

The tag follows the OS the process is running on, not the SDK it was built against. One Apple binary can run in three ways, and the obvious tests (#if targetEnvironment(macCatalyst), #if os(iOS), UIDevice.systemName) all get it wrong — a Mac Catalyst build compiles with os(iOS) true and reports systemName as iPadOS while running on macOS. Use the runtime pair:

isMacCatalystApp isiOSAppOnMac Running as Tag
false false a real iPhone / iPad ios
true false Mac Catalyst mac
true true an iOS app on Apple silicon ios

Mac Catalyst is the case that matters: it can read both identifierForVendor and IOKit IOPlatformUUID (the macOS App Sandbox does not deny IOKit property reads), so without a rule two SDKs on one Mac would disagree about which one to use. Hardware identity wins, per Scoped identity. An unmodified iOS app on Apple silicon cannot reach IOKit, so it stays on the scoped path and its id is not comparable with the Catalyst one — which the mbd2s_ stamp already says.

Identity parameters per platform

Parameters must appear in the order listed. Reads are best-effort: a missing or unreadable source yields an empty value, which step 3 then drops. A partially-available machine still hashes deterministically, and conforming SDKs agree because they apply the same collection rules.

macOS (mac)

Order Name Identifying Source
1 ioPlatformUuid ✅ IOKit IOPlatformUUID of IOPlatformExpertDevice, with all - removed and uppercased. Read via IOKit, or ioreg -rd1 -c IOPlatformExpertDevice and match "IOPlatformUUID" = "…".

macOS collects a single parameter, so a read either succeeds or yields insufficient identity.

Linux (linux)

All five sources are world-readable files, so the result does not depend on privilege, on any installed CLI, or on the locale. No subprocess is spawned.

Order Name Identifying Source
1 machineId ✅ the first of /etc/machine-id and /var/lib/dbus/machine-id holding a valid id (see below)
2 sysVendor /sys/class/dmi/id/sys_vendor
3 productName /sys/class/dmi/id/product_name
4 boardVendor /sys/class/dmi/id/board_vendor
5 boardName /sys/class/dmi/id/board_name

A source counts only if its canonical value matches ^[0-9a-f]{32}$, the format machine-id(5) defines, and is not an unprogrammed placeholder. Apply the same placeholder rule here as canonicalization does. A check that admits a value canonicalization will later discard (an all-f id passes a naive hex test) strands the remaining sources.

Validate each source before selecting it, rather than taking the first non-empty one. /etc/machine-id legitimately holds the literal marker uninitialized in an initrd or a golden image awaiting first boot, and every machine deployed from that image reads the same marker. Treating it as an id would give them all one device id, and would also stop the fall-through to a D-Bus id that may be perfectly valid.

machineId is the only per-machine value here; the DMI fields are model-level context. On a board with no DMI at all (many ARM SBCs) only machineId survives, which is correct and still unique.

Because it is the only identifying parameter, a Linux machine with no readable machine-id has no device identity and must be refused. Every remaining field is shared by every unit of the model, so fingerprinting them would let those machines validate one another's licenses. This is reachable: non-systemd installs, minimal containers, and images shipped with an empty /etc/machine-id.

Do not add board_serial, product_uuid or any other 0400 file: the id would then depend on whether the process runs as root. Do not add bios_*: those change on firmware update. Do not parse lscpu: its labels are translated, so the id would depend on LANG, and its values are model-level anyway.

Windows (windows)

Read the raw SMBIOS structure table, via GetSystemFirmwareTable('RSMB') (P/Invoke on .NET, native on C++) or WMI root\wmi → MSSmBios_RawSMBiosTables.SMBiosData.

If you read via GetSystemFirmwareTable('RSMB'), skip the leading 8-byte RawSMBIOSData header (Used20CallingMethod, 3 version bytes, DWORD Length); parsing starts at the first structure. WMI's SMBiosData already excludes that header.

Walk the structures and take the first structure of type 1 and the first of type 2. Later structures of the same type are ignored.

Type Order Name Identifying Field offset within the structure
1 System 1 systemManufacturer 0x04 (string)
2 systemProductName 0x05 (string)
3 systemUuid ✅ 0x08 (16 raw bytes)
2 Baseboard 4 baseboardManufacturer 0x04 (string)
5 baseboardProduct 0x05 (string)
6 baseboardSerialNumber ✅ 0x07 (string)

At least one of systemUuid and baseboardSerialNumber must survive, or the machine has no device identity and must be refused. Both being unusable is the common case on cloned VM images and on consumer boards: an unset (all-00/all-FF) UUID alongside a baseboard serial that is blank or an OEM filler string like To be filled by O.E.M. Without this rule every such machine would fingerprint as its model and share a binding.

Type 4 (Processor) is deliberately not collected. Its values are model-level rather than per-machine, and the number of type-4 structures tracks the CPU socket / vCPU count, so collecting them would change the device id every time a VM is resized.

SMBIOS structure walking:

  • Header: type (byte @0x00), length (byte @0x01, the size of the formatted area including the header), handle (word @0x02).
  • The string table immediately follows the formatted area: NUL-terminated strings ending in a double-NUL. A structure with no strings is just the double-NUL.
  • A string field in the formatted area holds a 1-based index into that string table. Index 0, or an index past the end, means "no string" and yields an empty value.
  • Bound every field read by the structure's own length, not by the size of the table. Older (SMBIOS 2.x) structures are shorter than the current layout, and reading past the formatted area silently picks up bytes from the string pool and resolves a garbage index.
  • systemUuid is the 16 bytes at offset 0x08 formatted as uppercase hexadecimal, no hyphens, no byte reordering: the raw bytes in order, exactly 32 hex characters. Do not apply the SMBIOS-canonical little-endian swap of the first three UUID fields. The value will therefore not match what dmidecode, wmic csproduct get uuid or Win32_ComputerSystemProduct display. That is intentional, and an SDK reading the UUID through WMI must undo the swap.
  • An all-00 or all-FF systemUuid means "not set" and is treated as absent, so fleets of VMs with unset UUIDs cannot collide.

iOS (ios) and Android (android) — scoped

Both platforms deliberately removed every device identifier that unrelated applications can read. An SDK on them MAY emit a scoped identity; it has nothing else to offer.

Platform Order Name Identifying Source
iOS 1 identifierForVendor ✅ [[UIDevice currentDevice] identifierForVendor].UUIDString, with all - removed and uppercased, matching ioPlatformUuid. On watchOS, [[WKInterfaceDevice currentDevice] identifierForVendor]
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

The Android value must come from Settings.Secure.getString. Reading the static field Settings.Secure.ANDROID_ID yields the string constant "android_id", which is the key name and is identical on every device. An SDK that hashes it gives its entire Android install base one device id, so a single activation unlocks every device. This is not hypothetical: JUCE's SystemStats::getUniqueDeviceID() reads the static field via GetStaticObjectField, never touching a ContentResolver, and still does so in 9.0.0 — so every JUCE Android app returns the same value. Its jassert that the result is non-empty never fires, because the hash of a constant is not empty. The defect is silent.

The ^[0-9a-f]{1,16}$ rule is what makes that mistake mechanically impossible rather than merely documented: "android_id" is not hex, so it never reaches the material. The bound is 1,16 and not 16 because AOSP before 8.0 generated the value with Long.toHexString, which drops leading zeros — a strict 16 would reject legitimate ids on roughly one in sixteen pre-Oreo devices.

Either value may be absent, and then resolves to insufficient identity rather than to a constant. Apple gives "after the device has been restarted but before the user has unlocked it" as an example of when identifierForVendor is nil, not an exhaustive list; on Android the value is generated lazily and getString can return null. Treat absence as normal and retry later rather than assuming a cause.

BSD (bsd), other (unknown)

No identity parameters are defined. These platforms always resolve to insufficient identity.

Scoped identity

A scoped device id is stable for a given device within one scope, and carries no meaning outside it. It exists because some platforms provide nothing better. The unscoped alternatives are gone: iOS has not exposed a hardware serial since iOS 7, Android Build.SERIAL returns unknown without a privileged permission from Android 10, IMEI requires READ_PRIVILEGED_PHONE_STATE, and MAC addresses are randomised.

What the scope actually is

"One publisher" is a useful shorthand and a poor rule, because neither platform scopes by publisher. Be precise, because the difference is observable:

Platform Scope key
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.
Android, API 26+ The app signing key, per OS user, per device.
Android, before API 26 The device and OS user only. Every app on the device reads the same value.

So com.example.editor and com.example.player share an iOS scope, while the same publisher's two Android apps signed with different keys do not share an Android one on API 26 or later. An SDK must never assume that "same publisher" means "same scope".

Per-signing-key scoping arrived in Android 8.0. Older devices are still in scope for this spec — the ^[0-9a-f]{1,16}$ rule below deliberately accepts the shorter ids they generate — and on them ANDROID_ID is a single per-device value that every installed app can read. That makes the scope wider than the table's first Android row, never narrower, so treating those ids as scoped is conservative rather than unsound: the rules below forbid correlating them, which is still correct when they happen to be correlatable. It does mean two unrelated apps on one pre-Oreo device compute the same scoped id, so a server must not infer distinct devices from distinct ids, nor one device from one id.

What scoped identity guarantees

Scoped ids are stamped mbd2s_ so the limitation travels with the value. The rules that follow are what the stamp promises:

  • Two scoped ids from different scopes are not comparable at all. Equal values do not imply the same device, and different values do not imply different devices. A validator, a server and an analytics pipeline must all refuse to correlate them.
  • A scoped id and an unscoped one are likewise never comparable, so a machine that could produce both must not be given a scoped id. See the Mac Catalyst rule under Platform tags.
  • Everything else is unchanged: same canonicalization, same material grammar, same digest.

Within one scope the stability contract holds for every hardware event in it — renames, network changes, OS upgrades. But scoped values also move for reasons no hardware identifier does, and this table, not that one, is what mbd2s_ promises:

Event The device id may
App reinstalled, iOS, at least one other app from the vendor still installed not change
App reinstalled, Android, same signing key not change
Every app from that vendor deleted, then one reinstalled (iOS) change
Installed by Xcode or ad-hoc distribution rather than the App Store (iOS) change
App signing key rotated between uninstall and reinstall (Android, API 26+) change
Device factory reset change
A different OS user on the same device (Android) change
App transferred to another App Store team (iOS) change

Every "change" row costs the user a re-activation. That is the price of the platform, not a defect to be engineered around — the only way to avoid it is an identifier neither platform offers.

Scoped identity is a floor, not a preference. An SDK MUST use hardware identity where the platform provides it, and MAY use a scoped identity only where it does not.

Insufficient identity

An SDK must raise an error when no parameter survives canonicalization, or when none of the survivors is an identifying parameter. It must not hash the platform line alone, not hash a model-only parameter set, and not silently substitute the host name.

Each of those is well-defined but catastrophic in the same way: it hands a whole class of machines (every machine on a platform, or every unit of a model) the same device id, and a license bound to that id then validates on all of them. Substituting the host name is nearly as bad, being user-renameable, duplicated across imaged fleets, and regenerated on every container start.

An SDK may offer an explicit, opt-in host-name fallback for platforms with no defined parameters. The material is then the single parameter deviceName=<host name>, and the id must be stamped mbd2n_ so the weaker binding is visible to the server and to support. If the host name is empty too, that is still insufficient identity.

The host-name fallback MUST NOT be offered on ios or android. On those platforms the host name is not weak identity, it is not identity at all: since iOS 17 gethostname() and utsname.nodename return the literal localhost on every device, and since iOS 16 UIDevice.name returns the model name — "iPhone" — regardless of which SDK the app was built against. The entitlement that restores the user-assigned name is granted only to apps that do not use it for fingerprinting, so it is closed to licensing by policy as well as by API.

An iOS SDK that fell through to this fallback when identifierForVendor was momentarily absent would hand its entire install base one device id, and a single activation would unlock every device — exactly the catastrophe this section exists to prevent, reached by following the section above it. On those platforms the ladder is scoped identity, then insufficient identity, and nothing else. Absence is transient: raise the error and retry later.

Device name

A human-readable label sent alongside the device id at activation. It is not part of the material (except in the opt-in fallback above), so it can change freely without invalidating a license.

Platform Source
macOS host name, with a trailing .local removed (case-insensitive)
iOS UIDevice.name (the model name on iOS 16+), or the empty string
Android Settings.Global.DEVICE_NAME, falling back to Build.MODEL, or the empty string
other host name

On iOS and Android this label is close to worthless for telling two devices apart — it is the model name on most modern devices. That is tolerable because it is only a label: it never enters the material on those platforms, since the host-name fallback is forbidden there. An empty value is fine; the server treats the label as decoration, not identity.

Worked examples

Reproduced by fingerprint-vectors.json, which contains these and many more. Materials are shown with literal newlines and, to repeat, no trailing newline.

macOS:

moonbase:fingerprint:v2
platform=mac
ioPlatformUuid=0123456789ABCDEF0123456789ABCDEF

→ mbd2_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32

Linux:

moonbase:fingerprint:v2
platform=linux
machineId=b08dfa6083e7567a1921a715000001fb
sysVendor=LENOVO
productName=20HRCTO1WW
boardVendor=LENOVO
boardName=20HRCTO1WW

→ mbd2_ba16d78604f90c6c8b00dc1065a70c866884fadfa818ed2db83b2bfe0dc94933

Windows:

moonbase:fingerprint:v2
platform=windows
systemManufacturer=ACME
systemProductName=Server 9000
systemUuid=0123456789ABCDEF0123456789ABCDEF
baseboardManufacturer=ACME
baseboardProduct=MB-1
baseboardSerialNumber=BSN-42

→ mbd2_fadd75457e44f669e9865caff122b4706a4501089ac9e73b8735139bf57676ad

iOS — scoped, note the s:

moonbase:fingerprint:v2
platform=ios
identifierForVendor=0123456789ABCDEF0123456789ABCDEF

→ mbd2s_298ced47f8d983939db1d5fce6d4b4f2f8766aa19e3e17536fcd1604a81febf1

Android — also scoped:

moonbase:fingerprint:v2
platform=android
androidId=a1b2c3d4e5f60718

→ mbd2s_ca988ecf5c529964bfaa80734da3dbe070dd41881aa43f8c472f3a5d512b4eff

Opt-in host-name fallback (note the n):

moonbase:fingerprint:v2
platform=unknown
deviceName=PC-1

→ mbd2n_493978eb157552e60a13694bd6861b2a82d0dba2746431a5f7921951ed460045

Conformance checklist

Run fingerprint-vectors.json in your SDK's test suite. It covers every item in the first list; that list is what to look at when a vector fails. The second list is about behaviour around the id rather than its computation, so no vector can settle it — review it by hand.

Covered by the vectors:

  • Material prefix is exactly moonbase:fingerprint:v2.
  • Lines are joined with a single LF, and the material has no trailing newline.
  • Values are canonicalized NFC → printable-ASCII-only → capped at 128 → space-trimmed, in that order; empty values are dropped.
  • Identifying parameters holding an unprogrammed placeholder are dropped; descriptive ones are not.
  • An empty surviving parameter set raises an error rather than producing a digest.
  • A surviving set with no identifying parameter raises an error rather than fingerprinting the model.
  • Duplicate parameter names raise an error.
  • Per-platform params are collected with the exact names and order above.
  • Linux spawns no subprocess and reads no root-only file, and validates each machine-id source against ^[0-9a-f]{32}$ and the placeholder rule before selecting it.
  • Windows takes only the first type-1 and first type-2 structure, ignores type 4, and bounds every field read by the structure length.
  • systemUuid is uppercase hex, no hyphens, no byte swap; all-00/all-FF is absent.
  • androidId comes from Settings.Secure.getString and matches ^[0-9a-f]{1,16}$; the literal "android_id" never reaches the material.
  • Digest is SHA-256 over UTF-8 material, output as 64 lowercase hex characters.
  • The emitted device id is stamped mbd2_ (mbd2n_ for the opt-in fallback, mbd2s_ for a scoped identity).
  • A source tag the SDK does not define still parses, so the id can be compared literally rather than rejected as "not a Moonbase id".

Review by hand:

  • The platform tag follows the OS the process runs on. A Mac Catalyst build uses hardware identity, not the scoped path.
  • The host-name fallback is not offered on ios or android.
  • A scoped id is never compared against one from another scope — in the SDK, on the server, and in analytics. Note that the last two live outside this repository, so the vectors could not check them even in principle.
  • A version or source-tag difference is surfaced without claiming the license came from this machine.

Versioning

The material prefix and the device id stamp both carry the version, and they always match. Any change to collection rules, ordering, canonicalization or encoding that would alter output for an unchanged machine must bump both (to moonbase:fingerprint:v3 and mbd3_).

Because the version is recoverable from the id, an SDK can validate against several versions during a migration while emitting only one. Parse the stamp on the sig claim and compute that version. If the SDK no longer supports it, say the license needs re-activating rather than reporting the machine as wrong.