diff --git a/.github/scripts/check-fingerprint-parity.mjs b/.github/scripts/check-fingerprint-parity.mjs new file mode 100644 index 0000000..06a0922 --- /dev/null +++ b/.github/scripts/check-fingerprint-parity.mjs @@ -0,0 +1,133 @@ +// Assert that this SDK and @moonbase.sh/licensing compute the same device id on +// the machine running them both. +// +// The conformance vectors in tests/vectors/fingerprint-vectors.json prove the +// algorithm agrees given identical inputs. They cannot prove the *readers* agree, +// because those are the part that touches real hardware: which SMBIOS structure +// the firmware hands back, whether IOKit and ioreg see the same UUID, whether +// /etc/machine-id or the D-Bus copy wins. A whole platform's reader can be +// broken while every vector passes. This closes that gap on real hardware. +// +// Usage: node check-fingerprint-parity.mjs + +import { appendFileSync, readFileSync } from 'node:fs' + +const nativePath = process.argv[2] +if (!nativePath) { + console.error('usage: check-fingerprint-parity.mjs ') + process.exit(2) +} + +const native = JSON.parse(readFileSync(nativePath, 'utf8')) + +// The reference SDK is installed next to this script (npm install --prefix +// .github/scripts), because a bare ESM import resolves from the importing file's +// directory rather than the working directory. Installing it into the repo root +// instead would put a runtime dependency in the package.json that +// semantic-release runs `npm ci` against. +const { MoonbaseDeviceIdResolver } = await import('@moonbase.sh/licensing') +const { version: packageVersion } = JSON.parse( + readFileSync(new URL('node_modules/@moonbase.sh/licensing/package.json', import.meta.url), 'utf8'), +) + +let reference +try { + const resolver = new MoonbaseDeviceIdResolver() + reference = { + ...(await resolver.describeDevice()), + deviceName: await resolver.resolveDeviceName(), + } +} catch (err) { + reference = { error: err?.type ?? err?.name ?? 'Error', message: err?.message } +} + +const problems = [] +const notes = [] + +// The only error both sides are allowed to agree on. Anything else means an +// implementation is broken, not that the machine lacks identity. +const IDENTITY_UNAVAILABLE = 'DeviceIdentityUnavailable' + +if (native.error || reference.error) { + // A runner with no hardware identity is a legitimate outcome, and the two SDKs + // agreeing that there is nothing to hash is exactly the property under test. + // Refusing to fingerprint such a machine is the spec's whole point, so that + // passes, but it is surfaced because it means the ids were never compared. + // + // Both erroring is only agreement when both errored *for that reason*. Treating + // any pair of errors as agreement would let a reference SDK throwing a TypeError + // cancel out a native SDK that genuinely cannot read the machine, and the run + // would go green with one side broken. + if (native.error === IDENTITY_UNAVAILABLE && reference.error === IDENTITY_UNAVAILABLE) { + notes.push( + 'Both SDKs report no usable device identity, which is agreement, but no device id ' + + 'was compared on this runner.', + ) + } else if (native.error && reference.error) { + problems.push( + `Both SDKs failed, but not both with ${IDENTITY_UNAVAILABLE}: ` + + `this SDK ${native.error} (${native.message}), reference ${reference.error} (${reference.message}). ` + + 'At least one implementation is broken rather than reporting an unidentifiable machine.', + ) + } else if (native.error) { + problems.push( + `This SDK found no device identity (${native.error}: ${native.message}) ` + + `while the reference computed ${reference.deviceId}.`, + ) + } else { + problems.push( + `The reference found no device identity (${reference.error}: ${reference.message}) ` + + `while this SDK computed ${native.deviceId}.`, + ) + } +} else { + // The device id is the contract. Everything else is here to explain a + // mismatch: differing paramNames localise it to a reader, while identical + // paramNames and a differing id point at the material or the hash. + const compare = (field, a, b) => { + if (JSON.stringify(a) !== JSON.stringify(b)) { + problems.push(`${field} differs: this SDK ${JSON.stringify(a)}, reference ${JSON.stringify(b)}`) + } + } + + compare('deviceId', native.deviceId, reference.deviceId) + compare('version', native.version, reference.version) + compare('platform', native.platform, reference.platform) + compare('source', native.source, reference.source) + compare('paramNames', native.paramNames, reference.paramNames) + + if (native.deviceName !== reference.deviceName) { + // Not part of the hashed material, so it cannot invalidate a license. Worth + // saying out loud anyway, since it is what a customer sees in their account. + notes.push( + `Device name differs: this SDK ${JSON.stringify(native.deviceName)}, ` + + `reference ${JSON.stringify(reference.deviceName)}. ` + + 'Not part of the fingerprint material, so licenses are unaffected.', + ) + } +} + +const lines = [ + `### Device fingerprint parity on \`${process.platform}\``, + '', + `Reference: \`@moonbase.sh/licensing@${packageVersion}\``, + '', + '| | this SDK | @moonbase.sh/licensing |', + '| --- | --- | --- |', + ...['deviceId', 'version', 'platform', 'source', 'paramNames', 'error'].flatMap((field) => { + if (native[field] === undefined && reference[field] === undefined) return [] + const cell = (value) => (value === undefined ? '_(absent)_' : `\`${JSON.stringify(value)}\``) + return [`| \`${field}\` | ${cell(native[field])} | ${cell(reference[field])} |`] + }), + '', + problems.length ? `**MISMATCH**\n\n${problems.map((p) => `- ${p}`).join('\n')}` : '**Device ids agree.**', + ...notes.map((note) => `\n> ${note}`), +] + +const report = lines.join('\n') +console.log(report) +if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${report}\n`) +} + +process.exit(problems.length ? 1 : 0) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ac05f7..42af11a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,36 @@ concurrency: cancel-in-progress: true jobs: + # include/moonbase is the single source of truth; the JUCE module carries an + # rsync mirror of it so it stays self-contained. rsync --delete means a + # forgotten sync leaves the module compiling against stale headers, which no + # other job would catch. + consistency: + name: consistency + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: JUCE module headers are in sync + run: scripts/sync-juce-module.sh --check + + - name: Conformance vectors are in sync + run: scripts/sync-fingerprint-vectors.sh --check + + - name: JUCE module version matches the project version + run: | + project="$(sed -nE 's/^[[:space:]]*VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt | head -1)" + module_header=modules/moonbase_licensing/moonbase_licensing.h + declared="$(sed -nE 's/^[[:space:]]*version:[[:space:]]+([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' "$module_header")" + defined="$(sed -nE 's/.*#define MOONBASE_LICENSING_VERSION "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$module_header")" + echo "project=$project module declaration=$declared define=$defined" + if [[ "$declared" != "$project" || "$defined" != "$project" ]]; then + echo "::error::$module_header is out of sync with CMakeLists.txt VERSION ($project)." + echo "MOONBASE_LICENSING_VERSION also feeds the client User-Agent, so drift misreports SDK traffic." + exit 1 + fi + build: name: ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/.github/workflows/fingerprint-parity.yml b/.github/workflows/fingerprint-parity.yml new file mode 100644 index 0000000..2f32d39 --- /dev/null +++ b/.github/workflows/fingerprint-parity.yml @@ -0,0 +1,160 @@ +name: Fingerprint parity + +# Prove on real hardware, on every OS, that this SDK computes the same device id +# as @moonbase.sh/licensing. +# +# The conformance vectors already prove the algorithm agrees given identical +# inputs. They cannot prove the platform *readers* agree, because those are the +# half that touches the machine: which SMBIOS structure the firmware returns, +# whether IOKit sees what ioreg sees, which machine-id source wins. An entire +# platform's reader can be wrong while every vector passes, and the symptom would +# be a customer whose license works in a web app and not in a plugin. +# +# Path-filtered, because it only has something to say when the fingerprint moves, +# and it installs a toolchain plus an npm package on three runners to say it. +# Trigger it by hand from the Actions tab after a @moonbase.sh/licensing release, +# which can break parity without anything here changing. + +on: + pull_request: + paths: + - 'include/moonbase/fingerprint_spec.hpp' + - 'include/moonbase/moonbase_device_id_resolver.hpp' + - 'include/moonbase/device_id_resolver.hpp' + - 'include/moonbase/detail/unicode/**' + - 'tests/vectors/fingerprint-vectors.json' + - 'examples/device_id.cpp' + - '.github/workflows/fingerprint-parity.yml' + - '.github/scripts/check-fingerprint-parity.mjs' + push: + branches: [main] + paths: + - 'include/moonbase/fingerprint_spec.hpp' + - 'include/moonbase/moonbase_device_id_resolver.hpp' + - 'include/moonbase/device_id_resolver.hpp' + - 'include/moonbase/detail/unicode/**' + - 'tests/vectors/fingerprint-vectors.json' + - 'examples/device_id.cpp' + - '.github/workflows/fingerprint-parity.yml' + - '.github/scripts/check-fingerprint-parity.mjs' + workflow_dispatch: + +concurrency: + group: fingerprint-parity-${{ github.ref }} + cancel-in-progress: true + +jobs: + parity: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + # Only OpenSSL is needed: the probe links no HTTP transport, and + # nlohmann/json is fetched by CMake when the system copy is absent. + - name: Install OpenSSL (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libssl-dev + + - name: Install OpenSSL (macOS) + if: runner.os == 'macOS' + run: brew install openssl@3 + + - name: Install OpenSSL (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: choco install openssl --no-progress -y + + - name: Configure (Ubuntu) + if: runner.os == 'Linux' + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DMOONBASE_USE_CURL=OFF -DMOONBASE_BUILD_TESTS=OFF -DMOONBASE_BUILD_EXAMPLES=OFF \ + -DMOONBASE_BUILD_DEVICE_ID_TOOL=ON + + - name: Configure (macOS) + if: runner.os == 'macOS' + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DMOONBASE_USE_CURL=OFF -DMOONBASE_BUILD_TESTS=OFF -DMOONBASE_BUILD_EXAMPLES=OFF \ + -DMOONBASE_BUILD_DEVICE_ID_TOOL=ON \ + -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" + + - name: Configure (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DMOONBASE_USE_CURL=OFF -DMOONBASE_BUILD_TESTS=OFF -DMOONBASE_BUILD_EXAMPLES=OFF \ + -DMOONBASE_BUILD_DEVICE_ID_TOOL=ON \ + -DOPENSSL_ROOT_DIR="C:/Program Files/OpenSSL" + + - name: Build the device id probe + run: cmake --build build --config Release --target moonbase_device_id + + # Installed beside the checker rather than at the repo root: a bare ESM + # import resolves from the importing file's directory, and the root + # package.json is what semantic-release runs `npm ci` against. + # + # Pinned to the major implementing fingerprint spec v2. A new major there + # means a new spec version, which this SDK should adopt deliberately rather + # than discover as a red parity run. + - name: Install the reference SDK + shell: bash + run: | + npm install --no-audit --no-fund --prefix .github/scripts '@moonbase.sh/licensing@^3' + node -p "'reference: @moonbase.sh/licensing@' + require('./.github/scripts/node_modules/@moonbase.sh/licensing/package.json').version" + + # The package ships the conformance suite, so the published copy is worth + # comparing against. Advisory, not fatal: this SDK may legitimately implement + # a spec revision that has not been published yet, and did so for the + # scoped-identity extension, where the vectors went from 51 to 64 cases. + # + # A difference here is only a problem if the *device ids* also disagree, and + # the next step is what decides that. Once the reference package ships the + # newer vectors, re-run scripts/sync-fingerprint-vectors.sh and this goes + # quiet again. + - name: Compare vendored vectors with the published ones + shell: bash + run: | + if ! scripts/sync-fingerprint-vectors.sh --check --vectors-only \ + --from .github/scripts/node_modules/@moonbase.sh/licensing/fingerprint-vectors.json + then + echo "::warning::Vendored vectors differ from the published package. Expected while this SDK leads the spec; the device id comparison below is the real check." + fi + + - name: Capture this SDK's device id + shell: bash + run: | + if [ -x build/Release/moonbase_device_id.exe ]; then + build/Release/moonbase_device_id.exe > native-device-id.json + elif [ -x build/Release/moonbase_device_id ]; then + build/Release/moonbase_device_id > native-device-id.json + else + build/moonbase_device_id > native-device-id.json + fi + cat native-device-id.json + + - name: Compare against the reference SDK + shell: bash + run: node .github/scripts/check-fingerprint-parity.mjs native-device-id.json + + - name: Upload the comparison inputs + if: always() + uses: actions/upload-artifact@v4 + with: + name: device-id-${{ matrix.os }} + path: native-device-id.json + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 55e6ab8..7444e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ CMakeLists.txt.bak ui-snapshots/ node_modules .argos +__pycache__/ + +# The fingerprint parity workflow installs @moonbase.sh/licensing here at run +# time (npm install --prefix .github/scripts). Committing that manifest would +# pin the reference SDK, defeating the point of comparing against what is +# currently published. +.github/scripts/package.json +.github/scripts/package-lock.json diff --git a/.releaserc.json b/.releaserc.json index f59fb1f..3f47be0 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -18,7 +18,12 @@ [ "@semantic-release/git", { - "assets": ["CHANGELOG.md", "CMakeLists.txt", "README.md"], + "assets": [ + "CHANGELOG.md", + "CMakeLists.txt", + "README.md", + "modules/moonbase_licensing/moonbase_licensing.h" + ], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ], diff --git a/CMakeLists.txt b/CMakeLists.txt index f2baceb..99a6c62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,6 +83,20 @@ if(MOONBASE_USE_CURL) else() target_compile_definitions(moonbase_cpp INTERFACE MOONBASE_DISABLE_CURL_TRANSPORT=1) endif() + +# The device fingerprint reads IOPlatformUUID from the IO registry. IOKit works +# inside the App Sandbox and under a hardened runtime, where spawning `ioreg` +# does not, which is why plugins can use the default resolver at all. INTERFACE, +# so it flows through install(EXPORT) to consumers doing find_package(moonbase_cpp); +# tests/consumer_smoke is what proves that export is right. +# +# macOS only, mirroring the TARGET_OS_OSX gate in moonbase_device_id_resolver.hpp. +# APPLE alone is also true for iOS, tvOS and watchOS, which never compile the IOKit +# path but would still inherit the framework through the exported interface and +# could fail to link. CMAKE_SYSTEM_NAME is "Darwin" only for macOS. +if(APPLE AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") + target_link_libraries(moonbase_cpp INTERFACE "-framework IOKit" "-framework CoreFoundation") +endif() if(MOONBASE_NLOHMANN_FETCHED) target_include_directories(moonbase_cpp INTERFACE @@ -101,6 +115,23 @@ if(MOONBASE_BUILD_EXAMPLES) target_link_libraries(moonbase_activation_example PRIVATE moonbase::licensing) endif() +# Device id diagnostic: prints this machine's device id and how it was derived. +# Support asks customers to run it, and the cross-SDK parity workflow builds it to +# compare against @moonbase.sh/licensing. +# +# Kept separate from MOONBASE_BUILD_EXAMPLES so the parity workflow can build it +# without the rest, but still defaulted off for subprojects: a consumer using +# FetchContent or add_subdirectory should not silently acquire an application +# target, which is the whole point of the MOONBASE_BUILD_* opt-outs. +option(MOONBASE_BUILD_DEVICE_ID_TOOL + "Build the moonbase_device_id diagnostic" ${MOONBASE_IS_TOP_LEVEL}) + +if(MOONBASE_BUILD_DEVICE_ID_TOOL) + # Needs no HTTP transport, so it still links with MOONBASE_USE_CURL=OFF. + add_executable(moonbase_device_id examples/device_id.cpp) + target_link_libraries(moonbase_device_id PRIVATE moonbase::licensing) +endif() + if(MOONBASE_BUILD_JUCE_EXAMPLE) enable_language(C) if(APPLE) @@ -158,6 +189,9 @@ if(MOONBASE_BUILD_TESTS) add_executable(moonbase_tests tests/main.cpp tests/client_tests.cpp + tests/device_id_resolver_tests.cpp + tests/fingerprint_reader_tests.cpp + tests/fingerprint_spec_tests.cpp tests/fingerprint_tests.cpp tests/header_smoke_tests.cpp tests/inventory_tests.cpp @@ -168,6 +202,13 @@ if(MOONBASE_BUILD_TESTS) tests/version_tests.cpp tests/live_tests.cpp) target_link_libraries(moonbase_tests PRIVATE moonbase::licensing doctest::doctest) + + # An absolute path baked in at configure time, so the conformance suite is + # found regardless of ctest's working directory or where a multi-config + # generator puts the binary. CMAKE_CURRENT_SOURCE_DIR is forward-slashed on + # every platform, so this needs no escaping. + target_compile_definitions(moonbase_tests PRIVATE + MOONBASE_FINGERPRINT_VECTORS_PATH="${CMAKE_CURRENT_SOURCE_DIR}/tests/vectors/fingerprint-vectors.json") moonbase_apply_sanitizer(moonbase_tests) include(doctest) doctest_discover_tests(moonbase_tests) diff --git a/FINGERPRINT_SPEC.md b/FINGERPRINT_SPEC.md new file mode 100644 index 0000000..595d2e6 --- /dev/null +++ b/FINGERPRINT_SPEC.md @@ -0,0 +1,655 @@ + + +# 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](#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`](tests/vectors/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](#scoped-identity) parameters cannot satisfy it and are governed by a +[weaker contract](#what-scoped-identity-guarantees) 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](#insufficient-identity). | +| `s` | `mbd2s_` | [Scoped identity](#scoped-identity): stable for the device within one [scope](#what-the-scope-actually-is), 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](#identifying-parameters). +4. If **no** pairs survive, or none of the survivors is an + [identifying parameter](#identifying-parameters), stop. This is an error, not a device id. See + [Insufficient identity](#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= + = + = + ... + ``` + + > 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](#scoped-identity)) | +| `androidId` | Android ([scoped](#scoped-identity)) | +| `deviceName` | the opt-in host-name fallback only | + +At least one must survive canonicalization, or the result is +[insufficient identity](#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 `0`s or entirely `f`/`F`s (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](#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](#identifying-parameters). 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](#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](#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](#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](#platform-tags). +- Everything else is unchanged: same canonicalization, same material grammar, same digest. + +Within one scope the [stability contract](#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](#identifying-parameters). 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=`, 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](#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`](tests/vectors/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`](tests/vectors/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](#what-the-scope-actually-is) — + 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. diff --git a/README.md b/README.md index 8b947dd..35c6e6a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Moonbase C++ Activation SDK -Header-only C++17 SDK for Moonbase license activation. It supports activation requests, polling for fulfilled activations, local RS256 JWT validation, overridable device fingerprinting, and overridable license storage. +Header-only C++17 SDK for Moonbase license activation. It supports activation requests, polling for fulfilled activations, local RS256 JWT validation, cross-SDK device fingerprinting (spec v2), and overridable license storage. ## Requirements - CMake 3.20 or newer - A C++17 compiler -- Windows, macOS, or Linux (the default fingerprint provider has native implementations for each) +- Windows, macOS, or Linux (the default device id resolver has native implementations for each) - `CURL::libcurl` and OpenSSL (`OpenSSL::SSL`, `OpenSSL::Crypto`) — must be findable on the system (e.g. via your distro, Homebrew, or vcpkg) - `nlohmann_json` 3.11+ — used if `find_package(nlohmann_json)` succeeds; otherwise it is fetched automatically at build time from the upstream release tarball @@ -60,6 +60,7 @@ The build provides three options, all useful when consuming the SDK as a subproj | `MOONBASE_BUILD_EXAMPLES` | `ON` for the top-level project, `OFF` as a subproject | Build the standalone activation example under `examples/`. | | `MOONBASE_BUILD_JUCE_EXAMPLE` | `OFF` | Fetch JUCE and build the JUCE `OnlineUnlockStatus` bridge example (see below). | | `MOONBASE_BUILD_JUCE_NATIVE_EXAMPLE` | `OFF` | Fetch JUCE and build the `moonbase_licensing` native module example (see below). | +| `MOONBASE_BUILD_DEVICE_ID_TOOL` | `ON` for the top-level project, `OFF` as a subproject | Build the `moonbase_device_id` diagnostic, which prints this machine's device id and how it was derived. | Override `MOONBASE_BUILD_TESTS` and `MOONBASE_BUILD_EXAMPLES` explicitly when you want a subproject integration to build SDK artifacts too. @@ -187,29 +188,209 @@ requires the token to have been issued via offline activation, throwing and [cannot be revoked](#revoking-an-activation); they stay valid until the machine's device fingerprint changes. -## Custom Fingerprinting and Storage +## Device fingerprint + +Every license is bound to the machine via a device id, stored in the token's `sig` +claim and re-checked on every local validation. The default +`moonbase_device_id_resolver` computes it from the cross-SDK +**[device fingerprint spec](FINGERPRINT_SPEC.md)** (`moonbase:fingerprint:v2`): a +SHA-256 of stable native hardware identifiers, stamped with the spec version. + +``` +mbd2_9f3c… // 'mbd' + version + '_' + 64 lowercase hex characters +``` + +Sources are SMBIOS on Windows, `IOPlatformUUID` via IOKit on macOS, and +`machine-id` plus world-readable DMI on Linux. No subprocess is spawned and no +root-only file is read, so the id is the same elevated or not, and the resolver +works inside an App Sandbox and a plugin host. + +The algorithm is language-neutral by design: any Moonbase SDK that implements the +spec and passes the shipped +[`fingerprint-vectors.json`](tests/vectors/fingerprint-vectors.json) computes the +same id on a given machine, so a license activated by one validates in the others. +Adoption is per-SDK: **this SDK conforms from 4.0.0; `@moonbase.sh/licensing` +conforms from 3.0.0.** Check the version of whichever SDK you are pairing with +before relying on it. + +The id survives a rename, a locale change, a firmware update, a vCPU resize, and +running with or without elevated privileges. The spec's **stability contract** is +the definitive list. Read it before shipping, along with the two Linux exceptions, +which exist because every per-unit hardware serial is root-only there and the id is +therefore tied to the OS installation rather than the hardware: + +- A Linux **OS reinstall** requires re-activation. +- A Linux **VM cloned without clearing `/etc/machine-id`** keeps its device id, so + a license copied with the disk keeps validating. `machine-id(5)` requires + reusable images to ship that file empty; when they do, clones behave correctly. + The SDK cannot detect a badly-prepared image, because the value that would + distinguish the instances is root-only. + +Because the version is part of the id, a mismatch is diagnosable. `validate_token` +throws `license_device_mismatch_error` either way, and the message says which case +you are in: + +```cpp +try { + licensing.validator().validate_token(token); +} catch (const moonbase::moonbase_error& ex) { + if (ex.type() == moonbase::error_type::license_device_mismatch) + std::cerr << ex.what(); // 'not for this device', plus any version difference +} +``` + +### When there is no hardware identity + +The resolver throws `insufficient_device_identity_error` rather than falling back +to something weak, in two cases: + +- **Nothing readable.** A locked-down process, or a platform with no defined + parameters (Android, BSD). +- **Only model-level values readable.** Vendor, product and board names are + byte-identical across every unit of a product line, so fingerprinting them would + let those machines validate one another's licenses. In practice: a Linux install + with no `machine-id`, or a machine whose SMBIOS carries an unset UUID *and* a + blank or filler baseboard serial, the usual shape of a cloned VM image. + +Opt in explicitly if a weaker id beats none. Those ids are stamped `mbd2n_` so the +server can tell them apart: + +```cpp +moonbase::moonbase_device_id_resolver_options resolver_options; +resolver_options.fallback = moonbase::device_id_fallback::device_name; +auto resolver = std::make_shared(resolver_options); +``` + +### Diagnostics and parity checks + +`describe_device()` returns the id, spec version, platform tag and the *names* of +the parameters that contributed. It is safe to log or attach to a support ticket, +and returns a fresh copy each call so editing it cannot disturb the binding. + +```cpp +if (const auto described = licensing.describe_device()) + std::cout << described->device_id << " (" << described->platform << ")\n"; +``` + +Parameter values are never exposed there, and neither are per-parameter hashes. +They are hardware serial numbers, and an unsalted per-value digest is no safer to +publish than the value, since low-entropy values such as host names or sequential +serials fall to a dictionary. Which parameters contributed is the useful +diagnostic; what they read is not. + +The device id itself is a one-way hash of all of them together, so it discloses no +individual serial. It is, however, **derived identically for every +Moonbase-powered product**. The material contains no product- or account-specific +input, so the same machine yields the same device id everywhere, and merchants +receive that string through the integration API and webhooks. Treat it as a stable +cross-vendor machine identifier. That is more than `machine-id(5)` intends, which +asks that the Linux machine id only leave the host through an +*application-specific keyed* derivation. If that matters for your deployment, +supply a custom `device_id_resolver` that mixes in a key of your own. + +The lower-level `build_fingerprint_material`, `fingerprint_digest`, +`fingerprint_device_id` and `parse_device_id_stamp` helpers are exported from +`` so you can verify cross-SDK parity against the +vector file. `examples/device_id.cpp` builds as the `moonbase_device_id` target and +prints all of the above as JSON, which is what +[the parity workflow](.github/workflows/fingerprint-parity.yml) compares against +`@moonbase.sh/licensing` on every OS. + +## Migrating from 3.x + +Device ids computed by 3.x do not follow the spec, so **by default every device +must re-activate once** after you upgrade. That is not free: a new device id +consumes a fresh activation seat (the old one is not reclaimed) and resets any +device-scoped trial. On a license with few seats, a fleet-wide upgrade can exhaust +them immediately. + +Three options, in increasing order of effort: + +**1. Let devices re-activate (default).** Simplest, and the id is correct from then +on. Catch `error_type::license_device_mismatch` and call `request_activation()`. +Best when seats are generous or the install base is small. + +**2. Accept the old id while binding the new one (recommended for existing +fleets).** `migrating_device_id_resolver` keeps recognising ids this device was +previously bound to, without ever issuing one: ```cpp -class my_fingerprint final : public moonbase::fingerprint_provider { +auto resolver = std::make_shared( + std::make_shared(), // always what a new activation binds + std::make_shared()); // additionally accepted at validation + +moonbase::licensing licensing(options, store, resolver); +``` + +Existing licenses keep validating untouched, while anything newly activated binds +the current fingerprint. The fleet migrates as devices naturally re-activate, with +no flag day and no seat churn. The legacy id is computed lazily, only when the fast +comparison fails, and then memoized, so apps on the happy path pay nothing. Drop +the wrapper in a later release to finish the migration. + +**Which legacy resolver to name depends on which integration path you shipped**, +and this is the one thing to get right: + +| You shipped | Historical resolver | +| --- | --- | +| The core SDK's default | `moonbase::legacy_cpp_device_id_resolver` (``) | +| The `moonbase_licensing` JUCE module | `moonbase::juce_integration::legacy_juce_device_id_resolver` | +| The `OnlineUnlockStatus` bridge | `MoonbaseJuceDeviceIdResolver` from your copy of `MoonbaseJuceBridge.h` | +| More than one, or you are not sure | Pass all of them | + +iOS and Android need migrating too. Neither has an identifier that unrelated apps +can read, so the JUCE module emits a [scoped](FINGERPRINT_SPEC.md#scoped-identity) +id there, stamped `mbd2s_` and derived from `identifierForVendor` or `ANDROID_ID`: +stable for the device within the platform's own scope, and deliberately never +correlated across scopes. That is still a different value from the raw id bound +before 4.0.0, so name `legacy_juce_device_id_resolver` as a historical resolver on +mobile as well. + +The wrapper takes any number of historical resolvers, and the only cost of an extra +one is a single lazy hardware read on the mismatch path, so "pass both if unsure" +is the safe advice. Note that the JUCE resolver derives its id from +`juce::SystemStats::getUniqueDeviceID()`, which is not a published stable format, +so it only vouches for a binding if your plugin still ships the JUCE version that +created it. + +**3. Stay on the old id.** Pin `legacy_cpp_device_id_resolver` as the current +resolver. Nothing changes, but you keep the old algorithm's defects (on Linux the +id depended on whether the process ran elevated; on Windows the SMBIOS read never +succeeded, so the id silently degraded to a hash of the computer name and renaming +a PC invalidated its license) and you get no cross-SDK compatibility. Use this only +as a short-term hold. + +> Options 1 and 2 both recompute every accepted id from the machine's own hardware +> on each call. Nothing about a device binding is ever read from disk, so widening +> what a validator accepts does not widen what an attacker can assert. + +## Custom storage and device resolvers + +```cpp +class my_resolver final : public moonbase::device_id_resolver { public: std::string device_name() const override { return "Studio Mac"; } std::string device_id() const override { return "stable-device-id"; } }; auto store = std::make_shared("licenses/license.mb"); -auto fingerprint = std::make_shared(); -moonbase::licensing licensing(options, store, fingerprint); +auto resolver = std::make_shared(); +moonbase::licensing licensing(options, store, resolver); ``` The default store is in-memory. `file_license_store` persists a JSON representation of the validated license. -The default fingerprint provider builds a stable, native hardware fingerprint -from platform identity parameters such as SMBIOS fields on Windows, -`IOPlatformUUID` on macOS, and board/BIOS/CPU fields on Linux. Use a custom -`fingerprint_provider` when you need an exact legacy fingerprint or any other -application-specific device ID. If you include narrow SDK headers instead of -``, include `` for the -native provider and `` for the default CURL transport. +A custom resolver's id is compared literally, so it does not need to follow the +`mbd2_` stamp format, and it gives up cross-SDK compatibility by definition. If you +include narrow SDK headers instead of ``, include +`` for the default resolver and +`` for the default CURL transport. + +> **Renamed in 4.0.0.** `fingerprint_provider` is now `device_id_resolver`, +> `static_fingerprint_provider` is `static_device_id_resolver`, and +> `licensing::fingerprint()` is `licensing::device_resolver()`. The old names remain +> as deprecated aliases and will be removed in 5.0.0; define +> `MOONBASE_DISABLE_DEPRECATED_ALIASES` to find every remaining use now. ## JUCE Plugins @@ -225,6 +406,7 @@ available and unchanged. | **Built-in UI** | Yes (polished, animated, themeable) | No (you build it) | | **JUCE integration** | Native Moonbase API | `juce::OnlineUnlockStatus` wrapper | | **JUCE version** | 8.0.4+ | 7+ | +| **Device fingerprint** | Spec v2 (`mbd2_`), cross-SDK; scoped `mbd2s_` on mobile | Spec v2 (`mbd2_`), cross-SDK; scoped `mbd2s_` on mobile | | **Third-party deps** | None (JUCE `WebInputStream` HTTP, bundled `nlohmann/json`, OS-native RS256) | Inherits the core SDK's CURL + OpenSSL | | **Entry point** | `ActivationComponent` / `ActivationDialog` | `MoonbaseUnlockStatus` | | **Best for** | New plugins wanting a ready-made UI | Apps already on `OnlineUnlockStatus`, or JUCE 7 | @@ -287,8 +469,8 @@ for the full wiring. ### `OnlineUnlockStatus` bridge A drop-in bridge ([`docs/juce.md`](docs/juce.md)) that wires Moonbase activation -into `juce::OnlineUnlockStatus`, sources the device fingerprint from JUCE's -`SystemStats` helpers, and populates activation metadata with host/system +into `juce::OnlineUnlockStatus`, uses the same spec device id as the rest of the +SDK, and populates activation metadata with host/system context (DAW, plugin format, OS, CPU, JUCE version). The bridge header lives at [`examples/juce/MoonbaseJuceBridge.h`](examples/juce/MoonbaseJuceBridge.h) and is copy-pasteable into any JUCE project; you supply your own activation UI. diff --git a/docs/juce-module.md b/docs/juce-module.md index a2b70a3..c831aa4 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -163,12 +163,102 @@ manufacturer name, `accent` colour, the Moonbase co-brand badge (`showMoonbaseBa (every colour is a token) or bundle real Inter / Space Mono typefaces and point the `heading` / `body` / `mono` font helpers at them. -## Fingerprinting +## Device identity + +By default the module identifies the device with +`moonbase::moonbase_device_id_resolver`, which implements the cross-SDK +[device fingerprint spec](../FINGERPRINT_SPEC.md) (`moonbase:fingerprint:v2`). A +license activated in a web or Electron app built on `@moonbase.sh/licensing` +validates in your plugin, and the other way round. See +[Device fingerprint](../README.md#device-fingerprint) for the stability contract and +the Linux caveats. + +It still shells out to nothing: IOKit on macOS, world-readable files on Linux and +the firmware table on Windows all work inside a sandboxed host, and none of them +depend on the process running elevated. + +Supply your own with `config.deviceIdResolver`, and inspect what the default +resolved to with `controller().describeDevice()`, which returns the id, spec +version, platform tag and the *names* of the contributing parameters. That is safe +to put behind a "Copy diagnostics" button; parameter values are hardware serial +numbers and are never exposed. + +### Migrating an already-shipped plugin + +**This changed in 4.0.0.** Earlier versions used +`juce::SystemStats::getUniqueDeviceID()`, so every already-activated user's license +is bound to that id. Without action they are locked out at next launch and must +re-activate, which consumes a fresh activation seat and resets any device-scoped +trial. One line avoids it: -By default the module identifies the device via -`juce::SystemStats::getUniqueDeviceID()` (so it never shells out to ioreg/dmidecode -inside a sandboxed host). Pick one fingerprint source when you ship and keep it — -changing it changes the device id Moonbase sees and invalidates existing activations. +```cpp +config.deviceIdResolver = std::make_shared( + // The platform default, NOT moonbase_device_id_resolver directly: on iOS and + // Android that has no identity to read and throws, and a migrating resolver + // asks its current resolver for an id before consulting any historical one, + // so hard-coding it locks mobile users out of validation *and* activation. + ActivationConfig::defaultDeviceIdResolver(), // binds + std::make_shared()); // still accepted +``` + +New activations bind the spec id; existing licenses keep validating; the fleet +migrates as devices naturally re-activate, and you drop the wrapper in a later +release. Because `getUniqueDeviceID()` is JUCE's own derivation rather than a +published format, the historical resolver only vouches for a binding if the plugin +still ships the JUCE version that created it, so do not combine this upgrade with a +JUCE major bump. Full options in +[Migrating from 3.x](../README.md#migrating-from-3x). + +### iOS and Android use a scoped identity + +On iOS and Android the module emits a **scoped** spec id, stamped `mbd2s_`, built +from `identifierForVendor` and `ANDROID_ID` respectively. Both readers live in the +core SDK, not in this module: the fingerprint is framework-independent by design, +so a non-JUCE app computes the same id. + +Neither platform exposes a device identifier that unrelated applications can read. +iOS has had no accessible hardware serial since iOS 7, and `identifierForVendor` is +scoped to the App Store vendor; Android's `Build.SERIAL` returns `unknown` without a +privileged permission from Android 10, and `ANDROID_ID` has been scoped to the app +signing key since Android 8. **Cross-SDK parity on mobile is therefore impossible by +construction.** Rather than hide that, the spec's +[scoped identity](../FINGERPRINT_SPEC.md#scoped-identity) rules stamp the limitation +into the id: it is stable for your device within the platform's scope, and must +never be correlated with an id from another scope, in either direction. + +Note the scope is the platform's, not yours: one vendor's two differently-signed +Android apps do not share an id. + +`config.allowDeviceNameFallback` is **forbidden** on both, not merely ignored. Since +iOS 17 `gethostname()` returns the literal `localhost` on every device and +`UIDevice.name` returns the model name; on Android the label is `Build.MODEL`. An +SDK that fell through to the fallback when the scoped identifier was momentarily +absent would hand its entire install base one device id, and a single activation +would unlock every device. The ladder is scoped identity, then insufficient +identity, and nothing else. Absence is transient, so retry later. + +> The Android reader deliberately does **not** use +> `juce::SystemStats::getUniqueDeviceID()`. That reads the *static field* +> `Settings.Secure.ANDROID_ID`, which is the key name `"android_id"` rather than the +> device's value, so every JUCE Android app reports the same id. The spec's +> `^[0-9a-f]{1,16}$` rule makes that mistake mechanically impossible here, since +> `"android_id"` is not hex. + +**Mobile needs the same migration as desktop.** `mbd2s_` is a different value +from the raw `getUniqueDeviceID()` string bound before 4.0.0, so pass +`legacy_juce_device_id_resolver` as a historical resolver there too. + +### When a machine has no hardware identity + +Elsewhere, activation fails with `moonbase::insufficient_device_identity_error` +rather than binding something weak, and the controller routes it to the `Error` +screen with a diagnostic explaining why. This is reachable on cloned VM images whose +SMBIOS carries an unset UUID and a blank baseboard serial, and on minimal containers. + +Set `config.allowDeviceNameFallback = true` to accept a weaker id derived from the +computer name instead. Those ids are stamped `mbd2n_` so the server can tell them +apart from real hardware bindings. Weigh it against the `"iPhone"` problem above: +the fallback is only safe where computer names are actually distinct. ## Sample app diff --git a/docs/juce.md b/docs/juce.md index fee44b8..5188d79 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -14,11 +14,12 @@ build dependency of the SDK itself. ## What you get [`examples/juce/MoonbaseJuceBridge.h`](../examples/juce/MoonbaseJuceBridge.h) is a -single header containing four pieces under the `moonbase::juce_bridge` +single header containing three pieces under the `moonbase::juce_bridge` namespace: -- **`MoonbaseJuceFingerprintProvider`** — implements `moonbase::fingerprint_provider` - on top of `juce::SystemStats::getUniqueDeviceID()` (JUCE 7+). +- **`MoonbaseJuceDeviceIdResolver`** — implements `moonbase::device_id_resolver` + on top of `juce::SystemStats::getUniqueDeviceID()`. Retained only as a + *historical* resolver for migrating an already-shipped bridge. - **`applyJuceMetadata(options)`** — fills `licensing_options.metadata` (and `application_version`) from JUCE's system + host helpers. - **`MoonbaseUnlockStatus`** — subclass of `juce::OnlineUnlockStatus` that @@ -278,21 +279,57 @@ process), and after each state change synthesizes a JUCE-format keyfile signed with that key and hands it to `applyKeyFile()`. That's the only public path into JUCE's private `status` ValueTree, so we go through it. -## Fingerprinting +## Device identity -`MoonbaseJuceFingerprintProvider` delegates to -`juce::SystemStats::getUniqueDeviceID()`, which JUCE 7+ derives from stable -hardware identifiers and hashes for you. If you don't pass a custom provider, -`MoonbaseUnlockStatus` defaults to it. +`MoonbaseUnlockStatus` defaults to `moonbase::moonbase_device_id_resolver`, which +implements the cross-SDK [device fingerprint spec](../FINGERPRINT_SPEC.md) +(`moonbase:fingerprint:v2`). A license activated in a web or Electron app built on +`@moonbase.sh/licensing` therefore validates in your plugin, and the other way +round. See [Device fingerprint](../README.md#device-fingerprint) for the stability +contract and the Linux caveats. -If your codebase predates JUCE 7 or you want the SDK's native fingerprint -(SMBIOS on Windows, `IOPlatformUUID` on macOS, board/BIOS/CPU on Linux), pass -`std::make_shared()` as the third -constructor argument instead. +It needs no JUCE at all, and spawns no subprocess on any platform: IOKit on macOS, +world-readable files on Linux and the firmware table on Windows all work inside a +sandboxed host. -Switching providers between releases changes the device ID Moonbase sees, -which invalidates existing activations. Pick one when you ship and stay with -it. +### Migrating an already-shipped bridge + +**This changed in 4.0.0.** Earlier versions used +`juce::SystemStats::getUniqueDeviceID()`, so if you have already shipped, every +existing user's license is bound to that id and will fail against the new one. Keep +accepting it while binding the spec id on new activations: + +```cpp +MoonbaseUnlockStatus status( + options, + store, + std::make_shared( + std::make_shared(), // binds + std::make_shared())); // still accepted +``` + +`MoonbaseJuceDeviceIdResolver` (renamed from `MoonbaseJuceFingerprintProvider`) is +retained in the bridge header for exactly this purpose. Because +`getUniqueDeviceID()` is JUCE's own derivation rather than a published format, it +only vouches for a binding if your plugin still ships the JUCE version that created +it, so do not combine this upgrade with a JUCE major bump. + +Without the wrapper, users are locked out until they re-activate, which consumes a +fresh activation seat and resets any device-scoped trial. The full set of options is +in [Migrating from 3.x](../README.md#migrating-from-3x). + +Switching resolvers changes the device id Moonbase sees, so pick one when you ship +and stay with it. The migrating wrapper exists precisely so a change of algorithm +need not be a change of binding. + +### When a machine has no hardware identity + +`device_id()` throws `moonbase::insufficient_device_identity_error` rather than +inventing something weak. The bridge's `getLocalMachineIDs()` and its keyfile +synthesis both swallow that, because JUCE calls them from inside `applyKeyFile()` +and a throw would unwind through JUCE's own code; the license simply goes unmatched. +To accept a weaker host-name id instead, pass a resolver configured with +`moonbase::moonbase_device_id_resolver_options::fallback = moonbase::device_id_fallback::device_name`. ## Metadata helper @@ -332,7 +369,7 @@ options.metadata["app.channel"] = "beta"; // your own keys still go through The helper deliberately omits `SystemStats::getFullUserName()`, `getLogonName()`, and `getComputerName()` — PII that doesn't belong in activation metadata. The computer name is already covered by -`fingerprint_provider::device_name()`. +`device_id_resolver::device_name()`. If your toolchain has `juce_audio_processors` linked but the auto-detect doesn't pick it up, define `MOONBASE_JUCE_HAS_AUDIO_PROCESSORS=1` before diff --git a/examples/device_id.cpp b/examples/device_id.cpp new file mode 100644 index 0000000..a9a45b8 --- /dev/null +++ b/examples/device_id.cpp @@ -0,0 +1,52 @@ +// Print this machine's Moonbase device id and how it was derived. +// +// Two uses. It is the diagnostic to run when a customer reports "this license is +// not for this device": the output is safe to paste into a support ticket, +// because it names the identity parameters that contributed but never their +// values (those are hardware serial numbers, and an unsalted hash of one is a +// stable global correlator, no safer to publish than the value itself). +// +// It is also how CI proves cross-SDK parity. The device fingerprint spec is only +// worth anything if two SDKs agree on real hardware, so +// .github/workflows/fingerprint-parity.yml runs this next to +// @moonbase.sh/licensing on the same runner and requires the two device ids to +// be identical. Hence the machine-readable output. +// +// Exits 0 even when this machine has no usable identity: that is a legitimate +// answer about the machine, reported as an "error" field, and the parity check +// needs to see it in order to confirm the other SDK reached the same conclusion. + +#include + +#include + +#include + +int main() +{ + // Report what this machine actually offers, so a host with no hardware + // identity is visible as such rather than silently downgraded. + moonbase::moonbase_device_id_resolver resolver; + + nlohmann::json out; + out["deviceName"] = resolver.device_name(); + + try { + const auto described = resolver.describe_device().value(); + + out["deviceId"] = described.device_id; + out["version"] = described.version; + out["platform"] = described.platform; + out["source"] = described.source == moonbase::fingerprint_spec::device_id_source::device_name + ? "deviceName" + : "identity"; + out["paramNames"] = described.param_names; + } catch (const moonbase::insufficient_device_identity_error& ex) { + out["error"] = "DeviceIdentityUnavailable"; + out["message"] = ex.what(); + out["platform"] = ex.platform(); + } + + std::cout << out.dump(2) << '\n'; + return 0; +} diff --git a/examples/juce/MoonbaseJuceBridge.h b/examples/juce/MoonbaseJuceBridge.h index 82855d8..31ec72d 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -55,12 +55,28 @@ namespace moonbase::juce_bridge { // --------------------------------------------------------------------------- -// Fingerprinting +// Device identity // --------------------------------------------------------------------------- -// Sources the device fingerprint from juce::SystemStats::getUniqueDeviceID(), -// which JUCE itself hashes from stable hardware identifiers. Requires JUCE 7+. -class MoonbaseJuceFingerprintProvider : public moonbase::fingerprint_provider +// The device id this bridge used before the SDK adopted the cross-SDK +// fingerprint spec: juce::SystemStats::getUniqueDeviceID(). +// +// No longer the default. It is not the spec, so a license activated in a web or +// Electron app built on @moonbase.sh/licensing never validates here, and +// getUniqueDeviceID() is JUCE's own derivation rather than a published format, +// so it can change between JUCE versions. +// +// Keep it as a *historical* resolver if this bridge already has activated users, +// so their licenses keep validating while new activations bind the spec id: +// +// MoonbaseUnlockStatus status(options, store, +// std::make_shared( +// std::make_shared(), +// std::make_shared())); +// +// Deliberately not wired up by default: widening what your validator accepts is +// your decision, not something a header you own should do silently. +class MoonbaseJuceDeviceIdResolver : public moonbase::device_id_resolver { public: [[nodiscard]] std::string device_name() const override @@ -185,13 +201,13 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus public: explicit MoonbaseUnlockStatus(moonbase::licensing_options options, std::shared_ptr store = nullptr, - std::shared_ptr fingerprint - = std::make_shared(), + std::shared_ptr deviceIds + = std::make_shared(), juce::String websiteName = "moonbase.sh") : productId_(options.product_id), websiteName_(std::move(websiteName)), licensing_(getOrCreateLicensing( - std::move(options), std::move(store), std::move(fingerprint))) + std::move(options), std::move(store), std::move(deviceIds))) { juce::RSAKey::createKeyPair(juceUnlockPublicKey_, juceUnlockPrivateKey_, 512); } @@ -832,13 +848,23 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus return {}; } - // Returns a single-entry list with the moonbase device fingerprint, which + // Returns a single-entry list with the moonbase device id, which // is also what we encode into the synthesized keyfiles. This keeps // applyKeyFile()'s machine-number match step consistent with our own // notion of device identity. juce::StringArray getLocalMachineIDs() override { - return juce::StringArray(juce::String(licensing_->fingerprint().device_id())); + // JUCE calls this from inside applyKeyFile(), so an exception here would + // unwind through JUCE's own code. A machine with no readable identity + // reports no ids, which fails the match step rather than the process. + try + { + return juce::StringArray(juce::String(licensing_->device_resolver().device_id())); + } + catch (const std::exception&) + { + return {}; + } } private: @@ -859,7 +885,17 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus void applyLicenseToJuceState(const moonbase::license& lic) { - const auto machineId = juce::String(licensing_->fingerprint().device_id()); + // Same reasoning as getLocalMachineIDs(): this runs on state changes, and + // a machine with no readable identity must not turn that into a throw out + // of a JUCE callback. An empty machine id simply fails the later match. + juce::String machineId; + try + { + machineId = juce::String(licensing_->device_resolver().device_id()); + } + catch (const std::exception&) + { + } const auto appId = juce::String(productId_); const auto email = juce::String(lic.issued_to.email); const auto userName = lic.issued_to.name.empty() @@ -933,7 +969,7 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus // The key combines every input that affects which backend, which signing // key, and which on-disk slot the instance speaks to. Two bridges that // happen to share a product_id but point at different tenants, public - // keys, store paths, or fingerprint providers each get their own SDK + // keys, store paths, or device id resolvers each get their own SDK // instance. Weak-ptr storage releases entries once their last bridge // dies; the vector scan is O(N) over distinct active configurations, // which is bounded by the number of products this process hosts. @@ -944,7 +980,7 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus std::string public_key; std::optional account_id; moonbase::license_store* store_ptr; - moonbase::fingerprint_provider* fingerprint_ptr; + moonbase::device_id_resolver* device_id_resolver_ptr; bool operator==(const LicensingCacheKey& other) const noexcept { @@ -953,14 +989,14 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus && public_key == other.public_key && account_id == other.account_id && store_ptr == other.store_ptr - && fingerprint_ptr == other.fingerprint_ptr; + && device_id_resolver_ptr == other.device_id_resolver_ptr; } }; static std::shared_ptr getOrCreateLicensing( moonbase::licensing_options options, std::shared_ptr store, - std::shared_ptr fingerprint) + std::shared_ptr deviceIds) { static std::mutex cacheMutex; static std::vector lock(cacheMutex); @@ -992,7 +1028,7 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus } auto instance = std::make_shared( - std::move(options), std::move(store), std::move(fingerprint)); + std::move(options), std::move(store), std::move(deviceIds)); cache.emplace_back(key, instance); return instance; } diff --git a/include/moonbase/client.hpp b/include/moonbase/client.hpp index a7a656f..8b86ae0 100644 --- a/include/moonbase/client.hpp +++ b/include/moonbase/client.hpp @@ -10,8 +10,8 @@ #include #include "moonbase/detail/url.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" -#include "moonbase/fingerprint.hpp" #include "moonbase/http.hpp" #include "moonbase/types.hpp" #include "moonbase/validator.hpp" @@ -137,15 +137,15 @@ class license_client { public: license_client( licensing_options options, - std::shared_ptr fingerprints, + std::shared_ptr device_ids, std::shared_ptr validator, std::shared_ptr transport) : options_(std::move(options)), - fingerprints_(std::move(fingerprints)), + device_ids_(std::move(device_ids)), validator_(std::move(validator)), transport_(std::move(transport)) { - if (!fingerprints_) { + if (!device_ids_) { throw configuration_error("A fingerprint provider is required"); } if (!validator_) { @@ -163,8 +163,8 @@ class license_client { detail::client_query(options_)); const auto payload = nlohmann::json{ - {"deviceName", fingerprints_->device_name()}, - {"deviceSignature", fingerprints_->device_id()}, + {"deviceName", device_ids_->device_name()}, + {"deviceSignature", device_ids_->device_id()}, }; http_request request; @@ -252,7 +252,7 @@ class license_client { private: licensing_options options_; - std::shared_ptr fingerprints_; + std::shared_ptr device_ids_; std::shared_ptr validator_; std::shared_ptr transport_; }; diff --git a/include/moonbase/default_fingerprint.hpp b/include/moonbase/default_fingerprint.hpp index 470096d..751fbfc 100644 --- a/include/moonbase/default_fingerprint.hpp +++ b/include/moonbase/default_fingerprint.hpp @@ -1,372 +1,31 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#if defined(_WIN32) -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#else -#include -#endif - -#include "moonbase/detail/crypto/crypto.hpp" -#include "moonbase/fingerprint.hpp" +// Compatibility header. +// +// The default device fingerprint changed in 4.0.0: it now follows the cross-SDK +// Moonbase device fingerprint spec, so `default_fingerprint_provider` resolves to +// moonbase_device_id_resolver rather than to the old `moonbase-cpp:fingerprint:v1` +// algorithm. Device ids computed here therefore differ from those computed by +// 3.x, and existing licenses need either re-activation or a +// migrating_device_id_resolver. See "Migrating from 3.x" in the README. +// +// The previous algorithm is preserved verbatim as +// moonbase::legacy_cpp_device_id_resolver in , +// so it can keep validating licenses that were bound under it. +// +// Define MOONBASE_DISABLE_DEPRECATED_ALIASES to compile the alias out. + +#include "moonbase/moonbase_device_id_resolver.hpp" namespace moonbase { -class default_fingerprint_provider : public fingerprint_provider { -public: - using identity_parameter = std::pair; - - [[nodiscard]] static std::string platform_tag() - { -#if defined(__APPLE__) - return "mac"; -#elif defined(_WIN32) - return "windows"; -#elif defined(__ANDROID__) - return "android"; -#elif defined(__linux__) - return "linux"; -#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) - return "bsd"; -#else - return "unknown"; -#endif - } - - [[nodiscard]] static std::string hash_identity_parameters( - const std::vector& parameters, - std::string_view platform = platform_tag()) - { - std::string material; - material += "moonbase-cpp:fingerprint:v1\n"; - material += "platform="; - material.append(platform.data(), platform.size()); - material += "\n"; - - for (const auto& parameter : parameters) { - auto name = trim_ascii(parameter.first); - auto value = trim_ascii(parameter.second); - if (!name.empty() && !value.empty()) { - material += name; - material += "="; - material += value; - material += "\n"; - } - } - - return detail::sha256_hex(material); - } - - [[nodiscard]] static std::vector identity_parameters() - { - std::vector parameters; - -#if defined(_WIN32) - append_windows_identity_parameters(parameters); -#elif defined(__APPLE__) - auto uuid = trim_ascii(command_output( - "ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | " - "awk -F\\\" '/IOPlatformUUID/{print $4; exit}'")); - uuid.erase(std::remove(uuid.begin(), uuid.end(), '-'), uuid.end()); - append_parameter(parameters, "ioPlatformUuid", uuid); -#elif defined(__linux__) && !defined(__ANDROID__) - const auto board_serial = trim_ascii(read_file("/sys/class/dmi/id/board_serial")); - if (!board_serial.empty()) { - append_parameter(parameters, "boardSerial", board_serial); - } else { - append_parameter(parameters, "biosDate", read_file("/sys/class/dmi/id/bios_date")); - append_parameter(parameters, "biosRelease", read_file("/sys/class/dmi/id/bios_release")); - append_parameter(parameters, "biosVendor", read_file("/sys/class/dmi/id/bios_vendor")); - append_parameter(parameters, "biosVersion", read_file("/sys/class/dmi/id/bios_version")); - } - - const auto cpu_data = command_output("lscpu 2>/dev/null"); - if (!cpu_data.empty()) { - append_parameter(parameters, "cpuFamily", linux_cpu_field(cpu_data, "CPU family:")); - append_parameter(parameters, "cpuModel", linux_cpu_field(cpu_data, "Model:")); - append_parameter(parameters, "cpuModelName", linux_cpu_field(cpu_data, "Model name:")); - append_parameter(parameters, "cpuVendor", linux_cpu_field(cpu_data, "Vendor ID:")); - } -#endif - - return parameters; - } - - [[nodiscard]] std::string device_name() const override - { -#if defined(_WIN32) - char buffer[128]{}; - DWORD size = static_cast(sizeof(buffer)) - 1; - if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer, &size)) { - return std::string(buffer, size); - } - return {}; -#else - std::array buffer{}; - if (gethostname(buffer.data(), buffer.size() - 1) == 0) { - auto name = std::string(buffer.data()); -#if defined(__APPLE__) - const auto suffix = std::string(".local"); - if (name.size() >= suffix.size()) { - auto tail = name.substr(name.size() - suffix.size()); - std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - if (tail == suffix) { - name.erase(name.size() - suffix.size()); - } - } -#endif - return name; - } - return {}; -#endif - } - - [[nodiscard]] std::string device_id() const override - { - auto parameters = identity_parameters(); - if (parameters.empty()) { - append_parameter(parameters, "deviceName", device_name()); - } - return hash_identity_parameters(parameters); - } - -private: - [[nodiscard]] static std::string read_file(const std::string& path) - { - std::ifstream file(path); - if (!file) { - return {}; - } - std::ostringstream out; - out << file.rdbuf(); - return out.str(); - } - - [[nodiscard]] static std::string command_output(const std::string& command) - { -#if defined(_WIN32) - (void)command; - return {}; -#else - std::array buffer{}; - std::string result; - std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); - if (!pipe) { - return {}; - } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { - result += buffer.data(); - } - return result; -#endif - } - - [[nodiscard]] static std::string trim_ascii(std::string value) - { - while (!value.empty() && - (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { - value.pop_back(); - } - while (!value.empty() && - (value.front() == '\n' || value.front() == '\r' || value.front() == ' ' || value.front() == '\t')) { - value.erase(value.begin()); - } - return value; - } - - [[nodiscard]] static std::string linux_cpu_field(const std::string& lscpu_output, const std::string& key) - { - const auto key_index = lscpu_output.find(key); - if (key_index == std::string::npos) { - return {}; - } - - const auto colon = lscpu_output.find(':', key_index); - if (colon == std::string::npos) { - return {}; - } - - const auto end = lscpu_output.find('\n', colon); - return trim_ascii(lscpu_output.substr( - colon + 1, - end == std::string::npos ? std::string::npos : end - colon - 1)); - } - - static void append_parameter( - std::vector& parameters, - std::string name, - std::string value) - { - name = trim_ascii(std::move(name)); - value = trim_ascii(std::move(value)); - if (!name.empty() && !value.empty()) { - parameters.emplace_back(std::move(name), std::move(value)); - } - } - -#if defined(_WIN32) - [[nodiscard]] static std::string windows_string_from_offset( - const std::vector& content, - const std::vector& strings, - std::size_t byte_offset) - { - if (byte_offset >= content.size()) { - return {}; - } - - const auto index = static_cast(content[byte_offset]); - if (index == 0 || index > strings.size()) { - return {}; - } - - return std::string(strings[index - 1]); - } - - [[nodiscard]] static std::size_t windows_bounded_string_length(const char* value, std::size_t max_length) - { - std::size_t length = 0; - while (length < max_length && value[length] != '\0') { - ++length; - } - return length; - } - - static void append_windows_identity_parameters(std::vector& parameters) - { - constexpr DWORD signature = - static_cast('R') | - (static_cast('S') << 8U) | - (static_cast('M') << 16U) | - (static_cast('B') << 24U); - - const auto table_size = GetSystemFirmwareTable(signature, 0, nullptr, 0); - if (table_size == 0) { - return; - } - - std::vector smbios(table_size); - if (GetSystemFirmwareTable(signature, 0, smbios.data(), table_size) != table_size) { - return; - } - - struct raw_smbios_data { - std::uint8_t unused[4]; - std::uint32_t length; - }; - - struct smbios_header { - std::uint8_t id; - std::uint8_t length; - std::uint16_t handle; - }; - - if (smbios.size() < sizeof(raw_smbios_data)) { - return; - } - - raw_smbios_data raw{}; - std::memcpy(&raw, smbios.data(), sizeof(raw)); - if (smbios.size() < sizeof(raw_smbios_data) + raw.length) { - return; - } - - std::vector content( - smbios.begin() + static_cast(sizeof(raw_smbios_data)), - smbios.begin() + static_cast(sizeof(raw_smbios_data) + raw.length)); - - std::size_t offset = 0; - while (offset < content.size()) { - if (content.size() - offset < sizeof(smbios_header)) { - break; - } - - smbios_header header{}; - std::memcpy(&header, content.data() + offset, sizeof(header)); - if (header.length == 0 || content.size() - offset < header.length) { - break; - } - - std::vector strings; - auto string_offset = offset + header.length; - while (string_offset < content.size()) { - const auto* str = reinterpret_cast(content.data() + string_offset); - const auto max_length = content.size() - string_offset; - const auto length = windows_bounded_string_length(str, max_length); - if (length == 0) { - break; - } - strings.emplace_back(str, length); - string_offset += std::min(length + 1, max_length); - } - - const auto end_of_table = std::min( - content.size(), - std::max(offset + static_cast(header.length) + 2, string_offset + 1)); - - const auto from_offset = [&](std::size_t byte_offset) { - return windows_string_from_offset(content, strings, offset + byte_offset); - }; - - switch (header.id) { - case 1: { - append_parameter(parameters, "systemManufacturer", from_offset(0x04)); - append_parameter(parameters, "systemProductName", from_offset(0x05)); - - if (offset + 0x08 + 16 <= content.size()) { - std::ostringstream hex; - hex << std::uppercase << std::hex << std::setfill('0'); - for (std::size_t index = 0; index != 16; ++index) { - hex << std::setw(2) << static_cast(content[offset + 0x08 + index]); - } - append_parameter(parameters, "systemUuid", hex.str()); - } - break; - } - - case 2: - append_parameter(parameters, "baseboardManufacturer", from_offset(0x04)); - append_parameter(parameters, "baseboardProduct", from_offset(0x05)); - append_parameter(parameters, "baseboardVersion", from_offset(0x06)); - append_parameter(parameters, "baseboardSerialNumber", from_offset(0x07)); - append_parameter(parameters, "baseboardAssetTag", from_offset(0x08)); - break; - - case 4: - append_parameter(parameters, "processorManufacturer", from_offset(0x07)); - append_parameter(parameters, "processorVersion", from_offset(0x10)); - append_parameter(parameters, "processorAssetTag", from_offset(0x21)); - append_parameter(parameters, "processorPartNumber", from_offset(0x22)); - break; +#if !defined(MOONBASE_DISABLE_DEPRECATED_ALIASES) - default: - break; - } +using default_fingerprint_provider + [[deprecated("renamed to moonbase::moonbase_device_id_resolver; note that 4.0.0 also changed " + "the algorithm to the cross-SDK spec, and the previous one is now " + "moonbase::legacy_cpp_device_id_resolver")]] = moonbase_device_id_resolver; - offset = end_of_table; - } - } #endif -}; } // namespace moonbase diff --git a/include/moonbase/detail/unicode/nfc_ascii.hpp b/include/moonbase/detail/unicode/nfc_ascii.hpp new file mode 100644 index 0000000..200fdf7 --- /dev/null +++ b/include/moonbase/detail/unicode/nfc_ascii.hpp @@ -0,0 +1,294 @@ +#pragma once + +// NFC followed by a printable-ASCII filter, without ICU. +// +// THIS IS NOT A GENERAL NFC IMPLEMENTATION AND MUST NOT BE REUSED AS ONE. It +// answers exactly one question, and only because of what runs immediately after +// it: given a string, which printable-ASCII characters does NFC leave visible? +// It never materializes a normalized string, and it is only correct downstream +// of the U+0020..U+007E filter. +// +// The device fingerprint spec (FINGERPRINT_SPEC.md) requires NFC before that +// filter, and skipping it is not cosmetic: "cafe" + U+0301 must canonicalize to +// "caf", not "cafe", because NFC first composes the pair into a precomposed +// e-acute that the filter then drops whole. Getting this wrong yields a device +// id that disagrees with every other Moonbase SDK on the same machine. +// +// Because the filter discards everything outside printable ASCII, NFC can only +// change the visible result in three ways, and scripts/gen-nfc-tables.py +// enumerates all three exhaustively from the Unicode database: +// +// 1. An ASCII starter is consumed by a following combining mark. +// 2. A non-ASCII code point whose NFC form *is* a printable-ASCII character +// (exactly three exist, all singleton decompositions). +// 3. A combining mark with its own canonical decomposition, expanded before +// the blocking analysis in (1). +// +// Blocking collapses to a single question per cluster, because once an ASCII +// base composes the result is non-ASCII forever and no later composition can +// bring it back. Canonical ordering is a stable sort by combining class, so a +// mark is blocked exactly when an earlier mark in the cluster shares its class. +// The base is therefore annihilated if and only if it composes with some mark +// that is the first of its class in the cluster, which needs no sorting. +// +// scripts/gen-nfc-tables.py --verify differential-tests this against real NFC +// over every code point, every printable-ASCII base crossed with every combining +// mark, exhaustive base x mark x mark for a sample of bases, and several hundred +// thousand random strings. +// +// It is exact except in one deliberately bounded way. The shipped combining-class +// table covers the five combining-mark blocks rather than all of Unicode, because +// the full table costs 8.5 KB of header for nothing the spec exercises. A mark +// outside those blocks therefore reads as a starter, which ends the cluster, so a +// later composing mark never gets the chance to annihilate the base. Reaching that +// needs an ASCII base, then a combining mark from a non-Latin script, then a Latin +// composing mark. No IOKit UUID, machine-id, sysfs DMI string, SMBIOS string or +// host name contains such a sequence, and --verify asserts the shape of every +// divergence, that it always errs towards keeping the base, and that no input of +// one or two code points diverges at all. + +#include +#include +#include +#include + +#include "moonbase/detail/unicode/nfc_tables.hpp" + +namespace moonbase::detail::unicode { + +/// Lowest and highest characters the fingerprint material may contain. +inline constexpr char32_t printable_ascii_min = 0x20; +inline constexpr char32_t printable_ascii_max = 0x7E; + +/// A byte that could not begin or continue a well-formed UTF-8 sequence. +/// +/// Kept as an opaque non-ASCII starter rather than dropped outright: it ends any +/// combining cluster in progress but never annihilates a preceding base. That is +/// what Node produces for the same input, since its UTF-8 decoder substitutes +/// U+FFFD, which is likewise a non-ASCII starter. Firmware strings are the +/// realistic source of such bytes. +inline constexpr char32_t malformed_code_point = 0x7FFFFFFF; + +namespace detail { + +/// Canonical combining class, or 0 for a starter. +[[nodiscard]] inline std::uint8_t combining_class(char32_t code_point) noexcept +{ + const auto* ranges = tables::combining_class_ranges; + std::size_t low = 0; + std::size_t high = tables::combining_class_range_count; + + while (low < high) { + const std::size_t middle = low + (high - low) / 2; + if (code_point < ranges[middle].first) { + high = middle; + } else if (code_point > ranges[middle].last) { + low = middle + 1; + } else { + return ranges[middle].combining_class; + } + } + + return 0; +} + +/// Bit position for a combining class that some composing mark uses, else -1. +[[nodiscard]] inline int combining_class_slot(std::uint8_t combining) noexcept +{ + for (std::size_t index = 0; index != tables::composing_mark_class_count; ++index) { + if (tables::composing_mark_classes[index] == combining) { + return static_cast(index); + } + } + return -1; +} + +/// Does `base` form a primary composite with `mark`? +[[nodiscard]] inline bool composes(char32_t base, char32_t mark) noexcept +{ + if (base < tables::composition_mask_first || base > tables::composition_mask_last) { + return false; + } + if (mark < tables::composing_mark_first || mark > tables::composing_mark_last) { + return false; + } + + const auto bit = tables::composing_mark_index[mark - tables::composing_mark_first]; + if (bit < 0) { + return false; + } + + const auto mask = tables::composition_masks[base - tables::composition_mask_first]; + return (mask & (std::uint32_t{1} << bit)) != 0; +} + +/// Apply the singleton decompositions that expose a printable-ASCII character. +[[nodiscard]] inline char32_t map_ascii_singleton(char32_t code_point) noexcept +{ + for (std::size_t index = 0; index != tables::ascii_singleton_count; ++index) { + if (tables::ascii_singletons[index].from == code_point) { + return tables::ascii_singletons[index].to; + } + } + return code_point; +} + +/// Canonical decomposition of a combining mark, or null when it has none. +[[nodiscard]] inline const tables::mark_decomposition* decompose_mark(char32_t code_point) noexcept +{ + for (std::size_t index = 0; index != tables::mark_decomposition_count; ++index) { + if (tables::mark_decompositions[index].from == code_point) { + return &tables::mark_decompositions[index]; + } + } + return nullptr; +} + +/// Decode one UTF-8 sequence, advancing `offset`. +/// +/// Strict: overlong encodings, surrogates, values above U+10FFFF and truncated +/// sequences all yield `malformed_code_point`. On failure exactly one byte is +/// consumed, so a following ASCII byte can never be swallowed by a bad prefix +/// ("A\xC3B" keeps both the A and the B). +[[nodiscard]] inline char32_t decode_utf8(std::string_view text, std::size_t& offset) noexcept +{ + const auto lead = static_cast(text[offset]); + + if (lead < 0x80) { + offset += 1; + return lead; + } + + std::size_t length = 0; + char32_t code_point = 0; + char32_t lowest = 0; + + if ((lead & 0xE0U) == 0xC0U) { + length = 2; + code_point = lead & 0x1FU; + lowest = 0x80; + } else if ((lead & 0xF0U) == 0xE0U) { + length = 3; + code_point = lead & 0x0FU; + lowest = 0x800; + } else if ((lead & 0xF8U) == 0xF0U) { + length = 4; + code_point = lead & 0x07U; + lowest = 0x10000; + } else { + // A continuation byte with no lead, or an invalid 5/6-byte prefix. + offset += 1; + return malformed_code_point; + } + + if (offset + length > text.size()) { + offset += 1; + return malformed_code_point; + } + + for (std::size_t index = 1; index != length; ++index) { + const auto continuation = static_cast(text[offset + index]); + if ((continuation & 0xC0U) != 0x80U) { + offset += 1; + return malformed_code_point; + } + code_point = (code_point << 6U) | (continuation & 0x3FU); + } + + // Overlong, surrogate, or beyond the Unicode range. + if (code_point < lowest || (code_point >= 0xD800 && code_point <= 0xDFFF) || code_point > 0x10FFFF) { + offset += 1; + return malformed_code_point; + } + + offset += length; + return code_point; +} + +} // namespace detail + +/// NFC-normalize `text` and keep only the printable-ASCII characters that +/// survive, in order. +/// +/// The result is pure ASCII, so its length in characters equals its length in +/// bytes. Truncation and trimming are the fingerprint spec's business and happen +/// in fingerprint_spec::canonicalize_value, not here. +[[nodiscard]] inline std::string canonicalize_printable_ascii(std::string_view text) +{ + std::string out; + out.reserve(text.size()); + + // The printable-ASCII starter whose cluster is still open, or 0 for none. + char32_t pending = 0; + bool annihilated = false; + std::uint32_t seen_classes = 0; + + const auto flush = [&]() { + if (pending != 0 && !annihilated) { + out.push_back(static_cast(pending)); + } + pending = 0; + annihilated = false; + seen_classes = 0; + }; + + const auto consume = [&](char32_t code_point) { + const std::uint8_t combining = detail::combining_class(code_point); + + if (combining == 0) { + // A starter closes the previous cluster and opens its own. + flush(); + const char32_t mapped = detail::map_ascii_singleton(code_point); + if (mapped >= printable_ascii_min && mapped <= printable_ascii_max) { + pending = mapped; + } + return; + } + + // A combining mark. Marks are never printable ASCII, so the only thing + // one can do is decide the fate of the base it is attached to. + if (pending == 0 || annihilated) { + return; + } + + const int slot = detail::combining_class_slot(combining); + if (slot < 0) { + // No composing mark uses this class, so it can neither compose with + // the base nor block a mark that would. + return; + } + + const std::uint32_t bit = std::uint32_t{1} << slot; + if ((seen_classes & bit) != 0) { + // Blocked by an earlier mark of the same class. + return; + } + seen_classes |= bit; + + if (detail::composes(pending, code_point)) { + annihilated = true; + } + }; + + std::size_t offset = 0; + while (offset < text.size()) { + const char32_t code_point = detail::decode_utf8(text, offset); + + // A mark with its own canonical decomposition is expanded in place. Both + // halves keep their original position, which is what a stable canonical + // sort would do, so the blocking analysis stays correct. + if (const auto* decomposition = detail::decompose_mark(code_point)) { + for (std::uint8_t index = 0; index != decomposition->length; ++index) { + consume(decomposition->to[index]); + } + continue; + } + + consume(code_point); + } + + flush(); + return out; +} + +} // namespace moonbase::detail::unicode diff --git a/include/moonbase/detail/unicode/nfc_tables.hpp b/include/moonbase/detail/unicode/nfc_tables.hpp new file mode 100644 index 0000000..6a9b736 --- /dev/null +++ b/include/moonbase/detail/unicode/nfc_tables.hpp @@ -0,0 +1,141 @@ +#pragma once + +// Generated by scripts/gen-nfc-tables.py from Unicode 16.0.0. Do not edit by hand. +// +// Supporting data for detail/unicode/nfc_ascii.hpp, which answers exactly one +// question: after NFC, which printable-ASCII characters are still visible? See +// that header, and the generator, for why this is not a general NFC +// implementation and must never be reused as one. +// +// Stability: the Unicode Normalization Stability Policy freezes the composition +// table, the singleton mappings and the mark decompositions below, so those can +// never change. Only the combining-class ranges grow, as new scripts are +// encoded. A stale range can only matter for a value that mixes a printable +// ASCII base with a combining mark from a script this table predates, which no +// UUID, machine-id, DMI string, SMBIOS string or host name contains. + +#include +#include + +namespace moonbase::detail::unicode::tables { + +inline constexpr const char* unicode_version = "16.0.0"; + +// --------------------------------------------------------------------------- +// Canonical combining class, as run-collapsed ranges over the code points with +// a non-zero class. Needed both to tell a combining mark from a starter and to +// find the head of each equal-class run when testing whether a mark is blocked. + +struct combining_class_range { + char32_t first; + char32_t last; + std::uint8_t combining_class; +}; + +inline constexpr combining_class_range combining_class_ranges[] = { + {0x0300,0x0314,230}, {0x0315,0x0315,232}, {0x0316,0x0319,220}, {0x031A,0x031A,232}, {0x031B,0x031B,216}, {0x031C,0x0320,220}, + {0x0321,0x0322,202}, {0x0323,0x0326,220}, {0x0327,0x0328,202}, {0x0329,0x0333,220}, {0x0334,0x0338,1}, {0x0339,0x033C,220}, + {0x033D,0x0344,230}, {0x0345,0x0345,240}, {0x0346,0x0346,230}, {0x0347,0x0349,220}, {0x034A,0x034C,230}, {0x034D,0x034E,220}, + {0x0350,0x0352,230}, {0x0353,0x0356,220}, {0x0357,0x0357,230}, {0x0358,0x0358,232}, {0x0359,0x035A,220}, {0x035B,0x035B,230}, + {0x035C,0x035C,233}, {0x035D,0x035E,234}, {0x035F,0x035F,233}, {0x0360,0x0361,234}, {0x0362,0x0362,233}, {0x0363,0x036F,230}, + {0x1AB0,0x1AB4,230}, {0x1AB5,0x1ABA,220}, {0x1ABB,0x1ABC,230}, {0x1ABD,0x1ABD,220}, {0x1ABF,0x1AC0,220}, {0x1AC1,0x1AC2,230}, + {0x1AC3,0x1AC4,220}, {0x1AC5,0x1AC9,230}, {0x1ACA,0x1ACA,220}, {0x1ACB,0x1ACE,230}, {0x1DC0,0x1DC1,230}, {0x1DC2,0x1DC2,220}, + {0x1DC3,0x1DC9,230}, {0x1DCA,0x1DCA,220}, {0x1DCB,0x1DCC,230}, {0x1DCD,0x1DCD,234}, {0x1DCE,0x1DCE,214}, {0x1DCF,0x1DCF,220}, + {0x1DD0,0x1DD0,202}, {0x1DD1,0x1DF5,230}, {0x1DF6,0x1DF6,232}, {0x1DF7,0x1DF8,228}, {0x1DF9,0x1DF9,220}, {0x1DFA,0x1DFA,218}, + {0x1DFB,0x1DFB,230}, {0x1DFC,0x1DFC,233}, {0x1DFD,0x1DFD,220}, {0x1DFE,0x1DFE,230}, {0x1DFF,0x1DFF,220}, {0x20D0,0x20D1,230}, + {0x20D2,0x20D3,1}, {0x20D4,0x20D7,230}, {0x20D8,0x20DA,1}, {0x20DB,0x20DC,230}, {0x20E1,0x20E1,230}, {0x20E5,0x20E6,1}, + {0x20E7,0x20E7,230}, {0x20E8,0x20E8,220}, {0x20E9,0x20E9,230}, {0x20EA,0x20EB,1}, {0x20EC,0x20EF,220}, {0x20F0,0x20F0,230}, + {0xFE20,0xFE26,230}, {0xFE27,0xFE2D,220}, {0xFE2E,0xFE2F,230}, +}; + +inline constexpr std::size_t combining_class_range_count = + sizeof(combining_class_ranges) / sizeof(combining_class_ranges[0]); + +// --------------------------------------------------------------------------- +// The 26 combining marks that can form a primary composite with a printable +// ASCII base, and a bitmask per base saying which. All of them fall in +// U+0300..U+0338, so a small index array resolves a mark to its bit in O(1). + +inline constexpr char32_t composing_mark_first = 0x0300; +inline constexpr char32_t composing_mark_last = 0x0338; + +inline constexpr std::int8_t composing_mark_index[] = { + 0, 1, 2, 3, 4, -1, 5, 6, 7, 8, 9, 10, 11, -1, -1, 12, + -1, 13, -1, -1, -1, -1, -1, -1, -1, -1, -1, 14, -1, -1, -1, -1, + -1, -1, -1, 15, 16, 17, 18, 19, 20, -1, -1, -1, -1, 21, 22, -1, + 23, 24, -1, -1, -1, -1, -1, -1, 25, +}; + +// Indexed by (base - 0x20) for base in U+0020..U+007E. Bit i corresponds to the +// mark whose composing_mark_index value is i. +inline constexpr std::uint32_t composition_masks[] = { + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x02000000, 0x02000000, + 0x02000000, 0x00000000, 0x00000000, 0x0012BBFF, 0x01008040, 0x00080846, + 0x01288840, 0x00B8B9FF, 0x00000040, 0x00080876, 0x004888C4, 0x0090B9FF, + 0x00000004, 0x01088802, 0x01288802, 0x00008042, 0x0128884B, 0x0010FDFF, + 0x00000042, 0x00000000, 0x0108B842, 0x000C8846, 0x012C8840, 0x00B1FFBF, + 0x00008008, 0x000080C7, 0x000000C0, 0x000081DF, 0x01008846, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x0012BBFF, + 0x01008040, 0x00080846, 0x01288840, 0x00B8B9FF, 0x00000040, 0x00080876, + 0x014888C4, 0x0090B9BF, 0x00000804, 0x01088802, 0x01288802, 0x00008042, + 0x0128884B, 0x0010FDFF, 0x00000042, 0x00000000, 0x0108B842, 0x000C8846, + 0x012C88C0, 0x00B1FFBF, 0x00008008, 0x000082C7, 0x000000C0, 0x000083DF, + 0x01008846, 0x00000000, 0x00000000, 0x00000000, 0x00000000, +}; + +inline constexpr char32_t composition_mask_first = 0x0020; +inline constexpr char32_t composition_mask_last = 0x007E; + +// The distinct combining classes those marks use. A mark is blocked only by an +// earlier mark of the same class, so a class absent from this list can neither +// compose with an ASCII base nor block something that would, and the cluster +// walk skips it. Position in this array is the bit used to remember that the +// class has already been seen in the current cluster. +inline constexpr std::uint8_t composing_mark_classes[] = { + 1, 202, 216, 220, 230, +}; + +inline constexpr std::size_t composing_mark_class_count = + sizeof(composing_mark_classes) / sizeof(composing_mark_classes[0]); + +// --------------------------------------------------------------------------- +// Non-ASCII code points whose NFC form is a single printable-ASCII character. +// These are singleton decompositions, which are always composition-excluded, so +// NFC leaves the ASCII result exposed and it must survive the filter. + +struct ascii_singleton { + char32_t from; + char32_t to; +}; + +inline constexpr ascii_singleton ascii_singletons[] = { + {0x037E,0x003B}, {0x1FEF,0x0060}, {0x212A,0x004B}, +}; + +inline constexpr std::size_t ascii_singleton_count = + sizeof(ascii_singletons) / sizeof(ascii_singletons[0]); + +// --------------------------------------------------------------------------- +// Combining marks with a canonical decomposition of their own. They must be +// expanded in place before the blocking analysis, or a mark that decomposes to +// a composing one would fail to annihilate its base. + +struct mark_decomposition { + char32_t from; + char32_t to[2]; + std::uint8_t length; +}; + +inline constexpr mark_decomposition mark_decompositions[] = { + {0x0340,{0x0300,0x0000},1}, {0x0341,{0x0301,0x0000},1}, + {0x0343,{0x0313,0x0000},1}, {0x0344,{0x0308,0x0301},2}, +}; + +inline constexpr std::size_t mark_decomposition_count = + sizeof(mark_decompositions) / sizeof(mark_decompositions[0]); + +} // namespace moonbase::detail::unicode::tables diff --git a/include/moonbase/device_id_resolver.hpp b/include/moonbase/device_id_resolver.hpp new file mode 100644 index 0000000..9f2f881 --- /dev/null +++ b/include/moonbase/device_id_resolver.hpp @@ -0,0 +1,223 @@ +#pragma once + +// How this machine is identified to Moonbase. +// +// A license token carries a `sig` claim equal to the device id, recomputed and +// compared on every local validation. The default implementation +// (moonbase_device_id_resolver.hpp) follows the cross-SDK fingerprint spec, so a +// license activated by any conforming Moonbase SDK validates here and vice +// versa. Custom resolvers are compared literally and need not follow the spec. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "moonbase/errors.hpp" +#include "moonbase/fingerprint_spec.hpp" + +namespace moonbase { + +/// A device id plus the provenance needed to reason about it: safe to log, show +/// in an about box, or attach to a support ticket. +/// +/// Parameter *names* only, deliberately. Their values are hardware serial +/// numbers, and a hash of one is no safer to publish: an unsalted digest is a +/// stable global correlator for the machine, and low-entropy values such as host +/// names or sequential serials fall to a dictionary. machine-id(5) is explicit +/// that the Linux machine id is confidential and must only ever leave the host +/// through an application-specific *keyed* hash. Which parameters contributed is +/// the useful diagnostic anyway; what they read is not. +struct device_id_description { + std::string device_id; + /// Fingerprint spec version that produced it. + int version = 0; + std::string platform; + fingerprint_spec::device_id_source source = fingerprint_spec::device_id_source::identity; + /// Names of the identity parameters that went into the material, in order. + std::vector param_names; +}; + +class device_id_resolver { +public: + virtual ~device_id_resolver() = default; + + /// A human-readable label sent alongside the device id at activation. Not + /// part of the hashed material (except in the opt-in host-name fallback), so + /// it can change freely without invalidating a license. + [[nodiscard]] virtual std::string device_name() const = 0; + + /// The device id this machine binds on activation. + [[nodiscard]] virtual std::string device_id() const = 0; + + /// Does this machine also answer to a device id it used to have? + /// + /// Deliberately separate from device_id(), which stays single-valued: what a + /// device binds on activation and what a validator accepts are different + /// questions, and conflating them is what forces an all-or-nothing migration. + /// The default is no history, so a resolver that has never changed algorithm + /// need not think about this. + /// + /// A virtual with a default rather than a second interface plus a + /// dynamic_cast: plugin builds commonly disable RTTI, and the reference SDK + /// duck-types this for the same reason. + [[nodiscard]] virtual bool accepts_device_id(const std::string& /*device_id*/) const { return false; } + + /// Provenance for diagnostics, when the resolver can explain itself. + [[nodiscard]] virtual std::optional describe_device() const { return std::nullopt; } +}; + +/// A fixed identity. Useful in tests, and for apps that source the device id +/// from somewhere else entirely. +class static_device_id_resolver : public device_id_resolver { +public: + static_device_id_resolver(std::string name, std::string id) + : name_(std::move(name)), id_(std::move(id)) + { + } + + [[nodiscard]] std::string device_name() const override { return name_; } + [[nodiscard]] std::string device_id() const override { return id_; } + +private: + std::string name_; + std::string id_; +}; + +/* + * A note for anyone tempted to add a resolver that remembers a previously + * computed device id on disk, to survive a transient read failure (a sandbox + * refusing a firmware-table read, an unreadable /sys, a blocked IOKit call): + * + * It cannot be done safely at this layer. Any such cache is an unsigned file in + * the application's own storage, so the attacker controls both its contents + * *and* whether the fresh read is degraded. Recording the device id and + * replaying it when the read looks weaker therefore reduces to "write the id you + * want, then break one source": a scriptable license bypass, cheaper than + * patching the binary. + * + * Corroborating the cache against the parameters that still read does not fix + * it, because an attacker's own machine legitimately produces matching evidence, + * so they pass the check while substituting any id they like. + * + * The only sound construction stores protected *inputs* and recomputes the id, + * making a forged id require a SHA-256 preimage. That needs the raw parameter + * values to derive a key from, which this layer deliberately never sees, since + * it must not write hardware serials to disk. So it belongs inside the resolver + * that reads them, if it is ever worth building. + * + * This SDK ships store.hpp, so an on-disk cache is a much shorter change here + * than in the reference implementation. That makes the warning more important, + * not less. The memoization in moonbase_device_id_resolver is process-lifetime + * only. It is not persistence. + */ + +/// Binds the current fingerprint while still recognising ids this device was +/// bound to before: the migration path off an older algorithm without a flag day. +/// +/// device_id() always returns the *current* resolver's id, so every new +/// activation binds the current algorithm. The historical resolvers are consulted +/// only when a validator is deciding whether to accept an already-issued license. +/// A fleet therefore migrates as licenses are naturally re-activated, instead of +/// every device re-activating at once, which would burn a second activation seat +/// per device and reset device-scoped trials. +/// +/// \code +/// auto resolver = std::make_shared( +/// std::make_shared(), // binds +/// std::make_shared()); // also accepted +/// \endcode +/// +/// Historical ids are computed lazily, only on a mismatch, and then memoized, so +/// the happy path never pays for them. A historical resolver that throws is +/// skipped: it may simply not work on this platform any more, which just means it +/// cannot vouch for the license. +/// +/// Every accepted id is recomputed from the machine's own hardware. Nothing is +/// read from disk, so widening what a validator accepts does not widen what an +/// attacker can assert. +class migrating_device_id_resolver : public device_id_resolver { +public: + migrating_device_id_resolver( + std::shared_ptr current, + std::vector> previous) + : current_(std::move(current)), previous_(std::move(previous)) + { + if (!current_) { + throw configuration_error("A current device id resolver is required"); + } + } + + template < + typename... Previous, + typename = std::enable_if_t< + (std::is_convertible_v> && ...)>> + explicit migrating_device_id_resolver(std::shared_ptr current, Previous... previous) + : migrating_device_id_resolver( + std::move(current), + std::vector>{std::move(previous)...}) + { + } + + [[nodiscard]] std::string device_name() const override { return current_->device_name(); } + [[nodiscard]] std::string device_id() const override { return current_->device_id(); } + + [[nodiscard]] bool accepts_device_id(const std::string& device_id) const override + { + // An empty id is what a historical resolver that could not read anything + // reduces to. It must never match, or a machine with no identity would + // accept a license bound to another such machine. + if (device_id.empty()) { + return false; + } + + // A mutex and a flag rather than std::once_flag, matching + // moonbase_device_id_resolver: the allocations here can throw, and an + // exception escaping std::call_once deadlocks under ThreadSanitizer, + // whose pthread_once interceptor does not model the reset that path + // performs. The flag is set only after the work completes, so a throw + // simply means the next caller retries. + { + const std::lock_guard lock(previous_ids_mutex_); + if (!previous_ids_computed_) { + previous_ids_.reserve(previous_.size()); + for (const auto& resolver : previous_) { + if (!resolver) { + continue; + } + try { + previous_ids_.push_back(resolver->device_id()); + } catch (...) { + // Cannot vouch for the license on this machine; carry on. + } + } + previous_ids_computed_ = true; + } + } + + return std::any_of( + previous_ids_.begin(), previous_ids_.end(), [&device_id](const std::string& previous) { + return !previous.empty() && previous == device_id; + }); + } + + /// Forwarded so the current resolver stays describable through this wrapper. + [[nodiscard]] std::optional describe_device() const override + { + return current_->describe_device(); + } + +private: + std::shared_ptr current_; + std::vector> previous_; + + mutable std::mutex previous_ids_mutex_; + mutable bool previous_ids_computed_ = false; + mutable std::vector previous_ids_; +}; + +} // namespace moonbase diff --git a/include/moonbase/errors.hpp b/include/moonbase/errors.hpp index e7df001..00c58ab 100644 --- a/include/moonbase/errors.hpp +++ b/include/moonbase/errors.hpp @@ -6,6 +6,7 @@ namespace moonbase { +// New values are appended, never inserted: consumers persist and compare these. enum class error_type { api_error, license_invalid, @@ -13,6 +14,11 @@ enum class error_type { storage_error, configuration_error, operation_not_supported, + /// The license is valid but bound to a different device, or to an older + /// fingerprint version. + license_device_mismatch, + /// No stable hardware identifier could be read, so no device id exists. + device_identity_unavailable, }; class moonbase_error : public std::runtime_error { @@ -62,6 +68,86 @@ class license_invalid_error : public moonbase_error { : moonbase_error(error_type::license_invalid, message) { } + +protected: + // For subclasses that are a more specific kind of "this license is not + // usable here" and want their own error_type. + license_invalid_error(error_type type, const std::string& message) + : moonbase_error(type, message) + { + } +}; + +// The token verified, but its `sig` claim is not this device's id and no +// historical resolver recognised it. +// +// Derives from license_invalid_error deliberately. Existing `catch +// (license_invalid_error&)` sites keep working across the upgrade, and, more +// importantly, licensing's offline grace period keys off that type: a mismatch +// that escaped it would let a license copied from another machine keep running +// for the whole grace window. Code switching on type() must add the new case. +class license_device_mismatch_error : public license_invalid_error { +public: + explicit license_device_mismatch_error(const std::string& message) + : license_invalid_error(error_type::license_device_mismatch, message) + { + } +}; + +// The device fingerprint had nothing machine-specific to hash: either no +// parameter could be read, or the only ones that could are model-level (vendor, +// product and board names, shared by every unit of a product line). +// +// The spec makes both an error rather than hashing what is there, because either +// would hand a whole class of machines the *same* device id, and a license bound +// to it would then validate on all of them. Substituting the host name is nearly +// as bad: it is user-renameable, duplicated across imaged fleets, and +// regenerated on every container start. +// +// Reachable on platforms with no defined identity parameters (Android, BSD, +// anything unknown); when every source fails, such as a container with no DMI or +// a blocked firmware-table read; and on machines whose per-device identifiers are +// simply absent, such as a Linux install with no machine-id or a VM whose SMBIOS +// carries an unset UUID alongside a blank baseboard serial. Opt into the weaker +// host-name id with moonbase_device_id_resolver_options::fallback. +class insufficient_device_identity_error : public moonbase_error { +public: + explicit insufficient_device_identity_error( + std::string platform, + std::string reason = "no identity parameter could be read") + : moonbase_error( + error_type::device_identity_unavailable, + "Could not identify this device (platform: " + platform + "): " + reason), + platform_(std::move(platform)), + reason_(std::move(reason)) + { + } + + [[nodiscard]] const std::string& platform() const noexcept { return platform_; } + [[nodiscard]] const std::string& reason() const noexcept { return reason_; } + +private: + std::string platform_; + std::string reason_; +}; + +// Two fingerprint parameters shared a name, which the material grammar cannot +// express. Unreachable from the built-in readers, so it always means a +// caller-supplied parameter list is wrong: a configuration error, not a +// machine-state one. No matching error_type, because no other Moonbase SDK +// reports this on its error enum. +class duplicate_fingerprint_parameter_error : public configuration_error { +public: + explicit duplicate_fingerprint_parameter_error(std::string name) + : configuration_error("Duplicate fingerprint parameter name: " + name), + parameter_name_(std::move(name)) + { + } + + [[nodiscard]] const std::string& parameter_name() const noexcept { return parameter_name_; } + +private: + std::string parameter_name_; }; class license_expired_error : public moonbase_error { diff --git a/include/moonbase/fingerprint.hpp b/include/moonbase/fingerprint.hpp index 9d511ab..847933b 100644 --- a/include/moonbase/fingerprint.hpp +++ b/include/moonbase/fingerprint.hpp @@ -1,30 +1,30 @@ #pragma once -#include -#include +// Compatibility header. +// +// moonbase::fingerprint_provider was renamed to moonbase::device_id_resolver in +// 4.0.0, when this SDK adopted the cross-SDK device fingerprint spec. The old +// names still work, so a custom provider keeps compiling across the upgrade, but +// they are deprecated and will be removed in 5.0.0. +// +// New code should include for the interface, +// for the spec implementation, and +// for the algorithm primitives. +// +// Define MOONBASE_DISABLE_DEPRECATED_ALIASES to compile the aliases out now, +// which is the quickest way to find every remaining use in a codebase. + +#include "moonbase/device_id_resolver.hpp" namespace moonbase { -class fingerprint_provider { -public: - virtual ~fingerprint_provider() = default; - [[nodiscard]] virtual std::string device_name() const = 0; - [[nodiscard]] virtual std::string device_id() const = 0; -}; - -class static_fingerprint_provider : public fingerprint_provider { -public: - static_fingerprint_provider(std::string name, std::string id) - : name_(std::move(name)), id_(std::move(id)) - { - } - - [[nodiscard]] std::string device_name() const override { return name_; } - [[nodiscard]] std::string device_id() const override { return id_; } - -private: - std::string name_; - std::string id_; -}; +#if !defined(MOONBASE_DISABLE_DEPRECATED_ALIASES) + +using fingerprint_provider [[deprecated("renamed to moonbase::device_id_resolver")]] = device_id_resolver; + +using static_fingerprint_provider + [[deprecated("renamed to moonbase::static_device_id_resolver")]] = static_device_id_resolver; + +#endif } // namespace moonbase diff --git a/include/moonbase/fingerprint_spec.hpp b/include/moonbase/fingerprint_spec.hpp new file mode 100644 index 0000000..6f97f80 --- /dev/null +++ b/include/moonbase/fingerprint_spec.hpp @@ -0,0 +1,856 @@ +#pragma once + +// The Moonbase device fingerprint specification, version 2. +// +// See FINGERPRINT_SPEC.md at the repo root for the normative text and +// tests/vectors/fingerprint-vectors.json for the conformance suite. When the +// spec prose and the vectors disagree, the vectors win: they are what every SDK +// can actually execute. +// +// A license token carries a `sig` claim equal to the device id, and each SDK +// recomputes that id locally and compares it 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 header is byte-exact and deterministic so +// they cannot: given the same platform tag and parameters, it produces the same +// device id as @moonbase.sh/licensing does. +// +// Everything here is pure. There are no OS headers and nothing reads the +// machine, so the whole file compiles and is tested on every platform. That is +// deliberate: it means the Windows SMBIOS parser and the macOS ioreg parser are +// exercised by CI on Linux and macOS runners too, rather than only where they +// happen to run. The platform reads themselves live in +// moonbase_device_id_resolver.hpp. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +// Macros only, no OS surface: this is how Apple platforms are told apart, and +// mac and ios are separate platform tags with different identity parameters. +#include +#endif + +#include "moonbase/detail/crypto/crypto.hpp" +#include "moonbase/detail/unicode/nfc_ascii.hpp" +#include "moonbase/errors.hpp" + +namespace moonbase::fingerprint_spec { + +/// First line of the hashed material. Always carries the same version number as +/// the device id stamp. +inline constexpr std::string_view prefix = "moonbase:fingerprint:v2"; + +/// Spec version, present in both the material prefix and the id stamp. +inline constexpr int version = 2; + +/// Longest permitted canonical value, in characters. +inline constexpr std::size_t max_value_length = 128; + +/// Where a device id's identity came from, and therefore what it may be compared +/// to. Each non-hardware source carries its own stamp so the limitation travels +/// with the value. +enum class device_id_source { + /// Hardware identity. Comparable across every conforming SDK. `mbd2_` + identity, + /// The opt-in host-name fallback. `mbd2n_` + device_name, + /// Scoped identity: stable for this device within one *scope*, and not + /// comparable across scopes or SDKs. The scope is the platform's, not ours: + /// iOS scopes identifierForVendor to the App Store vendor (else the bundle id + /// minus its last component), Android scopes ANDROID_ID to the app signing + /// key. So it is narrower than "publisher": one vendor's two differently + /// signed Android apps do not share an id. `mbd2s_` + scoped, +}; + +using parameter = std::pair; +using parameter_list = std::vector; + +struct device_id_stamp { + /// Fingerprint spec version that produced the digest. + int version = 0; + /// The literal source tag: "", "n", "s", or one a newer SDK introduced. + std::string source_tag; + /// What source_tag means, or nothing when this SDK does not define that tag. + std::optional source; + /// The 64-character lowercase-hex SHA-256. + std::string digest; +}; + +namespace internal { + +// Exactly these parameters describe the individual machine. Everything else a +// platform collects is model-level: vendor, product and board names are +// byte-identical across every unit of a product line, so a material built only +// from those would give every machine of that model the same device id, and each +// would validate the others' licenses. +inline constexpr std::array identifying_params{ + "ioPlatformUuid", + "machineId", + "systemUuid", + "baseboardSerialNumber", + // Scoped sources: identifying within the platform's own scope, which is all + // these platforms allow. See the spec's "Scoped identity" section. + "identifierForVendor", + "androidId", + "deviceName", +}; + +// OEM filler that is not really a value. Compared case-insensitively against the +// canonical value, and only for identifying parameters: a descriptive field +// reading "Default string" is still a fair description of the model, whereas a +// serial number reading it is not a serial number. +inline constexpr std::array not_programmed_values{ + "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", +}; + +[[nodiscard]] inline char to_lower(char value) noexcept +{ + return (value >= 'A' && value <= 'Z') ? static_cast(value - 'A' + 'a') : value; +} + +[[nodiscard]] inline bool equals_ignoring_ascii_case(std::string_view left, std::string_view right) noexcept +{ + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0; index != left.size(); ++index) { + if (to_lower(left[index]) != to_lower(right[index])) { + return false; + } + } + return true; +} + +// The tags this version defines. The grammar a parser *accepts* is deliberately +// wider (see parse_device_id_stamp): a tag introduced by a newer SDK must still +// parse, or a perfectly valid id gets reported as "not a Moonbase device id". +// +// One table, consulted in both directions, so the stamper and the parser cannot +// drift apart. +struct source_tag_entry { + device_id_source source; + std::string_view tag; +}; + +inline constexpr std::array source_tags{{ + {device_id_source::identity, ""}, + {device_id_source::device_name, "n"}, + {device_id_source::scoped, "s"}, +}}; + +[[nodiscard]] inline std::string_view tag_for_source(device_id_source source) noexcept +{ + for (const auto& entry : source_tags) { + if (entry.source == source) { + return entry.tag; + } + } + return {}; +} + +[[nodiscard]] inline std::optional source_for_tag(std::string_view tag) noexcept +{ + for (const auto& entry : source_tags) { + if (entry.tag == tag) { + return entry.source; + } + } + return std::nullopt; +} + +[[nodiscard]] inline bool is_lowercase_hex(std::string_view value) noexcept +{ + return std::all_of(value.begin(), value.end(), [](char character) { + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'); + }); +} + +} // namespace internal + +/// Canonicalize a raw value: NFC, then printable ASCII only, then capped at 128 +/// characters, then space-trimmed at both ends. In that order. +/// +/// Interior spaces are preserved and nothing else is altered: no case folding, +/// no reordering. Truncation runs before trimming, so a value whose 128-character +/// prefix ends in spaces does not keep them. +/// +/// Dropping everything outside U+0020..U+007E does more work than it looks. It +/// makes the material grammar unambiguous, since a value can no longer contain an +/// LF and so cannot forge an extra `name=value` line. It makes the decoding of +/// raw firmware strings very nearly irrelevant, since every byte two decoders +/// would disagree about is discarded. And it absorbs the trailing newline that +/// sysfs reads carry. +[[nodiscard]] inline std::string canonicalize_value(std::string_view value) +{ + auto printable = moonbase::detail::unicode::canonicalize_printable_ascii(value); + + // Pure ASCII by now, so characters and bytes are the same thing. + if (printable.size() > max_value_length) { + printable.resize(max_value_length); + } + + const auto first = printable.find_first_not_of(' '); + if (first == std::string::npos) { + return {}; + } + const auto last = printable.find_last_not_of(' '); + return printable.substr(first, last - first + 1); +} + +/// The platform tag for the host this was compiled for. +[[nodiscard]] inline std::string_view platform_tag() noexcept +{ + // Android must be tested before Linux: it defines both. +#if defined(__ANDROID__) + return "android"; +#elif defined(__APPLE__) + // macOS and iOS are separate tags: they have entirely different identity + // parameters, and an iOS id is scoped where a macOS one is not, so conflating + // them would let two incomparable ids claim the same provenance. Every Apple + // platform other than macOS maps to `ios`, because they all offer the same + // single identifier and nothing else. + // + // Mac Catalyst counts as macOS. The spec states the rule as a runtime pair, + // because Swift's compile-time tests get it wrong, but in C++ the compile-time + // macros reproduce that table exactly: + // + // isMacCatalystApp isiOSAppOnMac running as tag macro state + // false false a real iPhone / iPad ios MACCATALYST 0 + // true false Mac Catalyst mac MACCATALYST 1 + // true true iOS app on Apple silicon ios MACCATALYST 0 + // + // A Catalyst binary is always the middle row and an iOS binary is never it, so + // TARGET_OS_MACCATALYST decides it without a runtime query. Note + // TARGET_OS_IPHONE is 1 for Catalyst too, which is why it cannot be the test. + // + // This matters because a Catalyst app can read *both* identifierForVendor and + // IOKit, so without a rule two SDKs on one Mac would disagree about which to + // use. Hardware identity wins, and the Catalyst app then agrees with an + // Electron or web SDK on the same machine. +#if TARGET_OS_OSX || TARGET_OS_MACCATALYST + return "mac"; +#else + return "ios"; +#endif +#elif defined(_WIN32) + return "windows"; +#elif defined(__linux__) + return "linux"; +#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) + return "bsd"; +#else + return "unknown"; +#endif +} + +/// The identifying parameter names, in spec order. +/// +/// Returned by value so a consumer cannot widen the set the material check +/// accepts. Handing out a reference to the table would let one line of caller +/// code turn "this machine has no identity" into "this model has an identity". +[[nodiscard]] inline std::vector identifying_param_names() +{ + return {internal::identifying_params.begin(), internal::identifying_params.end()}; +} + +[[nodiscard]] inline bool is_identifying_param(std::string_view name) noexcept +{ + return std::find(internal::identifying_params.begin(), internal::identifying_params.end(), name) + != internal::identifying_params.end(); +} + +/// Is this canonical value OEM filler rather than a real identifier? +/// +/// An identifying value that is really 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, so hashing it would hand every unit the same device id. +[[nodiscard]] inline bool is_not_programmed(std::string_view canonical_value) noexcept +{ + if (canonical_value.empty()) { + return false; + } + + // A blank UUID field or a zeroed machine-id. + const bool all_zeroes = std::all_of( + canonical_value.begin(), canonical_value.end(), [](char character) { return character == '0'; }); + const bool all_f = std::all_of(canonical_value.begin(), canonical_value.end(), [](char character) { + return character == 'f' || character == 'F'; + }); + if (all_zeroes || all_f) { + return true; + } + + return std::any_of( + internal::not_programmed_values.begin(), + internal::not_programmed_values.end(), + [canonical_value](std::string_view filler) { + return internal::equals_ignoring_ascii_case(canonical_value, filler); + }); +} + +/// Constants shared across devices that are rejected for `androidId` only. +/// +/// Keyed by parameter on purpose. The shared placeholder list applies to every +/// identifying parameter, so putting this there would change the device id of a +/// machine that happens to report the same string as, say, a baseboard serial, +/// which the spec forbids without a version bump. A value that is meaningless as +/// an androidId can be a perfectly good Windows serial. +namespace internal { + +inline constexpr std::array rejected_android_ids{ + // 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. + // Valid hex, so the format rule cannot catch it. + "9774d56d682e549c", +}; + +} // namespace internal + +/// Does this canonical value look like a real Android SSAID? +/// +/// `^[0-9a-f]{1,16}$` plus the placeholder rule. That regex is what makes the +/// classic mistake *mechanically* impossible rather than merely documented: +/// reading the `Settings.Secure.ANDROID_ID` static field yields the key name +/// "android_id", which is identical on every device and is not hex, so it can +/// never reach the material. JUCE's SystemStats::getUniqueDeviceID() has exactly +/// that defect. +/// +/// The bound is 1..16 rather than exactly 16 because AOSP before 8.0 generated the +/// value with Long.toHexString, which drops leading zeros; requiring 16 would +/// reject legitimate ids on roughly one in sixteen pre-Oreo devices. +[[nodiscard]] inline bool is_valid_android_id(std::string_view canonical_value) noexcept +{ + if (canonical_value.empty() || canonical_value.size() > 16 + || !internal::is_lowercase_hex(canonical_value) || is_not_programmed(canonical_value)) { + return false; + } + + return std::none_of( + internal::rejected_android_ids.begin(), + internal::rejected_android_ids.end(), + [canonical_value](std::string_view rejected) { + return internal::equals_ignoring_ascii_case(canonical_value, rejected); + }); +} + +/// Can this identifying value stand for the machine? +/// +/// Not an unprogrammed placeholder, and matching the format the spec pins for that +/// parameter where it pins one. Only `androidId` has a pinned format, deliberately: +/// the rule exists to make one specific, currently-shipping defect impossible +/// rather than merely documented, and every extra rule is a new way to reject a +/// value some real device legitimately reports. +[[nodiscard]] inline bool is_usable_identity(std::string_view name, std::string_view canonical_value) noexcept +{ + if (is_not_programmed(canonical_value)) { + return false; + } + if (name == "androidId") { + return is_valid_android_id(canonical_value); + } + return true; +} + +/// Canonicalize every value and drop the pairs that do not survive. +/// +/// A pair is dropped when its canonical value is empty, or when its name is +/// identifying and the value is an unprogrammed placeholder. Both filters run +/// before the duplicate check, so two entries sharing a name where one is empty +/// are not a duplicate. +/// +/// \throws duplicate_fingerprint_parameter_error when two surviving pairs share a +/// name. The material grammar cannot express it, so it is a collection bug. +[[nodiscard]] inline parameter_list canonicalize_params(const parameter_list& params) +{ + parameter_list kept; + kept.reserve(params.size()); + + for (const auto& param : params) { + // Not a structured binding: capturing one in a lambda is C++20, and this + // header has to compile as C++17 on every supported compiler. + const std::string& name = param.first; + + auto value = canonicalize_value(param.second); + if (value.empty()) { + continue; + } + if (is_identifying_param(name) && !is_usable_identity(name, value)) { + continue; + } + + const bool already_kept = std::any_of( + kept.begin(), kept.end(), [&name](const parameter& kept_param) { return kept_param.first == name; }); + if (already_kept) { + throw duplicate_fingerprint_parameter_error(name); + } + + kept.emplace_back(name, std::move(value)); + } + + return kept; +} + +/// Assemble the material to be hashed. +/// +/// Lines are **joined** by a single LF, never terminated by one: 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, and +/// the vectors check it explicitly. +/// +/// \throws insufficient_device_identity_error when nothing survives +/// canonicalization, or when nothing that survives identifies this +/// individual machine. Neither may be hashed anyway: each would hand a +/// whole class of machines the same device id. +/// \throws duplicate_fingerprint_parameter_error via canonicalize_params. +[[nodiscard]] inline std::string build_fingerprint_material( + std::string_view platform, + const parameter_list& params) +{ + const auto kept = canonicalize_params(params); + if (kept.empty()) { + throw insufficient_device_identity_error(std::string(platform)); + } + + // Enforced here rather than in the resolver so it also binds a custom reader + // and a native bridge assembling material directly. On these platforms the + // host name is a constant (since iOS 17 gethostname() returns "localhost", and + // UIDevice.name the model name), so accepting it would be worse than failing: + // one activation would validate across the whole install base. + const bool scoped_platform = platform == "ios" || platform == "android"; + const bool has_device_name = std::any_of( + kept.begin(), kept.end(), [](const parameter& param) { return param.first == "deviceName"; }); + if (scoped_platform && has_device_name) { + throw insufficient_device_identity_error( + std::string(platform), + "the host-name fallback is not available on this platform, where the host name is the" + " same on every device"); + } + + const bool identifies_this_machine = std::any_of( + kept.begin(), kept.end(), [](const parameter& param) { return is_identifying_param(param.first); }); + if (!identifies_this_machine) { + std::string names; + for (const auto& [name, value] : kept) { + if (!names.empty()) { + names += ", "; + } + names += name; + } + throw insufficient_device_identity_error( + std::string(platform), + "only model-level parameters could be read (" + names + + "), none of which identify this individual machine"); + } + + std::string material; + material.append(prefix); + material += "\nplatform="; + material.append(platform); + for (const auto& [name, value] : kept) { + material += '\n'; + material += name; + material += '='; + material += value; + } + + return material; +} + +/// Hash material into a bare digest: 64 lowercase hex characters of SHA-256. +[[nodiscard]] inline std::string fingerprint_digest(std::string_view material) +{ + return moonbase::detail::sha256_hex(material); +} + +/// Prefix a digest with its version and source, producing the wire-form device id. +[[nodiscard]] inline std::string stamp_device_id( + std::string_view digest, + device_id_source source = device_id_source::identity) +{ + std::string out = "mbd"; + out += std::to_string(version); + out.append(internal::tag_for_source(source)); + out += '_'; + out.append(digest); + return out; +} + +/// Hash material and stamp it: the device id sent to Moonbase and stored in `sig`. +[[nodiscard]] inline std::string fingerprint_device_id( + std::string_view material, + device_id_source source = device_id_source::identity) +{ + return stamp_device_id(fingerprint_digest(material), source); +} + +/// Recover the version and source from a device id, or nothing if it is not a +/// Moonbase stamp. +/// +/// Because the version is recoverable from the id, a validator can tell an +/// out-of-date SDK (the binding is newer than what it computes) from a stale +/// binding (the binding is older), and say something better than "wrong device". +/// A bare digest, a custom resolver's id, or an id from an SDK predating +/// versioned fingerprints all return nothing and are compared literally. +/// +/// Strict by design: uppercase hex, a truncated digest and a missing separator +/// all fail to parse rather than being coerced. +[[nodiscard]] inline std::optional parse_device_id_stamp(std::string_view device_id) +{ + constexpr std::string_view lead = "mbd"; + if (device_id.size() < lead.size() || device_id.substr(0, lead.size()) != lead) { + return std::nullopt; + } + + std::size_t cursor = lead.size(); + const std::size_t digits_begin = cursor; + while (cursor != device_id.size() && device_id[cursor] >= '0' && device_id[cursor] <= '9') { + ++cursor; + } + + const std::size_t digit_count = cursor - digits_begin; + // At least one digit, and few enough that the value cannot overflow an int. + // JavaScript would happily produce a float for a 40-digit version; no + // validator will ever see one, and refusing is the safer disagreement. + if (digit_count == 0 || digit_count > 9) { + return std::nullopt; + } + + // [a-z]*, not a fixed set: a tag this SDK does not define must still parse, so + // that a newer SDK can introduce one without a version bump. Digits cannot + // appear in it and '_' terminates it, so the split from the version is + // unambiguous. Uppercase is not a tag, so mbd2S_ correctly fails to parse. + const std::size_t tag_begin = cursor; + while (cursor != device_id.size() && device_id[cursor] >= 'a' && device_id[cursor] <= 'z') { + ++cursor; + } + const auto source_tag = device_id.substr(tag_begin, cursor - tag_begin); + + if (cursor == device_id.size() || device_id[cursor] != '_') { + return std::nullopt; + } + ++cursor; + + const auto digest = device_id.substr(cursor); + if (digest.size() != 64 || !internal::is_lowercase_hex(digest)) { + return std::nullopt; + } + + int parsed_version = 0; + for (std::size_t index = digits_begin; index != digits_begin + digit_count; ++index) { + parsed_version = parsed_version * 10 + (device_id[index] - '0'); + } + + return device_id_stamp{ + parsed_version, + std::string(source_tag), + internal::source_for_tag(source_tag), + std::string(digest)}; +} + +// --------------------------------------------------------------------------- +// Source parsers. Pure, so the platform readers stay thin and every one of these +// is tested on every platform. + +/// Normalize a platform UUID for the material: hyphens removed, uppercased. +[[nodiscard]] inline std::string normalize_platform_uuid(std::string_view raw) +{ + std::string out; + out.reserve(raw.size()); + for (const char character : raw) { + if (character == '-') { + continue; + } + out.push_back( + (character >= 'a' && character <= 'z') ? static_cast(character - 'a' + 'A') : character); + } + return out; +} + +/// Extract IOPlatformUUID from `ioreg -rd1 -c IOPlatformExpertDevice` output. +/// +/// Kept for consumers that already have ioreg output; the resolver itself reads +/// IOKit directly, which works inside the App Sandbox where spawning a process +/// does not. +[[nodiscard]] inline std::string parse_ioreg_platform_uuid(std::string_view ioreg_output) +{ + constexpr std::string_view key = "\"IOPlatformUUID\""; + const auto key_at = ioreg_output.find(key); + if (key_at == std::string_view::npos) { + return {}; + } + + const auto skip_spaces = [&ioreg_output](std::size_t from) { + while (from != ioreg_output.size() + && (ioreg_output[from] == ' ' || ioreg_output[from] == '\t' || ioreg_output[from] == '\r' + || ioreg_output[from] == '\n')) { + ++from; + } + return from; + }; + + auto cursor = skip_spaces(key_at + key.size()); + if (cursor == ioreg_output.size() || ioreg_output[cursor] != '=') { + return {}; + } + cursor = skip_spaces(cursor + 1); + if (cursor == ioreg_output.size() || ioreg_output[cursor] != '"') { + return {}; + } + ++cursor; + + const auto end = ioreg_output.find('"', cursor); + if (end == std::string_view::npos || end == cursor) { + return {}; + } + + return normalize_platform_uuid(ioreg_output.substr(cursor, end - cursor)); +} + +/// Does this canonical value look like a real machine-id(5)? +[[nodiscard]] inline bool is_valid_machine_id(std::string_view canonical_value) noexcept +{ + return canonical_value.size() == 32 && internal::is_lowercase_hex(canonical_value) + && !is_not_programmed(canonical_value); +} + +/// Pick the first machine-id source holding a valid id. +/// +/// Each source is validated before selection 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. +[[nodiscard]] inline std::string select_machine_id(std::initializer_list sources) +{ + for (const auto source : sources) { + auto candidate = canonicalize_value(source); + if (is_valid_machine_id(candidate)) { + return candidate; + } + } + return {}; +} + +namespace internal { + +struct smbios_structure { + unsigned char type = 0; + /// The formatted area, indexed from the structure header (byte 0 = type). + const unsigned char* formatted = nullptr; + std::size_t formatted_size = 0; + /// Resolved string table; a string-index field holding N maps to strings[N - 1]. + std::vector strings; +}; + +// Firmware string bytes are decoded as Latin-1, matching the reference SDK's +// Buffer.toString('latin1'). SMBIOS strings are nominally ASCII but OEMs ship +// worse, and canonicalization discards everything above U+007E anyway, so the +// choice is very nearly immaterial. Very nearly: it matters when firmware bytes +// happen to form a valid UTF-8 combining mark after an ASCII byte, where a UTF-8 +// decoder would let the mark annihilate that character and a Latin-1 decoder +// would not. Matching the reference removes the last way two conforming SDKs +// could disagree. +[[nodiscard]] inline std::string latin1_to_utf8(const unsigned char* data, std::size_t size) +{ + std::string out; + out.reserve(size); + for (std::size_t index = 0; index != size; ++index) { + const unsigned char byte = data[index]; + if (byte < 0x80) { + out.push_back(static_cast(byte)); + } else { + out.push_back(static_cast(0xC0U | (byte >> 6U))); + out.push_back(static_cast(0x80U | (byte & 0x3FU))); + } + } + return out; +} + +/// Walk an SMBIOS structure table (with no leading RawSMBIOSData header). +[[nodiscard]] inline std::vector parse_smbios_structures( + const unsigned char* data, + std::size_t size) +{ + std::vector structures; + std::size_t offset = 0; + + while (offset + 4 <= size) { + const unsigned char type = data[offset]; + const std::size_t length = data[offset + 1]; + // A structure shorter than its own header, or one running off the end of + // the table, means the table is malformed from here on. + if (length < 4 || offset + length > size) { + break; + } + + smbios_structure structure; + structure.type = type; + structure.formatted = data + offset; + structure.formatted_size = length; + + // The string table follows the formatted area: NUL-terminated strings + // ending in a double-NUL. A structure with no strings is just the + // double-NUL. + std::size_t cursor = offset + length; + if (cursor + 1 < size && data[cursor] == 0 && data[cursor + 1] == 0) { + cursor += 2; + } else { + while (cursor < size) { + std::size_t end = cursor; + while (end < size && data[end] != 0) { + ++end; + } + structure.strings.push_back(latin1_to_utf8(data + cursor, end - cursor)); + cursor = end + 1; + if (cursor < size && data[cursor] == 0) { + cursor += 1; + break; + } + } + } + + structures.push_back(std::move(structure)); + + if (type == 127) { // End-of-table. + break; + } + + offset = cursor; + } + + return structures; +} + +/// Resolve a string-index field. Index 0, or one past the end of the table, +/// means "no string". +[[nodiscard]] inline std::string resolve_smbios_string( + const smbios_structure& structure, + std::size_t field_offset) +{ + // Bounded 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. + if (field_offset >= structure.formatted_size) { + return {}; + } + + const std::size_t index = structure.formatted[field_offset]; + if (index == 0 || index > structure.strings.size()) { + return {}; + } + + return structure.strings[index - 1]; +} + +/// Format a 16-byte UUID field as uppercase hex. +/// +/// The raw bytes in order: no hyphens, and deliberately **not** the +/// SMBIOS-canonical little-endian swap of the first three fields. The value will +/// therefore not match what dmidecode or Win32_ComputerSystemProduct display, +/// which is intentional and specified. An SDK reading the UUID through WMI must +/// undo that swap. +[[nodiscard]] inline std::string format_smbios_uuid( + const smbios_structure& structure, + std::size_t field_offset) +{ + if (field_offset + 16 > structure.formatted_size) { + return {}; + } + + const unsigned char* bytes = structure.formatted + field_offset; + + // All-00 or all-FF means "not set", so fleets of VMs with unset UUIDs cannot + // collide on one device id. + bool all_zeroes = true; + bool all_ones = true; + for (std::size_t index = 0; index != 16; ++index) { + all_zeroes = all_zeroes && bytes[index] == 0x00; + all_ones = all_ones && bytes[index] == 0xFF; + } + if (all_zeroes || all_ones) { + return {}; + } + + static constexpr char hex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(32); + for (std::size_t index = 0; index != 16; ++index) { + out.push_back(hex[bytes[index] >> 4U]); + out.push_back(hex[bytes[index] & 0x0FU]); + } + return out; +} + +} // namespace internal + +/// Extract the Windows identity parameters from a raw SMBIOS structure table. +/// +/// Takes the first type-1 (System) and the first type-2 (Baseboard) structure; +/// later structures of the same type are ignored. 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 or +/// vCPU count, so collecting them would change the device id every time a VM is +/// resized. +/// +/// Parameters are emitted even when their value is empty. Describing the +/// firmware is this function's job; deciding what counts is canonicalization's. +[[nodiscard]] inline parameter_list parse_smbios_params(const unsigned char* data, std::size_t size) +{ + parameter_list params; + if (data == nullptr || size == 0) { + return params; + } + + const auto structures = internal::parse_smbios_structures(data, size); + const auto find_first = [&structures](unsigned char type) { + return std::find_if(structures.begin(), structures.end(), [type](const internal::smbios_structure& s) { + return s.type == type; + }); + }; + + if (const auto system = find_first(1); system != structures.end()) { + params.emplace_back("systemManufacturer", internal::resolve_smbios_string(*system, 0x04)); + params.emplace_back("systemProductName", internal::resolve_smbios_string(*system, 0x05)); + params.emplace_back("systemUuid", internal::format_smbios_uuid(*system, 0x08)); + } + + if (const auto baseboard = find_first(2); baseboard != structures.end()) { + params.emplace_back("baseboardManufacturer", internal::resolve_smbios_string(*baseboard, 0x04)); + params.emplace_back("baseboardProduct", internal::resolve_smbios_string(*baseboard, 0x05)); + params.emplace_back("baseboardSerialNumber", internal::resolve_smbios_string(*baseboard, 0x07)); + } + + return params; +} + +[[nodiscard]] inline parameter_list parse_smbios_params(const std::vector& data) +{ + return parse_smbios_params(data.data(), data.size()); +} + +} // namespace moonbase::fingerprint_spec diff --git a/include/moonbase/legacy_fingerprint.hpp b/include/moonbase/legacy_fingerprint.hpp new file mode 100644 index 0000000..db9c9c2 --- /dev/null +++ b/include/moonbase/legacy_fingerprint.hpp @@ -0,0 +1,395 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +#include "moonbase/detail/crypto/crypto.hpp" +#include "moonbase/device_id_resolver.hpp" + +namespace moonbase { + +// The device id this SDK computed before it adopted the cross-SDK fingerprint +// spec: a bare, unstamped SHA-256 over `moonbase-cpp:fingerprint:v1` material. +// +// FROZEN. Everything below is load-bearing for licenses already issued against +// it, and "fixing" any of it would break exactly the bindings this class exists +// to keep accepting. That includes the parts that are plainly wrong: +// +// * The material is LF-*terminated*, where the spec joins lines with LF. +// * Values get an ASCII trim only: no NFC, no non-ASCII filtering, no length +// cap, no placeholder rejection, no duplicate-name detection. +// * Linux reads board_serial (mode 0400, so the id depends on privilege), +// otherwise bios_* (which change on a firmware update), plus lscpu (whose +// labels are translated, so the id depends on LANG). +// * Windows collects SMBIOS type 4, whose structure count tracks the vCPU +// count, and builds the RSMB provider signature byte-reversed, so +// GetSystemFirmwareTable almost certainly returned 0 and every Windows +// device id fell through to the host-name branch in device_id(). +// * An empty parameter set silently hashes the host name, handing every +// unidentifiable machine on a platform one shared id. +// +// Use it only as a historical resolver inside a migrating_device_id_resolver, so +// existing licenses keep validating while new activations bind the spec id. See +// "Migrating from 3.x" in the README. +class legacy_cpp_device_id_resolver : public device_id_resolver { +public: + using identity_parameter = std::pair; + + [[nodiscard]] static std::string platform_tag() + { +#if defined(__APPLE__) + return "mac"; +#elif defined(_WIN32) + return "windows"; +#elif defined(__ANDROID__) + return "android"; +#elif defined(__linux__) + return "linux"; +#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) + return "bsd"; +#else + return "unknown"; +#endif + } + + [[nodiscard]] static std::string hash_identity_parameters( + const std::vector& parameters, + std::string_view platform = platform_tag()) + { + std::string material; + material += "moonbase-cpp:fingerprint:v1\n"; + material += "platform="; + material.append(platform.data(), platform.size()); + material += "\n"; + + for (const auto& parameter : parameters) { + auto name = trim_ascii(parameter.first); + auto value = trim_ascii(parameter.second); + if (!name.empty() && !value.empty()) { + material += name; + material += "="; + material += value; + material += "\n"; + } + } + + return detail::sha256_hex(material); + } + + [[nodiscard]] static std::vector identity_parameters() + { + std::vector parameters; + +#if defined(_WIN32) + append_windows_identity_parameters(parameters); +#elif defined(__APPLE__) + auto uuid = trim_ascii(command_output( + "ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | " + "awk -F\\\" '/IOPlatformUUID/{print $4; exit}'")); + uuid.erase(std::remove(uuid.begin(), uuid.end(), '-'), uuid.end()); + append_parameter(parameters, "ioPlatformUuid", uuid); +#elif defined(__linux__) && !defined(__ANDROID__) + const auto board_serial = trim_ascii(read_file("/sys/class/dmi/id/board_serial")); + if (!board_serial.empty()) { + append_parameter(parameters, "boardSerial", board_serial); + } else { + append_parameter(parameters, "biosDate", read_file("/sys/class/dmi/id/bios_date")); + append_parameter(parameters, "biosRelease", read_file("/sys/class/dmi/id/bios_release")); + append_parameter(parameters, "biosVendor", read_file("/sys/class/dmi/id/bios_vendor")); + append_parameter(parameters, "biosVersion", read_file("/sys/class/dmi/id/bios_version")); + } + + const auto cpu_data = command_output("lscpu 2>/dev/null"); + if (!cpu_data.empty()) { + append_parameter(parameters, "cpuFamily", linux_cpu_field(cpu_data, "CPU family:")); + append_parameter(parameters, "cpuModel", linux_cpu_field(cpu_data, "Model:")); + append_parameter(parameters, "cpuModelName", linux_cpu_field(cpu_data, "Model name:")); + append_parameter(parameters, "cpuVendor", linux_cpu_field(cpu_data, "Vendor ID:")); + } +#endif + + return parameters; + } + + [[nodiscard]] std::string device_name() const override + { +#if defined(_WIN32) + char buffer[128]{}; + DWORD size = static_cast(sizeof(buffer)) - 1; + if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer, &size)) { + return std::string(buffer, size); + } + return {}; +#else + std::array buffer{}; + if (gethostname(buffer.data(), buffer.size() - 1) == 0) { + auto name = std::string(buffer.data()); +#if defined(__APPLE__) + const auto suffix = std::string(".local"); + if (name.size() >= suffix.size()) { + auto tail = name.substr(name.size() - suffix.size()); + std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (tail == suffix) { + name.erase(name.size() - suffix.size()); + } + } +#endif + return name; + } + return {}; +#endif + } + + [[nodiscard]] std::string device_id() const override + { + auto parameters = identity_parameters(); + if (parameters.empty()) { + append_parameter(parameters, "deviceName", device_name()); + } + return hash_identity_parameters(parameters); + } + +private: + [[nodiscard]] static std::string read_file(const std::string& path) + { + std::ifstream file(path); + if (!file) { + return {}; + } + std::ostringstream out; + out << file.rdbuf(); + return out.str(); + } + + [[nodiscard]] static std::string command_output(const std::string& command) + { +#if defined(_WIN32) + (void)command; + return {}; +#else + std::array buffer{}; + std::string result; + std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); + if (!pipe) { + return {}; + } + while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { + result += buffer.data(); + } + return result; +#endif + } + + [[nodiscard]] static std::string trim_ascii(std::string value) + { + while (!value.empty() && + (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { + value.pop_back(); + } + while (!value.empty() && + (value.front() == '\n' || value.front() == '\r' || value.front() == ' ' || value.front() == '\t')) { + value.erase(value.begin()); + } + return value; + } + + [[nodiscard]] static std::string linux_cpu_field(const std::string& lscpu_output, const std::string& key) + { + const auto key_index = lscpu_output.find(key); + if (key_index == std::string::npos) { + return {}; + } + + const auto colon = lscpu_output.find(':', key_index); + if (colon == std::string::npos) { + return {}; + } + + const auto end = lscpu_output.find('\n', colon); + return trim_ascii(lscpu_output.substr( + colon + 1, + end == std::string::npos ? std::string::npos : end - colon - 1)); + } + + static void append_parameter( + std::vector& parameters, + std::string name, + std::string value) + { + name = trim_ascii(std::move(name)); + value = trim_ascii(std::move(value)); + if (!name.empty() && !value.empty()) { + parameters.emplace_back(std::move(name), std::move(value)); + } + } + +#if defined(_WIN32) + [[nodiscard]] static std::string windows_string_from_offset( + const std::vector& content, + const std::vector& strings, + std::size_t byte_offset) + { + if (byte_offset >= content.size()) { + return {}; + } + + const auto index = static_cast(content[byte_offset]); + if (index == 0 || index > strings.size()) { + return {}; + } + + return std::string(strings[index - 1]); + } + + [[nodiscard]] static std::size_t windows_bounded_string_length(const char* value, std::size_t max_length) + { + std::size_t length = 0; + while (length < max_length && value[length] != '\0') { + ++length; + } + return length; + } + + static void append_windows_identity_parameters(std::vector& parameters) + { + constexpr DWORD signature = + static_cast('R') | + (static_cast('S') << 8U) | + (static_cast('M') << 16U) | + (static_cast('B') << 24U); + + const auto table_size = GetSystemFirmwareTable(signature, 0, nullptr, 0); + if (table_size == 0) { + return; + } + + std::vector smbios(table_size); + if (GetSystemFirmwareTable(signature, 0, smbios.data(), table_size) != table_size) { + return; + } + + struct raw_smbios_data { + std::uint8_t unused[4]; + std::uint32_t length; + }; + + struct smbios_header { + std::uint8_t id; + std::uint8_t length; + std::uint16_t handle; + }; + + if (smbios.size() < sizeof(raw_smbios_data)) { + return; + } + + raw_smbios_data raw{}; + std::memcpy(&raw, smbios.data(), sizeof(raw)); + if (smbios.size() < sizeof(raw_smbios_data) + raw.length) { + return; + } + + std::vector content( + smbios.begin() + static_cast(sizeof(raw_smbios_data)), + smbios.begin() + static_cast(sizeof(raw_smbios_data) + raw.length)); + + std::size_t offset = 0; + while (offset < content.size()) { + if (content.size() - offset < sizeof(smbios_header)) { + break; + } + + smbios_header header{}; + std::memcpy(&header, content.data() + offset, sizeof(header)); + if (header.length == 0 || content.size() - offset < header.length) { + break; + } + + std::vector strings; + auto string_offset = offset + header.length; + while (string_offset < content.size()) { + const auto* str = reinterpret_cast(content.data() + string_offset); + const auto max_length = content.size() - string_offset; + const auto length = windows_bounded_string_length(str, max_length); + if (length == 0) { + break; + } + strings.emplace_back(str, length); + string_offset += std::min(length + 1, max_length); + } + + const auto end_of_table = std::min( + content.size(), + std::max(offset + static_cast(header.length) + 2, string_offset + 1)); + + const auto from_offset = [&](std::size_t byte_offset) { + return windows_string_from_offset(content, strings, offset + byte_offset); + }; + + switch (header.id) { + case 1: { + append_parameter(parameters, "systemManufacturer", from_offset(0x04)); + append_parameter(parameters, "systemProductName", from_offset(0x05)); + + if (offset + 0x08 + 16 <= content.size()) { + std::ostringstream hex; + hex << std::uppercase << std::hex << std::setfill('0'); + for (std::size_t index = 0; index != 16; ++index) { + hex << std::setw(2) << static_cast(content[offset + 0x08 + index]); + } + append_parameter(parameters, "systemUuid", hex.str()); + } + break; + } + + case 2: + append_parameter(parameters, "baseboardManufacturer", from_offset(0x04)); + append_parameter(parameters, "baseboardProduct", from_offset(0x05)); + append_parameter(parameters, "baseboardVersion", from_offset(0x06)); + append_parameter(parameters, "baseboardSerialNumber", from_offset(0x07)); + append_parameter(parameters, "baseboardAssetTag", from_offset(0x08)); + break; + + case 4: + append_parameter(parameters, "processorManufacturer", from_offset(0x07)); + append_parameter(parameters, "processorVersion", from_offset(0x10)); + append_parameter(parameters, "processorAssetTag", from_offset(0x21)); + append_parameter(parameters, "processorPartNumber", from_offset(0x22)); + break; + + default: + break; + } + + offset = end_of_table; + } + } +#endif +}; + +} // namespace moonbase diff --git a/include/moonbase/licensing.hpp b/include/moonbase/licensing.hpp index 37fc0a6..ea2306d 100644 --- a/include/moonbase/licensing.hpp +++ b/include/moonbase/licensing.hpp @@ -9,6 +9,13 @@ #include #include "moonbase/client.hpp" +// Before 4.0.0 this header pulled in fingerprint.hpp and default_fingerprint.hpp, +// so a consumer including only could name +// fingerprint_provider, static_fingerprint_provider and +// default_fingerprint_provider. Those aliases are deprecated, not gone, so keep +// providing them here until they are removed in 5.0.0: dropping the includes would +// turn a documented deprecation warning into a hard compile error for exactly the +// consumers the aliases exist to protect. #include "moonbase/default_fingerprint.hpp" #include "moonbase/detail/base64.hpp" #include "moonbase/errors.hpp" @@ -17,6 +24,7 @@ #ifndef MOONBASE_DISABLE_CURL_TRANSPORT #include "moonbase/http_curl.hpp" #endif +#include "moonbase/moonbase_device_id_resolver.hpp" #include "moonbase/store.hpp" #include "moonbase/types.hpp" #include "moonbase/validator.hpp" @@ -28,18 +36,18 @@ class licensing { explicit licensing( licensing_options options, std::shared_ptr store = nullptr, - std::shared_ptr fingerprints = nullptr, + std::shared_ptr device_ids = nullptr, std::shared_ptr transport = nullptr) : options_(normalize_and_validate(std::move(options))), store_(std::move(store)), - fingerprints_(std::move(fingerprints)), + device_ids_(std::move(device_ids)), transport_(std::move(transport)) { if (!store_) { store_ = std::make_shared(); } - if (!fingerprints_) { - fingerprints_ = std::make_shared(); + if (!device_ids_) { + device_ids_ = std::make_shared(); } if (!transport_) { #ifdef MOONBASE_DISABLE_CURL_TRANSPORT @@ -49,8 +57,8 @@ class licensing { transport_ = std::make_shared(); #endif } - validator_ = std::make_shared(options_, fingerprints_); - client_ = std::make_shared(options_, fingerprints_, validator_, transport_); + validator_ = std::make_shared(options_, device_ids_); + client_ = std::make_shared(options_, device_ids_, validator_, transport_); } [[nodiscard]] activation_request request_activation() const @@ -85,8 +93,8 @@ class licensing { [[nodiscard]] std::string generate_device_token() const { const nlohmann::json payload{ - {"id", fingerprints_->device_id()}, - {"name", fingerprints_->device_name()}, + {"id", device_ids_->device_id()}, + {"name", device_ids_->device_name()}, {"productId", options_.product_id}, // The Moonbase API expects this to always be "JWT". {"format", "JWT"}, @@ -240,8 +248,33 @@ class licensing { [[nodiscard]] license_validator& validator() noexcept { return *validator_; } [[nodiscard]] const license_validator& validator() const noexcept { return *validator_; } - [[nodiscard]] moonbase::fingerprint_provider& fingerprint() noexcept { return *fingerprints_; } - [[nodiscard]] const moonbase::fingerprint_provider& fingerprint() const noexcept { return *fingerprints_; } + [[nodiscard]] moonbase::device_id_resolver& device_resolver() noexcept { return *device_ids_; } + [[nodiscard]] const moonbase::device_id_resolver& device_resolver() const noexcept { return *device_ids_; } + + // How this machine's device id was derived, for diagnostics. Names of the + // contributing identity parameters only, never their values. Empty when the + // resolver does not describe itself, as a custom one need not. + // + // Throws insufficient_device_identity_error when this machine has no readable + // identity, the same as device_resolver().device_id() would. + [[nodiscard]] std::optional describe_device() const + { + return device_ids_->describe_device(); + } + +#if !defined(MOONBASE_DISABLE_DEPRECATED_ALIASES) + [[deprecated("renamed to device_resolver()")]] [[nodiscard]] moonbase::device_id_resolver& + fingerprint() noexcept + { + return *device_ids_; + } + + [[deprecated("renamed to device_resolver()")]] [[nodiscard]] const moonbase::device_id_resolver& + fingerprint() const noexcept + { + return *device_ids_; + } +#endif private: static licensing_options normalize_and_validate(licensing_options options) @@ -261,7 +294,7 @@ class licensing { licensing_options options_; std::shared_ptr store_; - std::shared_ptr fingerprints_; + std::shared_ptr device_ids_; std::shared_ptr transport_; std::shared_ptr validator_; std::shared_ptr client_; diff --git a/include/moonbase/moonbase.hpp b/include/moonbase/moonbase.hpp index f46a6c4..b8fa50f 100644 --- a/include/moonbase/moonbase.hpp +++ b/include/moonbase/moonbase.hpp @@ -1,10 +1,18 @@ #pragma once #include "moonbase/client.hpp" +// default_fingerprint.hpp and fingerprint.hpp are the deprecated-alias headers +// for the pre-4.0.0 names. Kept in the umbrella so that including +// still compiles code written against 3.x; the aliases +// only warn where they are actually used. #include "moonbase/default_fingerprint.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint.hpp" +#include "moonbase/fingerprint_spec.hpp" #include "moonbase/http.hpp" +#include "moonbase/legacy_fingerprint.hpp" +#include "moonbase/moonbase_device_id_resolver.hpp" #ifndef MOONBASE_DISABLE_CURL_TRANSPORT #include "moonbase/http_curl.hpp" #endif diff --git a/include/moonbase/moonbase_device_id_resolver.hpp b/include/moonbase/moonbase_device_id_resolver.hpp new file mode 100644 index 0000000..3e97495 --- /dev/null +++ b/include/moonbase/moonbase_device_id_resolver.hpp @@ -0,0 +1,574 @@ +#pragma once + +// The default device id resolver: the Moonbase device fingerprint spec, v2. +// +// Builds the `moonbase:fingerprint:v2` material from native hardware +// identifiers (IOPlatformUUID via IOKit on macOS, machine-id plus world-readable +// DMI on Linux, SMBIOS on Windows) and stamps its SHA-256 as `mbd2_`. Every +// Moonbase SDK that implements the spec produces the same id on a given machine, +// so a license activated by one validates in the others. +// +// No subprocess is spawned on any platform, and no root-only file is read. That +// matters for plugins: IOKit works inside the App Sandbox and under a hardened +// runtime, where spawning `ioreg` does not, and it keeps the device id +// independent of whether the process happens to run elevated. +// +// The algorithm itself lives in fingerprint_spec.hpp, which has no OS headers +// and is tested on every platform. This header is only the reads. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +#if defined(__APPLE__) +#include +#if TARGET_OS_IPHONE && !TARGET_OS_MACCATALYST +// identifierForVendor lives in UIKit, but reaching it needs no Objective-C source +// and no framework wrapper: the Objective-C runtime's C API is callable straight +// from C++, which keeps this SDK usable without JUCE or any other framework. +#define MOONBASE_FINGERPRINT_USE_UIKIT 1 +#include +#include +#endif +#endif + +#if defined(__ANDROID__) +// Plain JNI, part of the NDK rather than any framework. The one thing a native +// library cannot obtain by itself is the application Context, so the host hands +// that in once; see moonbase::android::set_jni_environment below. +#include +#endif + +#if defined(__APPLE__) && !defined(MOONBASE_FINGERPRINT_NO_IOKIT) +#include +// Mac Catalyst included: it runs on macOS, can read IOKit, and takes the `mac` +// platform tag, so it must use hardware identity to agree with an Electron or web +// SDK on the same machine. TARGET_OS_IPHONE is 1 for Catalyst, so it cannot be the +// test; see platform_tag() in fingerprint_spec.hpp for the full rule. +#if ((defined(TARGET_OS_OSX) && TARGET_OS_OSX) \ + || (defined(TARGET_OS_MACCATALYST) && TARGET_OS_MACCATALYST)) \ + && !defined(MOONBASE_FINGERPRINT_NO_IOKIT) +#define MOONBASE_FINGERPRINT_USE_IOKIT 1 +#include +#include +#include +#endif +#endif + +#include "moonbase/device_id_resolver.hpp" +#include "moonbase/errors.hpp" +#include "moonbase/fingerprint_spec.hpp" + +namespace moonbase { + +#if defined(__ANDROID__) +namespace android { + +/// The JNI handles the Android device id reader needs. +/// +/// Everything else in this SDK reads the machine on its own. Android is the one +/// exception, and not for want of trying: Settings.Secure.getString needs a +/// ContentResolver, which needs an application Context, and there is no supported +/// way for a native library to obtain one by itself. So the host hands it in once +/// and the SDK does the rest, rather than this SDK depending on a framework. +struct jni_handles { + JavaVM* vm = nullptr; + jobject context = nullptr; +}; + +namespace detail { + +inline jni_handles& mutable_jni_environment() +{ + static jni_handles handles; + return handles; +} + +[[nodiscard]] inline jni_handles jni_environment() +{ + return mutable_jni_environment(); +} + +} // namespace detail + +/// Supply the JNI handles, once, during startup. Typically from JNI_OnLoad: +/// +/// jint JNI_OnLoad(JavaVM* vm, void*) { +/// moonbase::android::set_jni_environment(vm, applicationContext); +/// return JNI_VERSION_1_6; +/// } +/// +/// The JUCE module does this for you. Until it is called, Android resolves to +/// insufficient_device_identity_error rather than to a constant, which is the +/// honest answer for a machine the SDK cannot identify. +/// +/// `context` must outlive the SDK, so pass a global reference or the Application +/// object rather than an Activity. +inline void set_jni_environment(JavaVM* vm, jobject context) +{ + detail::mutable_jni_environment() = jni_handles{vm, context}; +} + +} // namespace android +#endif + + +/// What a single read of the machine produced. +struct device_identity { + fingerprint_spec::parameter_list params; + std::string device_name; +}; + +using device_identity_reader = std::function; + +/// What to do when no hardware identity is readable. +enum class device_id_fallback { + /// Throw insufficient_device_identity_error. The default. + none, + /// Hash the host name instead, producing a deliberately weaker id stamped + /// `mbd2n_`. Opt-in, because a host name is user-renameable, frequently + /// duplicated across imaged machines, and regenerated on every container start. + /// + /// **Ignored on iOS and Android**, which throw regardless: there the host name + /// is identical on every device (since iOS 17 gethostname() returns + /// "localhost", and UIDevice.name the model name), so the fallback would give a + /// whole install base one id rather than merely a weak one. The refusal lives + /// in build_fingerprint_material, so it binds a custom reader and a native + /// bridge assembling material directly, not just this resolver. + device_name, +}; + +struct moonbase_device_id_resolver_options { + device_id_fallback fallback = device_id_fallback::none; + /// Overrides the identity source. Primarily for testing. + device_identity_reader reader; + /// Overrides the detected platform tag. Primarily for testing. + std::string platform; +}; + +class moonbase_device_id_resolver : public device_id_resolver { +public: + explicit moonbase_device_id_resolver(moonbase_device_id_resolver_options options = {}) + : options_(std::move(options)) + { + if (options_.platform.empty()) { + options_.platform = std::string(fingerprint_spec::platform_tag()); + } + } + + /// The host name, with a trailing ".local" removed on macOS. + /// + /// Never throws: a machine with no readable identity still has to be able to + /// label itself, since activation sends the name alongside the id. + [[nodiscard]] std::string device_name() const override { return identity().device_name; } + + /// \throws insufficient_device_identity_error when nothing identifies this + /// machine and the host-name fallback is not enabled. + [[nodiscard]] std::string device_id() const override { return description().device_id; } + + /// \throws insufficient_device_identity_error, as device_id() does. + [[nodiscard]] std::optional describe_device() const override + { + // By value, so a caller that edits a diagnostic (or logs it through + // something that normalizes in place) cannot change the id every later + // call returns. + return description(); + } + + // ------------------------------------------------------------------ + // Host reads, exposed so a consumer can inspect what this machine offers + // without going through the resolver's memoization. + + [[nodiscard]] static device_identity read_host_identity() + { + device_identity identity; + identity.device_name = read_host_name(); + +#if defined(MOONBASE_FINGERPRINT_USE_IOKIT) + identity.params.emplace_back("ioPlatformUuid", read_io_platform_uuid()); +#elif defined(_WIN32) + identity.params = fingerprint_spec::parse_smbios_params(read_windows_smbios_table()); +#elif defined(__ANDROID__) + identity.params.emplace_back("androidId", read_android_id()); +#elif defined(MOONBASE_FINGERPRINT_USE_UIKIT) + identity.params.emplace_back("identifierForVendor", read_identifier_for_vendor()); +#elif defined(__linux__) + // All five sources are world-readable files, so the result does not + // depend on privilege, on any installed CLI, or on the locale. + const auto etc_machine_id = read_file("/etc/machine-id"); + const auto dbus_machine_id = read_file("/var/lib/dbus/machine-id"); + + identity.params.emplace_back( + "machineId", fingerprint_spec::select_machine_id({etc_machine_id, dbus_machine_id})); + identity.params.emplace_back("sysVendor", read_file("/sys/class/dmi/id/sys_vendor")); + identity.params.emplace_back("productName", read_file("/sys/class/dmi/id/product_name")); + identity.params.emplace_back("boardVendor", read_file("/sys/class/dmi/id/board_vendor")); + identity.params.emplace_back("boardName", read_file("/sys/class/dmi/id/board_name")); +#endif + + return identity; + } + + [[nodiscard]] static std::string read_host_name() + { +#if defined(_WIN32) + std::array buffer{}; + auto size = static_cast(buffer.size()) - 1; + if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { + return std::string(buffer.data(), size); + } + return {}; +#else + std::array buffer{}; + if (gethostname(buffer.data(), buffer.size() - 1) != 0) { + return {}; + } + std::string name(buffer.data()); + +#if defined(__APPLE__) + constexpr std::string_view suffix = ".local"; + if (name.size() >= suffix.size()) { + auto tail = name.substr(name.size() - suffix.size()); + std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + if (tail == suffix) { + name.erase(name.size() - suffix.size()); + } + } +#endif + return name; +#endif + } + +private: + // Both memos are a mutex plus a flag rather than std::once_flag, and that is + // deliberate: description() lets insufficient_device_identity_error escape so + // an unreadable machine retries instead of caching the failure, and + // std::call_once is a bad place to throw from. libstdc++ implements it on + // pthread_once, and ThreadSanitizer's pthread_once interceptor does not model + // the reset that the exception path performs, so the *second* call deadlocks. + // The plain mutex gives the same semantics with none of that: the flag is only + // set after the value is stored, so a throw leaves it false and the next + // caller tries again. + // + // Returning a reference is safe because neither value is ever mutated once its + // flag is set, and the lock establishes the happens-before edge for the reader. + + [[nodiscard]] const device_identity& identity() const + { + // Read at most once. Both halves of an activation request ask for it, the + // name and then the id, and the validator asks for the id on every single + // token check. Reading per call would also let the name and the id come + // from two different reads of the machine. + const std::lock_guard lock(identity_mutex_); + if (!identity_read_) { + try { + identity_ = options_.reader ? options_.reader() : read_host_identity(); + } catch (...) { + // Reads are best-effort. An unreadable machine is insufficient + // identity, which device_id() reports, not an exception thrown + // out of device_name(). + identity_ = device_identity{}; + } + identity_read_ = true; + } + return identity_; + } + + [[nodiscard]] const device_id_description& description() const + { + // A machine that is momentarily unreadable retries on the next call rather + // than caching the failure: describe() throws, described_ stays false, and + // the guard releases the lock on the way out. A sticky failure would + // outlive the condition that caused it. + const std::lock_guard lock(description_mutex_); + if (!described_) { + const auto& read = identity(); + try { + description_ = describe(read.params, fingerprint_spec::device_id_source::identity); + } catch (const insufficient_device_identity_error&) { + if (options_.fallback != device_id_fallback::device_name) { + throw; + } + description_ = describe( + {{"deviceName", read.device_name}}, fingerprint_spec::device_id_source::device_name); + } + described_ = true; + } + return description_; + } + + [[nodiscard]] device_id_description describe( + const fingerprint_spec::parameter_list& params, + fingerprint_spec::device_id_source source) const + { + const auto material = fingerprint_spec::build_fingerprint_material(options_.platform, params); + + device_id_description described; + described.device_id = fingerprint_spec::fingerprint_device_id(material, source); + described.version = fingerprint_spec::version; + described.platform = options_.platform; + described.source = source; + for (const auto& param : fingerprint_spec::canonicalize_params(params)) { + described.param_names.push_back(param.first); + } + return described; + } + + [[nodiscard]] static std::string read_file(const char* path) + { + std::ifstream file(path, std::ios::binary); + if (!file) { + return {}; + } + std::ostringstream out; + out << file.rdbuf(); + return out.str(); + } + +#if defined(MOONBASE_FINGERPRINT_USE_IOKIT) + [[nodiscard]] static std::string read_io_platform_uuid() + { + // MACH_PORT_NULL rather than kIOMainPortDefault or kIOMasterPortDefault: + // both constants are defined as MACH_PORT_NULL, but the first only exists + // in the macOS 12+ SDK and the second is deprecated there, so naming + // either breaks somebody's -Werror build. Passing MACH_PORT_NULL selects + // the default port and compiles against every SDK. + const io_service_t service = + IOServiceGetMatchingService(MACH_PORT_NULL, IOServiceMatching("IOPlatformExpertDevice")); + if (service == IO_OBJECT_NULL) { + return {}; + } + + const CFTypeRef property = IORegistryEntryCreateCFProperty( + service, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0); + IOObjectRelease(service); + + if (property == nullptr) { + return {}; + } + + std::string uuid; + if (CFGetTypeID(property) == CFStringGetTypeID()) { + std::array buffer{}; + if (CFStringGetCString( + static_cast(property), + buffer.data(), + static_cast(buffer.size()), + kCFStringEncodingUTF8)) { + uuid = buffer.data(); + } + } + CFRelease(property); + + // Spec: all hyphens removed and uppercased. + return fingerprint_spec::normalize_platform_uuid(uuid); + } +#endif + +#if defined(MOONBASE_FINGERPRINT_USE_UIKIT) + /// identifierForVendor, uppercased with hyphens removed like ioPlatformUuid. + /// + /// Reached through the Objective-C runtime's C API rather than Objective-C + /// source, so this header stays plain C++ and this SDK needs no framework to + /// fingerprint an iOS device. Empty when iOS declines to provide one, which it + /// does until the device is first unlocked after boot; absence is transient and + /// surfaces as insufficient identity rather than as a constant. + [[nodiscard]] static std::string read_identifier_for_vendor() + { + using send_id = id (*)(id, SEL); + using send_cstr = const char* (*)(id, SEL); + const auto msg_id = reinterpret_cast(objc_msgSend); + const auto msg_cstr = reinterpret_cast(objc_msgSend); + + // UIDevice everywhere except watchOS, which exposes the same property on + // WKInterfaceDevice. + Class device_class = objc_getClass("UIDevice"); + if (device_class == nullptr) { + device_class = objc_getClass("WKInterfaceDevice"); + } + if (device_class == nullptr) { + return {}; + } + + id device = msg_id(reinterpret_cast(device_class), sel_registerName("currentDevice")); + if (device == nullptr) { + return {}; + } + + id uuid = msg_id(device, sel_registerName("identifierForVendor")); + if (uuid == nullptr) { + return {}; + } + + id text = msg_id(uuid, sel_registerName("UUIDString")); + if (text == nullptr) { + return {}; + } + + const char* utf8 = msg_cstr(text, sel_registerName("UTF8String")); + if (utf8 == nullptr) { + return {}; + } + + return fingerprint_spec::normalize_platform_uuid(utf8); + } +#endif + +#if defined(__ANDROID__) + /// Settings.Secure.getString(contentResolver, ANDROID_ID), lowercased. + /// + /// Plain JNI, so this needs no framework either. Deliberately not the static + /// field Settings.Secure.ANDROID_ID: that is the key name "android_id", + /// identical on every device, and hashing it would give a whole install base + /// one device id. JUCE's SystemStats::getUniqueDeviceID() has exactly that + /// defect. The spec's ^[0-9a-f]{1,16}$ rule makes the mistake mechanically + /// impossible here regardless. + /// + /// Empty until the host supplies JNI handles (see + /// moonbase::android::set_jni_environment), and empty when Android returns + /// null, which it can before the user is set up. + [[nodiscard]] static std::string read_android_id() + { + const auto jni = android::detail::jni_environment(); + if (jni.vm == nullptr || jni.context == nullptr) { + return {}; + } + + JNIEnv* env = nullptr; + if (jni.vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK + || env == nullptr) { + return {}; + } + + const auto fail = [env] { + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + return std::string{}; + }; + + jclass secure = env->FindClass("android/provider/Settings$Secure"); + if (secure == nullptr) { + return fail(); + } + + const auto get_string = env->GetStaticMethodID( + secure, + "getString", + "(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;"); + if (get_string == nullptr) { + return fail(); + } + + const auto get_resolver = env->GetMethodID( + env->GetObjectClass(jni.context), "getContentResolver", "()Landroid/content/ContentResolver;"); + if (get_resolver == nullptr) { + return fail(); + } + + jobject resolver = env->CallObjectMethod(jni.context, get_resolver); + if (resolver == nullptr) { + return fail(); + } + + jstring key = env->NewStringUTF("android_id"); + auto value = static_cast( + env->CallStaticObjectMethod(secure, get_string, resolver, key)); + if (env->ExceptionCheck() || value == nullptr) { + return fail(); + } + + const char* utf8 = env->GetStringUTFChars(value, nullptr); + std::string out = utf8 != nullptr ? utf8 : ""; + if (utf8 != nullptr) { + env->ReleaseStringUTFChars(value, utf8); + } + + // Lowercased per the spec; the ^[0-9a-f]{1,16}$ rule is case-sensitive. + for (auto& character : out) { + character = (character >= 'A' && character <= 'Z') + ? static_cast(character - 'A' + 'a') + : character; + } + return out; + } +#endif + +#if defined(_WIN32) + [[nodiscard]] static std::vector read_windows_smbios_table() + { + // 'RSMB', the raw SMBIOS firmware table provider. + // + // Spelled out rather than written as the multi-character literal 'RSMB', + // which is implementation-defined and warns under -Wmultichar. Note the + // byte order: MSDN documents "this identifier is little endian, you must + // reverse the characters" for the *FirmwareTableID* parameter, not for + // the provider signature, and its own sample passes 'RSMB' unreversed. + // Reversing it here yields 0x424D5352, which no provider matches, so the + // call returns 0 and the whole SMBIOS path silently disappears. + constexpr DWORD rsmb = 0x52534D42; + + const DWORD size = GetSystemFirmwareTable(rsmb, 0, nullptr, 0); + if (size == 0) { + return {}; + } + + std::vector raw(size); + const DWORD written = GetSystemFirmwareTable(rsmb, 0, raw.data(), size); + if (written == 0 || written > size) { + return {}; + } + raw.resize(written); + + // Skip the RawSMBIOSData header (Used20CallingMethod, three version + // bytes, then a DWORD Length); parsing starts at the first structure. + // WMI's SMBiosData already excludes this header. + constexpr std::size_t header_size = 8; + if (raw.size() <= header_size) { + return {}; + } + + std::uint32_t declared_length = 0; + std::memcpy(&declared_length, raw.data() + 4, sizeof(declared_length)); + + const std::size_t available = raw.size() - header_size; + const auto table_size = std::min(static_cast(declared_length), available); + + return std::vector( + raw.begin() + static_cast(header_size), + raw.begin() + static_cast(header_size + table_size)); + } +#endif + + moonbase_device_id_resolver_options options_; + + mutable std::mutex identity_mutex_; + mutable bool identity_read_ = false; + mutable device_identity identity_; + mutable std::mutex description_mutex_; + mutable bool described_ = false; + mutable device_id_description description_; +}; + +} // namespace moonbase diff --git a/include/moonbase/validator.hpp b/include/moonbase/validator.hpp index 6854581..c4d88b6 100644 --- a/include/moonbase/validator.hpp +++ b/include/moonbase/validator.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -16,8 +17,8 @@ #include "moonbase/detail/base64.hpp" #include "moonbase/detail/crypto/crypto.hpp" #include "moonbase/detail/time.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" -#include "moonbase/fingerprint.hpp" #include "moonbase/types.hpp" namespace moonbase { @@ -183,16 +184,138 @@ inline nlohmann::json object_claim_or_empty(const nlohmann::json& payload, const return payload.at(key); } +// A factual note when the binding's stamp differs from what this SDK computes, or +// empty when it does not. +// +// Deliberately conditional about the machine. Reaching this point means nothing +// reproduced the bound id: a migrating_device_id_resolver, the only thing that +// could prove continuity, has already declined or was never configured. So the +// stamp says only which algorithm created the binding, never that it was *this* +// machine, because a token copied from another computer carries exactly the same +// relationship. +[[nodiscard]] inline std::string describe_version_difference( + const fingerprint_spec::device_id_stamp& expected, + const std::optional& bound) +{ + const auto expected_version = std::to_string(expected.version); + + // Direction matters. An older binding may need migrating; a newer one means + // this SDK is behind, and re-activating would rebind the device to an + // algorithm the issuing SDK has already moved on from. + if (bound && bound->version > expected.version) { + return "The binding was created by device fingerprint v" + std::to_string(bound->version) + + ", which is newer than the v" + expected_version + " this SDK computes." + + " Update the SDK rather than re-activating, which would rebind the device to the" + " older algorithm."; + } + + const auto bound_version = bound + ? "device fingerprint v" + std::to_string(bound->version) + : std::string("an SDK predating versioned device fingerprints"); + + return "The binding was created by " + bound_version + ", while this SDK computes v" + + expected_version + ", so this may instead be the same machine bound under the older" + " algorithm. Re-activate to find out, or configure a migrating_device_id_resolver to keep" + " accepting the previous id."; +} + +// Same fingerprint version, different source tag: the two ids were built from +// different *kinds* of identity, so they were never going to match even on one +// machine. Saying only "not for this device" would point at the wrong remedy. +// +// Ordered by how badly a wrong message would mislead. +[[nodiscard]] inline std::string describe_source_difference( + const fingerprint_spec::device_id_stamp& expected, + const fingerprint_spec::device_id_stamp& bound) +{ + using source = fingerprint_spec::device_id_source; + + // A scoped id is stable only within one platform-defined scope. Comparing it + // with anything from another scope is meaningless in both directions, so this + // must not borrow the version path's "may be the same machine" phrasing: that + // would be actively false. + // + // Which *side* is scoped decides the wording. A custom resolver or a native + // bridge can make this SDK the scoped one, and saying "the binding is scoped" + // there would describe the wrong id and point at the wrong remedy. + if (bound.source == source::scoped) { + return "The binding uses an app-scoped device identity, which cannot be compared with the id" + " this SDK computes, not even on the same device. Re-activate here to bind this build."; + } + + if (expected.source == source::scoped) { + return "This SDK computes an app-scoped device identity, which cannot be compared with the one" + " the binding carries, not even on the same device. Re-activate here to bind this app."; + } + + // An unrecognised tag can only have come from a newer SDK. Before the parser + // accepted arbitrary tags this fell through to the version branch and was + // reported as predating versioned fingerprints, which was exactly backwards. + if (!bound.source.has_value() || !expected.source.has_value()) { + const auto& unknown = !bound.source.has_value() ? bound : expected; + return "The binding carries the device identity tag \"" + unknown.source_tag + + "\", which this SDK does not recognise. It was created by a newer Moonbase SDK, so" + " update rather than re-activating."; + } + + // Hardware identity versus the opt-in host-name fallback. Direction matters as + // much as it does for versions, and the remedies are opposites. + if (bound.source == source::device_name) { + 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. Re-activate to find out."; + } + + return "The binding was created from hardware identity, while this SDK has fallen back to the" + " host name. Check why hardware identity cannot be read here rather than re-activating," + " which would rebind the device to the weaker id."; +} + +// The stamp difference behind a mismatch, or empty when there is none to report. +[[nodiscard]] inline std::string describe_stamp_difference( + const std::string& expected, + const std::string& bound) +{ + const auto expected_stamp = fingerprint_spec::parse_device_id_stamp(expected); + if (!expected_stamp) { + // A custom resolver's id, compared literally. Nothing to say about stamps. + return {}; + } + + const auto bound_stamp = fingerprint_spec::parse_device_id_stamp(bound); + + if (!bound_stamp || bound_stamp->version != expected_stamp->version) { + return describe_version_difference(*expected_stamp, bound_stamp); + } + + if (bound_stamp->source_tag != expected_stamp->source_tag) { + return describe_source_difference(*expected_stamp, *bound_stamp); + } + + return {}; +} + +// Explain a `sig` mismatch. Leads with the only thing that is certain, that the +// bound id is not this device's, and appends the stamp difference when there is one. +[[nodiscard]] inline license_device_mismatch_error device_mismatch_error( + const std::string& expected, + const std::string& bound) +{ + const std::string detail = "This license is not for this device"; + const auto note = describe_stamp_difference(expected, bound); + return license_device_mismatch_error(note.empty() ? detail : detail + ". " + note); +} + } // namespace detail class license_validator { public: - license_validator(licensing_options options, std::shared_ptr fingerprints) + license_validator(licensing_options options, std::shared_ptr device_ids) : options_(std::move(options)), - fingerprints_(std::move(fingerprints)), + device_ids_(std::move(device_ids)), key_(options_.public_key) { - if (!fingerprints_) { + if (!device_ids_) { throw configuration_error("A fingerprint provider is required"); } } @@ -302,17 +425,23 @@ class license_validator { throw license_expired_error("License has expired"); } - const auto expected_signature = fingerprints_->device_id(); + const auto expected_signature = device_ids_->device_id(); const auto actual_signature = detail::require_string(payload, "sig"); - if (actual_signature != expected_signature) { - throw license_invalid_error("License does not match the current device"); + // The literal comparison first, so an app that has not configured a + // migration pays nothing. Only on a mismatch does a + // migrating_device_id_resolver get the chance to vouch for an id this + // machine used to be bound to, which is the one thing that can establish + // continuity. + if (actual_signature != expected_signature + && !device_ids_->accepts_device_id(actual_signature)) { + throw detail::device_mismatch_error(expected_signature, actual_signature); } return result; } licensing_options options_; - std::shared_ptr fingerprints_; + std::shared_ptr device_ids_; detail::rsa_public_key key_; }; diff --git a/modules/moonbase_licensing/README.md b/modules/moonbase_licensing/README.md index e602902..3f78c97 100644 --- a/modules/moonbase_licensing/README.md +++ b/modules/moonbase_licensing/README.md @@ -17,6 +17,18 @@ Add the module, fill in three fields, show one component. (browser) activation, offline (machine-file) activation, validation with a grace period, and server-side deactivation. Not a `juce::OnlineUnlockStatus` wrapper. (If you want that bridge instead, see [`examples/juce/`](../../examples/juce/).) +- **Cross-SDK device identity.** Implements the + [Moonbase device fingerprint spec](../../FINGERPRINT_SPEC.md) v2, so a license + activated in a web or Electron app built on `@moonbase.sh/licensing` validates in + your plugin, and the other way round. No subprocess is spawned and no root-only + file is read, so it works inside a sandboxed host and gives the same id whether or + not the process is elevated. iOS and Android have no identifier unrelated apps can + read, so they get a *scoped* spec id (`mbd2s_`) that is stable within the + platform's own scope but not cross-SDK. Every reader lives in the core SDK, which + needs no framework, so a non-JUCE app on the same device computes the same id; see + [`docs/juce-module.md`](../../docs/juce-module.md#ios-and-android-use-a-scoped-identity). + **Changed in 4.0.0**: see [Upgrading from 3.x](#upgrading-from-3x) if you have + already shipped. - **Built-in UI.** A configurable, themeable `ActivationComponent` (and one-call `ActivationDialog`) covering every state — welcome, activating, success, offline, trial, trial expired, license details, and update-available — with JUCE 8 animated @@ -36,7 +48,7 @@ Add the module, fill in three fields, show one component. misconfiguration, exposes a diagnostics sink for field debugging, and can attach JUCE system/host telemetry to requests. Brandable end to end. -Requires **JUCE 8** (8.0.4+) and C++17. Supports macOS, Windows, and Linux. +Requires **JUCE 8** (8.0.4+) and C++17. Supports macOS, Windows, Linux, iOS and Android.

( + // The platform default, NOT moonbase_device_id_resolver directly: on iOS and + // Android that has no identity to read and throws, and a migrating resolver + // asks its current resolver for an id before consulting any historical one, + // so hard-coding it locks mobile users out of validation *and* activation. + ActivationConfig::defaultDeviceIdResolver(), // binds + std::make_shared()); // still accepted +``` + +New activations bind the spec id, existing licenses keep working, and the fleet +migrates as devices naturally re-activate. Drop the wrapper in a later release to +finish the migration. + +Because `getUniqueDeviceID()` is JUCE's own derivation rather than a published +format, the historical resolver only vouches for a binding if the plugin still +ships the JUCE version that created it, so do not combine this upgrade with a JUCE +major bump. + +See [`docs/juce-module.md`](../../docs/juce-module.md#migrating-an-already-shipped-plugin) +for the details, and [Migrating from 3.x](../../README.md#migrating-from-3x) for the +other options. diff --git a/modules/moonbase_licensing/juce/ActivationConfig.h b/modules/moonbase_licensing/juce/ActivationConfig.h index 23cf805..1921d36 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -11,12 +11,19 @@ #include #include +#if defined(__APPLE__) + #include // TARGET_OS_IPHONE, for the iOS resolver default +#endif + #include #include #include #include "JuceMetadata.h" +// Named by resolvedDeviceIdResolver(). Included here rather than relied on from +// the module umbrella so this header stays usable on its own. +#include "legacy_juce_device_id_resolver.h" // Normally defined by the module umbrella header; fall back so this header is // self-contained if included on its own. @@ -103,9 +110,33 @@ struct ActivationConfig // resolvedDownloadDirectory(). juce::File downloadDirectory; + //============================================================================== + // Device identity. + + // Overrides how this machine is identified to Moonbase. Leave null for the + // default moonbase::moonbase_device_id_resolver, which implements the + // cross-SDK device fingerprint spec, so a license activated in a web or + // Electron app built on @moonbase.sh/licensing validates here too. + // + // Set it to a moonbase::migrating_device_id_resolver when upgrading a plugin + // that already has activated users: the device id changed in 4.0.0, so + // without one every existing install must re-activate, which consumes a fresh + // activation seat and resets any device-scoped trial. See "Migrating from + // 3.x" in the README. + std::shared_ptr deviceIdResolver; + + // Accept a deliberately weaker device id (a hash of the host name, stamped + // mbd2n_) on machines with no readable hardware identity, instead of failing + // activation with moonbase::insufficient_device_identity_error. + // + // Off by default, because a host name is user-renameable, frequently + // duplicated across imaged machines, and regenerated on every container + // start. Ignored when deviceIdResolver is set. + bool allowDeviceNameFallback = false; + // Optional override for the device name shown on the activation screen (the - // " · " chip). When empty, the OS hostname is used - // (juce::SystemStats::getComputerName, via the default fingerprint provider). + // " · " chip). When empty, the resolver's own label is + // used: the OS host name, with a trailing ".local" removed on macOS. juce::String deviceName; // Product / manufacturer brand mark shown in the header lockup. When unset, @@ -262,6 +293,68 @@ struct ActivationConfig return juce::File::getSpecialLocation(juce::File::tempDirectory); } + // The resolver the controller will use. + [[nodiscard]] std::shared_ptr resolvedDeviceIdResolver() const + { + if (deviceIdResolver != nullptr) + return deviceIdResolver; + + return defaultDeviceIdResolver(allowDeviceNameFallback); + } + + /// The resolver this platform gets when `deviceIdResolver` is left unset. + /// + /// Always moonbase::moonbase_device_id_resolver: the core SDK reads every + /// platform natively, including identifierForVendor on iOS and ANDROID_ID on + /// Android, so there is nothing JUCE-specific to substitute. On the scoped + /// platforms it produces an `mbd2s_` id; everywhere else the cross-SDK + /// hardware id. + /// + /// Static and public because a migrating configuration must wrap *this*, so it + /// keeps whatever the platform default is instead of hard-coding one: + /// + /// \code + /// config.deviceIdResolver = std::make_shared( + /// ActivationConfig::defaultDeviceIdResolver(), + /// std::make_shared()); + /// \endcode + [[nodiscard]] static std::shared_ptr defaultDeviceIdResolver( + bool allowHostNameFallback = false) + { + moonbase::moonbase_device_id_resolver_options options; + // Ignored on iOS and Android: build_fingerprint_material refuses the + // host-name fallback there, where the name is the same on every device. + options.fallback = allowHostNameFallback + ? moonbase::device_id_fallback::device_name + : moonbase::device_id_fallback::none; + return std::make_shared(std::move(options)); + } + + // Platforms whose only device identifier is scoped, so the default resolver is + // a scoped one rather than the cross-SDK hardware fingerprint. Exposed so a + // consumer (and the module's tests) can reason about which default they get + // without duplicating the platform test. + // True on a real iOS/tvOS/watchOS device, and on an unmodified iOS app running + // on Apple silicon. Deliberately NOT Mac Catalyst: TARGET_OS_IPHONE is 1 there + // too, but a Catalyst build runs on macOS and can read IOKit, so it takes the + // cross-SDK hardware identity. See platform_tag() in fingerprint_spec.hpp. + static constexpr bool isMobileApple = +#if defined(__APPLE__) && TARGET_OS_IPHONE && !TARGET_OS_MACCATALYST + true; +#else + false; +#endif + + static constexpr bool isAndroid = +#if defined(__ANDROID__) || JUCE_ANDROID + true; +#else + false; +#endif + + /// True where the device id is scoped, and therefore not cross-SDK comparable. + static constexpr bool hasScopedIdentityOnly = isMobileApple || isAndroid; + [[nodiscard]] moonbase::licensing_options toLicensingOptions() const { moonbase::licensing_options options; diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index cbc6677..8409e8a 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -2,7 +2,6 @@ // moonbase_licensing module translation unit. #include "ActivationController.h" -#include "juce_fingerprint_provider.h" #include "juce_http_transport.h" namespace moonbase::juce_integration { @@ -20,6 +19,17 @@ juce::String describeError(const std::exception& ex) if (const auto* api = dynamic_cast(&ex)) if (! api->detail().empty()) message << " (" << api->detail() << ")"; + + // Every activation and offline-token path funnels through here, and this one + // is not a Moonbase failure at all: the machine has no stable hardware + // identifier to hash, so there is nothing to activate against. Left bare it + // reads like a bug in the plugin, so point at the two real options. + if (dynamic_cast(&ex) != nullptr) + message << ". This machine has no stable hardware identifier to bind a license to." + " On a virtual machine or a container, check that it has a system UUID or a" + " machine-id; otherwise set ActivationConfig::allowDeviceNameFallback to accept" + " a weaker id based on the computer name."; + return message; } } // namespace @@ -43,7 +53,19 @@ ActivationController::ActivationController(ActivationConfig config) auto store = std::make_shared( std::filesystem::path(file.getFullPathName().toStdString())); - auto fingerprint = std::make_shared(); +#if JUCE_ANDROID + // The one thing the core SDK cannot obtain by itself: an application Context. + // JUCE has one, so hand it over and the core's plain-JNI reader does the rest. + // Idempotent, so calling it per controller is fine. + if (auto* env = juce::getEnv()) + { + JavaVM* vm = nullptr; + if (env->GetJavaVM(&vm) == 0) + moonbase::android::set_jni_environment(vm, juce::getAppContext().get()); + } +#endif + + auto deviceIds = config_.resolvedDeviceIdResolver(); auto transport = std::make_shared(); // A second transport for the inventory (update) calls, so an update download // URL fetch and a license validation can't block on each other's stream. @@ -52,7 +74,7 @@ ActivationController::ActivationController(ActivationConfig config) try { licensing_ = std::make_shared( - config_.toLicensingOptions(), std::move(store), fingerprint, transport); + config_.toLicensingOptions(), std::move(store), deviceIds, transport); } catch (const std::exception& ex) { @@ -70,9 +92,22 @@ ActivationController::ActivationController(ActivationConfig config) transport->cancel(); inventoryTransport->cancel(); }; - setDeviceLabel(config_.deviceName.isNotEmpty() - ? config_.deviceName - : juce::String(fingerprint->device_name())); + // A custom resolver's device_name() is arbitrary consumer code, and this + // constructor runs inside a plugin editor's constructor, where an escaping + // exception takes the host down with it. An empty label is fine: + // setDeviceLabel renders that as "This device". + juce::String resolvedDeviceName = config_.deviceName; + if (resolvedDeviceName.isEmpty()) + { + try + { + resolvedDeviceName = juce::String(deviceIds->device_name()); + } + catch (const std::exception&) + { + } + } + setDeviceLabel(std::move(resolvedDeviceName)); state_.emplace(stateFilePath()); } @@ -88,6 +123,23 @@ ActivationController::ActivationController(ActivationConfig config, state_.emplace(stateFilePath()); } +std::optional ActivationController::describeDevice() const +{ + if (licensing_ == nullptr) + return std::nullopt; + + try + { + return licensing_->device_resolver().describe_device(); + } + catch (const std::exception&) + { + // A machine with no identity has nothing to describe, and a diagnostics + // getter is the last place that should throw. + return std::nullopt; + } +} + ActivationController::~ActivationController() { stopTimer(); @@ -162,10 +214,29 @@ void ActivationController::start() { peek = licensing->validate_token_local_allow_expired(stored->token); } + catch (const moonbase::license_device_mismatch_error& ex) + { + // The token is genuine but bound to a different device id. + // Its message already distinguishes a stale binding (an older + // fingerprint version, which a migrating_device_id_resolver + // could accept) from a genuinely foreign machine, so quoting + // it beats asserting either. + diag = juce::String("Stored token is not bound to this device: ") + ex.what(); + } + catch (const moonbase::insufficient_device_identity_error& ex) + { + // Nothing was wrong with the token: this machine could not + // identify itself, so no comparison was possible. Saying "not + // valid for this device" here would send support down entirely + // the wrong path. + diag = juce::String("Could not identify this device, so the stored license could not be " + "checked: ") + + ex.what(); + } catch (const std::exception& ex) { - // Tampered / foreign / unparseable -> locked, but left on disk. - diag = juce::String("Stored token rejected (not valid for this device): ") + ex.what(); + // Tampered / unparseable -> locked, but left on disk. + diag = juce::String("Stored token rejected: ") + ex.what(); } if (peek && peek->method == moonbase::activation_method::offline) diff --git a/modules/moonbase_licensing/juce/ActivationController.h b/modules/moonbase_licensing/juce/ActivationController.h index fd68ed2..dcc51df 100644 --- a/modules/moonbase_licensing/juce/ActivationController.h +++ b/modules/moonbase_licensing/juce/ActivationController.h @@ -185,6 +185,16 @@ class ActivationController : private juce::Timer, [[nodiscard]] juce::String statusMessage() const { return statusMessage_; } [[nodiscard]] juce::String offlineError() const { return offlineError_; } [[nodiscard]] juce::String deviceLabel() const { return deviceLabel_; } + + // How this machine's device id was derived: the id itself, the fingerprint + // spec version, the platform tag and the *names* of the identity parameters + // that contributed. Safe to log or put behind a "Copy diagnostics" button; + // parameter values are hardware serial numbers and are never exposed. + // + // Empty when the controller is misconfigured, when a custom resolver does not + // describe itself, or when this machine has no readable identity. + [[nodiscard]] std::optional describeDevice() const; + [[nodiscard]] const ActivationConfig& config() const noexcept { return config_; } [[nodiscard]] const UpdateInfo& updateInfo() const noexcept { return updateInfo_; } [[nodiscard]] bool isBusy() const noexcept { return busy_; } diff --git a/modules/moonbase_licensing/juce/juce_fingerprint_provider.h b/modules/moonbase_licensing/juce/juce_fingerprint_provider.h deleted file mode 100644 index a505227..0000000 --- a/modules/moonbase_licensing/juce/juce_fingerprint_provider.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -// Device fingerprint sourced from juce::SystemStats::getUniqueDeviceID(), which -// JUCE derives from stable hardware identifiers (JUCE 7+). Used by default so -// the module never shells out to ioreg/dmidecode from inside a sandboxed plugin -// host. Pick one provider when you ship and keep it — changing it changes the -// device id Moonbase sees and invalidates existing activations. - -#include - -#include - -#include - -namespace moonbase::juce_integration { - -class juce_fingerprint_provider : public moonbase::fingerprint_provider -{ -public: - [[nodiscard]] std::string device_name() const override - { - return juce::SystemStats::getComputerName().toStdString(); - } - - [[nodiscard]] std::string device_id() const override - { - return juce::SystemStats::getUniqueDeviceID().toStdString(); - } -}; - -} // namespace moonbase::juce_integration diff --git a/modules/moonbase_licensing/juce/legacy_juce_device_id_resolver.h b/modules/moonbase_licensing/juce/legacy_juce_device_id_resolver.h new file mode 100644 index 0000000..5ef7416 --- /dev/null +++ b/modules/moonbase_licensing/juce/legacy_juce_device_id_resolver.h @@ -0,0 +1,57 @@ +#pragma once + +// The device id this module used before it adopted the cross-SDK fingerprint +// spec: juce::SystemStats::getUniqueDeviceID(). +// +// FROZEN, and no longer the default. It is kept so a plugin that already has +// activated users can keep validating their licenses, by naming it as a +// historical resolver: +// +// config.deviceIdResolver = std::make_shared( +// ActivationConfig::defaultDeviceIdResolver(), +// std::make_shared()); +// +// Two reasons it is not the default any more. It is not the spec, so a license +// activated in a web or Electron app built on @moonbase.sh/licensing would never +// validate in the plugin, or the other way round. And getUniqueDeviceID() is +// JUCE's own derivation rather than a published format, so it can change between +// JUCE versions: this resolver only vouches for a binding if the plugin still +// ships the JUCE version that created it. +// +// The original reason for preferring it, that the SDK used to shell out to +// ioreg/dmidecode and a sandboxed plugin host blocks that, no longer applies. +// moonbase_device_id_resolver reads IOKit, world-readable files and the firmware +// table directly, and spawns no subprocess on any platform. + +#include + +#include + +#include + +namespace moonbase::juce_integration { + +class legacy_juce_device_id_resolver : public moonbase::device_id_resolver +{ +public: + [[nodiscard]] std::string device_name() const override + { + return juce::SystemStats::getComputerName().toStdString(); + } + + [[nodiscard]] std::string device_id() const override + { + return juce::SystemStats::getUniqueDeviceID().toStdString(); + } +}; + +#if !defined(MOONBASE_DISABLE_DEPRECATED_ALIASES) + +using juce_fingerprint_provider + [[deprecated("renamed to moonbase::juce_integration::legacy_juce_device_id_resolver; note that the " + "module default is now moonbase::moonbase_device_id_resolver, which implements the " + "cross-SDK fingerprint spec")]] = legacy_juce_device_id_resolver; + +#endif + +} // namespace moonbase::juce_integration diff --git a/modules/moonbase_licensing/moonbase/client.hpp b/modules/moonbase_licensing/moonbase/client.hpp index a7a656f..8b86ae0 100644 --- a/modules/moonbase_licensing/moonbase/client.hpp +++ b/modules/moonbase_licensing/moonbase/client.hpp @@ -10,8 +10,8 @@ #include #include "moonbase/detail/url.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" -#include "moonbase/fingerprint.hpp" #include "moonbase/http.hpp" #include "moonbase/types.hpp" #include "moonbase/validator.hpp" @@ -137,15 +137,15 @@ class license_client { public: license_client( licensing_options options, - std::shared_ptr fingerprints, + std::shared_ptr device_ids, std::shared_ptr validator, std::shared_ptr transport) : options_(std::move(options)), - fingerprints_(std::move(fingerprints)), + device_ids_(std::move(device_ids)), validator_(std::move(validator)), transport_(std::move(transport)) { - if (!fingerprints_) { + if (!device_ids_) { throw configuration_error("A fingerprint provider is required"); } if (!validator_) { @@ -163,8 +163,8 @@ class license_client { detail::client_query(options_)); const auto payload = nlohmann::json{ - {"deviceName", fingerprints_->device_name()}, - {"deviceSignature", fingerprints_->device_id()}, + {"deviceName", device_ids_->device_name()}, + {"deviceSignature", device_ids_->device_id()}, }; http_request request; @@ -252,7 +252,7 @@ class license_client { private: licensing_options options_; - std::shared_ptr fingerprints_; + std::shared_ptr device_ids_; std::shared_ptr validator_; std::shared_ptr transport_; }; diff --git a/modules/moonbase_licensing/moonbase/default_fingerprint.hpp b/modules/moonbase_licensing/moonbase/default_fingerprint.hpp index 470096d..751fbfc 100644 --- a/modules/moonbase_licensing/moonbase/default_fingerprint.hpp +++ b/modules/moonbase_licensing/moonbase/default_fingerprint.hpp @@ -1,372 +1,31 @@ #pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#if defined(_WIN32) -#ifndef NOMINMAX -#define NOMINMAX -#endif -#include -#else -#include -#endif - -#include "moonbase/detail/crypto/crypto.hpp" -#include "moonbase/fingerprint.hpp" +// Compatibility header. +// +// The default device fingerprint changed in 4.0.0: it now follows the cross-SDK +// Moonbase device fingerprint spec, so `default_fingerprint_provider` resolves to +// moonbase_device_id_resolver rather than to the old `moonbase-cpp:fingerprint:v1` +// algorithm. Device ids computed here therefore differ from those computed by +// 3.x, and existing licenses need either re-activation or a +// migrating_device_id_resolver. See "Migrating from 3.x" in the README. +// +// The previous algorithm is preserved verbatim as +// moonbase::legacy_cpp_device_id_resolver in , +// so it can keep validating licenses that were bound under it. +// +// Define MOONBASE_DISABLE_DEPRECATED_ALIASES to compile the alias out. + +#include "moonbase/moonbase_device_id_resolver.hpp" namespace moonbase { -class default_fingerprint_provider : public fingerprint_provider { -public: - using identity_parameter = std::pair; - - [[nodiscard]] static std::string platform_tag() - { -#if defined(__APPLE__) - return "mac"; -#elif defined(_WIN32) - return "windows"; -#elif defined(__ANDROID__) - return "android"; -#elif defined(__linux__) - return "linux"; -#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) - return "bsd"; -#else - return "unknown"; -#endif - } - - [[nodiscard]] static std::string hash_identity_parameters( - const std::vector& parameters, - std::string_view platform = platform_tag()) - { - std::string material; - material += "moonbase-cpp:fingerprint:v1\n"; - material += "platform="; - material.append(platform.data(), platform.size()); - material += "\n"; - - for (const auto& parameter : parameters) { - auto name = trim_ascii(parameter.first); - auto value = trim_ascii(parameter.second); - if (!name.empty() && !value.empty()) { - material += name; - material += "="; - material += value; - material += "\n"; - } - } - - return detail::sha256_hex(material); - } - - [[nodiscard]] static std::vector identity_parameters() - { - std::vector parameters; - -#if defined(_WIN32) - append_windows_identity_parameters(parameters); -#elif defined(__APPLE__) - auto uuid = trim_ascii(command_output( - "ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | " - "awk -F\\\" '/IOPlatformUUID/{print $4; exit}'")); - uuid.erase(std::remove(uuid.begin(), uuid.end(), '-'), uuid.end()); - append_parameter(parameters, "ioPlatformUuid", uuid); -#elif defined(__linux__) && !defined(__ANDROID__) - const auto board_serial = trim_ascii(read_file("/sys/class/dmi/id/board_serial")); - if (!board_serial.empty()) { - append_parameter(parameters, "boardSerial", board_serial); - } else { - append_parameter(parameters, "biosDate", read_file("/sys/class/dmi/id/bios_date")); - append_parameter(parameters, "biosRelease", read_file("/sys/class/dmi/id/bios_release")); - append_parameter(parameters, "biosVendor", read_file("/sys/class/dmi/id/bios_vendor")); - append_parameter(parameters, "biosVersion", read_file("/sys/class/dmi/id/bios_version")); - } - - const auto cpu_data = command_output("lscpu 2>/dev/null"); - if (!cpu_data.empty()) { - append_parameter(parameters, "cpuFamily", linux_cpu_field(cpu_data, "CPU family:")); - append_parameter(parameters, "cpuModel", linux_cpu_field(cpu_data, "Model:")); - append_parameter(parameters, "cpuModelName", linux_cpu_field(cpu_data, "Model name:")); - append_parameter(parameters, "cpuVendor", linux_cpu_field(cpu_data, "Vendor ID:")); - } -#endif - - return parameters; - } - - [[nodiscard]] std::string device_name() const override - { -#if defined(_WIN32) - char buffer[128]{}; - DWORD size = static_cast(sizeof(buffer)) - 1; - if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer, &size)) { - return std::string(buffer, size); - } - return {}; -#else - std::array buffer{}; - if (gethostname(buffer.data(), buffer.size() - 1) == 0) { - auto name = std::string(buffer.data()); -#if defined(__APPLE__) - const auto suffix = std::string(".local"); - if (name.size() >= suffix.size()) { - auto tail = name.substr(name.size() - suffix.size()); - std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - if (tail == suffix) { - name.erase(name.size() - suffix.size()); - } - } -#endif - return name; - } - return {}; -#endif - } - - [[nodiscard]] std::string device_id() const override - { - auto parameters = identity_parameters(); - if (parameters.empty()) { - append_parameter(parameters, "deviceName", device_name()); - } - return hash_identity_parameters(parameters); - } - -private: - [[nodiscard]] static std::string read_file(const std::string& path) - { - std::ifstream file(path); - if (!file) { - return {}; - } - std::ostringstream out; - out << file.rdbuf(); - return out.str(); - } - - [[nodiscard]] static std::string command_output(const std::string& command) - { -#if defined(_WIN32) - (void)command; - return {}; -#else - std::array buffer{}; - std::string result; - std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); - if (!pipe) { - return {}; - } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { - result += buffer.data(); - } - return result; -#endif - } - - [[nodiscard]] static std::string trim_ascii(std::string value) - { - while (!value.empty() && - (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { - value.pop_back(); - } - while (!value.empty() && - (value.front() == '\n' || value.front() == '\r' || value.front() == ' ' || value.front() == '\t')) { - value.erase(value.begin()); - } - return value; - } - - [[nodiscard]] static std::string linux_cpu_field(const std::string& lscpu_output, const std::string& key) - { - const auto key_index = lscpu_output.find(key); - if (key_index == std::string::npos) { - return {}; - } - - const auto colon = lscpu_output.find(':', key_index); - if (colon == std::string::npos) { - return {}; - } - - const auto end = lscpu_output.find('\n', colon); - return trim_ascii(lscpu_output.substr( - colon + 1, - end == std::string::npos ? std::string::npos : end - colon - 1)); - } - - static void append_parameter( - std::vector& parameters, - std::string name, - std::string value) - { - name = trim_ascii(std::move(name)); - value = trim_ascii(std::move(value)); - if (!name.empty() && !value.empty()) { - parameters.emplace_back(std::move(name), std::move(value)); - } - } - -#if defined(_WIN32) - [[nodiscard]] static std::string windows_string_from_offset( - const std::vector& content, - const std::vector& strings, - std::size_t byte_offset) - { - if (byte_offset >= content.size()) { - return {}; - } - - const auto index = static_cast(content[byte_offset]); - if (index == 0 || index > strings.size()) { - return {}; - } - - return std::string(strings[index - 1]); - } - - [[nodiscard]] static std::size_t windows_bounded_string_length(const char* value, std::size_t max_length) - { - std::size_t length = 0; - while (length < max_length && value[length] != '\0') { - ++length; - } - return length; - } - - static void append_windows_identity_parameters(std::vector& parameters) - { - constexpr DWORD signature = - static_cast('R') | - (static_cast('S') << 8U) | - (static_cast('M') << 16U) | - (static_cast('B') << 24U); - - const auto table_size = GetSystemFirmwareTable(signature, 0, nullptr, 0); - if (table_size == 0) { - return; - } - - std::vector smbios(table_size); - if (GetSystemFirmwareTable(signature, 0, smbios.data(), table_size) != table_size) { - return; - } - - struct raw_smbios_data { - std::uint8_t unused[4]; - std::uint32_t length; - }; - - struct smbios_header { - std::uint8_t id; - std::uint8_t length; - std::uint16_t handle; - }; - - if (smbios.size() < sizeof(raw_smbios_data)) { - return; - } - - raw_smbios_data raw{}; - std::memcpy(&raw, smbios.data(), sizeof(raw)); - if (smbios.size() < sizeof(raw_smbios_data) + raw.length) { - return; - } - - std::vector content( - smbios.begin() + static_cast(sizeof(raw_smbios_data)), - smbios.begin() + static_cast(sizeof(raw_smbios_data) + raw.length)); - - std::size_t offset = 0; - while (offset < content.size()) { - if (content.size() - offset < sizeof(smbios_header)) { - break; - } - - smbios_header header{}; - std::memcpy(&header, content.data() + offset, sizeof(header)); - if (header.length == 0 || content.size() - offset < header.length) { - break; - } - - std::vector strings; - auto string_offset = offset + header.length; - while (string_offset < content.size()) { - const auto* str = reinterpret_cast(content.data() + string_offset); - const auto max_length = content.size() - string_offset; - const auto length = windows_bounded_string_length(str, max_length); - if (length == 0) { - break; - } - strings.emplace_back(str, length); - string_offset += std::min(length + 1, max_length); - } - - const auto end_of_table = std::min( - content.size(), - std::max(offset + static_cast(header.length) + 2, string_offset + 1)); - - const auto from_offset = [&](std::size_t byte_offset) { - return windows_string_from_offset(content, strings, offset + byte_offset); - }; - - switch (header.id) { - case 1: { - append_parameter(parameters, "systemManufacturer", from_offset(0x04)); - append_parameter(parameters, "systemProductName", from_offset(0x05)); - - if (offset + 0x08 + 16 <= content.size()) { - std::ostringstream hex; - hex << std::uppercase << std::hex << std::setfill('0'); - for (std::size_t index = 0; index != 16; ++index) { - hex << std::setw(2) << static_cast(content[offset + 0x08 + index]); - } - append_parameter(parameters, "systemUuid", hex.str()); - } - break; - } - - case 2: - append_parameter(parameters, "baseboardManufacturer", from_offset(0x04)); - append_parameter(parameters, "baseboardProduct", from_offset(0x05)); - append_parameter(parameters, "baseboardVersion", from_offset(0x06)); - append_parameter(parameters, "baseboardSerialNumber", from_offset(0x07)); - append_parameter(parameters, "baseboardAssetTag", from_offset(0x08)); - break; - - case 4: - append_parameter(parameters, "processorManufacturer", from_offset(0x07)); - append_parameter(parameters, "processorVersion", from_offset(0x10)); - append_parameter(parameters, "processorAssetTag", from_offset(0x21)); - append_parameter(parameters, "processorPartNumber", from_offset(0x22)); - break; +#if !defined(MOONBASE_DISABLE_DEPRECATED_ALIASES) - default: - break; - } +using default_fingerprint_provider + [[deprecated("renamed to moonbase::moonbase_device_id_resolver; note that 4.0.0 also changed " + "the algorithm to the cross-SDK spec, and the previous one is now " + "moonbase::legacy_cpp_device_id_resolver")]] = moonbase_device_id_resolver; - offset = end_of_table; - } - } #endif -}; } // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/detail/unicode/nfc_ascii.hpp b/modules/moonbase_licensing/moonbase/detail/unicode/nfc_ascii.hpp new file mode 100644 index 0000000..200fdf7 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/detail/unicode/nfc_ascii.hpp @@ -0,0 +1,294 @@ +#pragma once + +// NFC followed by a printable-ASCII filter, without ICU. +// +// THIS IS NOT A GENERAL NFC IMPLEMENTATION AND MUST NOT BE REUSED AS ONE. It +// answers exactly one question, and only because of what runs immediately after +// it: given a string, which printable-ASCII characters does NFC leave visible? +// It never materializes a normalized string, and it is only correct downstream +// of the U+0020..U+007E filter. +// +// The device fingerprint spec (FINGERPRINT_SPEC.md) requires NFC before that +// filter, and skipping it is not cosmetic: "cafe" + U+0301 must canonicalize to +// "caf", not "cafe", because NFC first composes the pair into a precomposed +// e-acute that the filter then drops whole. Getting this wrong yields a device +// id that disagrees with every other Moonbase SDK on the same machine. +// +// Because the filter discards everything outside printable ASCII, NFC can only +// change the visible result in three ways, and scripts/gen-nfc-tables.py +// enumerates all three exhaustively from the Unicode database: +// +// 1. An ASCII starter is consumed by a following combining mark. +// 2. A non-ASCII code point whose NFC form *is* a printable-ASCII character +// (exactly three exist, all singleton decompositions). +// 3. A combining mark with its own canonical decomposition, expanded before +// the blocking analysis in (1). +// +// Blocking collapses to a single question per cluster, because once an ASCII +// base composes the result is non-ASCII forever and no later composition can +// bring it back. Canonical ordering is a stable sort by combining class, so a +// mark is blocked exactly when an earlier mark in the cluster shares its class. +// The base is therefore annihilated if and only if it composes with some mark +// that is the first of its class in the cluster, which needs no sorting. +// +// scripts/gen-nfc-tables.py --verify differential-tests this against real NFC +// over every code point, every printable-ASCII base crossed with every combining +// mark, exhaustive base x mark x mark for a sample of bases, and several hundred +// thousand random strings. +// +// It is exact except in one deliberately bounded way. The shipped combining-class +// table covers the five combining-mark blocks rather than all of Unicode, because +// the full table costs 8.5 KB of header for nothing the spec exercises. A mark +// outside those blocks therefore reads as a starter, which ends the cluster, so a +// later composing mark never gets the chance to annihilate the base. Reaching that +// needs an ASCII base, then a combining mark from a non-Latin script, then a Latin +// composing mark. No IOKit UUID, machine-id, sysfs DMI string, SMBIOS string or +// host name contains such a sequence, and --verify asserts the shape of every +// divergence, that it always errs towards keeping the base, and that no input of +// one or two code points diverges at all. + +#include +#include +#include +#include + +#include "moonbase/detail/unicode/nfc_tables.hpp" + +namespace moonbase::detail::unicode { + +/// Lowest and highest characters the fingerprint material may contain. +inline constexpr char32_t printable_ascii_min = 0x20; +inline constexpr char32_t printable_ascii_max = 0x7E; + +/// A byte that could not begin or continue a well-formed UTF-8 sequence. +/// +/// Kept as an opaque non-ASCII starter rather than dropped outright: it ends any +/// combining cluster in progress but never annihilates a preceding base. That is +/// what Node produces for the same input, since its UTF-8 decoder substitutes +/// U+FFFD, which is likewise a non-ASCII starter. Firmware strings are the +/// realistic source of such bytes. +inline constexpr char32_t malformed_code_point = 0x7FFFFFFF; + +namespace detail { + +/// Canonical combining class, or 0 for a starter. +[[nodiscard]] inline std::uint8_t combining_class(char32_t code_point) noexcept +{ + const auto* ranges = tables::combining_class_ranges; + std::size_t low = 0; + std::size_t high = tables::combining_class_range_count; + + while (low < high) { + const std::size_t middle = low + (high - low) / 2; + if (code_point < ranges[middle].first) { + high = middle; + } else if (code_point > ranges[middle].last) { + low = middle + 1; + } else { + return ranges[middle].combining_class; + } + } + + return 0; +} + +/// Bit position for a combining class that some composing mark uses, else -1. +[[nodiscard]] inline int combining_class_slot(std::uint8_t combining) noexcept +{ + for (std::size_t index = 0; index != tables::composing_mark_class_count; ++index) { + if (tables::composing_mark_classes[index] == combining) { + return static_cast(index); + } + } + return -1; +} + +/// Does `base` form a primary composite with `mark`? +[[nodiscard]] inline bool composes(char32_t base, char32_t mark) noexcept +{ + if (base < tables::composition_mask_first || base > tables::composition_mask_last) { + return false; + } + if (mark < tables::composing_mark_first || mark > tables::composing_mark_last) { + return false; + } + + const auto bit = tables::composing_mark_index[mark - tables::composing_mark_first]; + if (bit < 0) { + return false; + } + + const auto mask = tables::composition_masks[base - tables::composition_mask_first]; + return (mask & (std::uint32_t{1} << bit)) != 0; +} + +/// Apply the singleton decompositions that expose a printable-ASCII character. +[[nodiscard]] inline char32_t map_ascii_singleton(char32_t code_point) noexcept +{ + for (std::size_t index = 0; index != tables::ascii_singleton_count; ++index) { + if (tables::ascii_singletons[index].from == code_point) { + return tables::ascii_singletons[index].to; + } + } + return code_point; +} + +/// Canonical decomposition of a combining mark, or null when it has none. +[[nodiscard]] inline const tables::mark_decomposition* decompose_mark(char32_t code_point) noexcept +{ + for (std::size_t index = 0; index != tables::mark_decomposition_count; ++index) { + if (tables::mark_decompositions[index].from == code_point) { + return &tables::mark_decompositions[index]; + } + } + return nullptr; +} + +/// Decode one UTF-8 sequence, advancing `offset`. +/// +/// Strict: overlong encodings, surrogates, values above U+10FFFF and truncated +/// sequences all yield `malformed_code_point`. On failure exactly one byte is +/// consumed, so a following ASCII byte can never be swallowed by a bad prefix +/// ("A\xC3B" keeps both the A and the B). +[[nodiscard]] inline char32_t decode_utf8(std::string_view text, std::size_t& offset) noexcept +{ + const auto lead = static_cast(text[offset]); + + if (lead < 0x80) { + offset += 1; + return lead; + } + + std::size_t length = 0; + char32_t code_point = 0; + char32_t lowest = 0; + + if ((lead & 0xE0U) == 0xC0U) { + length = 2; + code_point = lead & 0x1FU; + lowest = 0x80; + } else if ((lead & 0xF0U) == 0xE0U) { + length = 3; + code_point = lead & 0x0FU; + lowest = 0x800; + } else if ((lead & 0xF8U) == 0xF0U) { + length = 4; + code_point = lead & 0x07U; + lowest = 0x10000; + } else { + // A continuation byte with no lead, or an invalid 5/6-byte prefix. + offset += 1; + return malformed_code_point; + } + + if (offset + length > text.size()) { + offset += 1; + return malformed_code_point; + } + + for (std::size_t index = 1; index != length; ++index) { + const auto continuation = static_cast(text[offset + index]); + if ((continuation & 0xC0U) != 0x80U) { + offset += 1; + return malformed_code_point; + } + code_point = (code_point << 6U) | (continuation & 0x3FU); + } + + // Overlong, surrogate, or beyond the Unicode range. + if (code_point < lowest || (code_point >= 0xD800 && code_point <= 0xDFFF) || code_point > 0x10FFFF) { + offset += 1; + return malformed_code_point; + } + + offset += length; + return code_point; +} + +} // namespace detail + +/// NFC-normalize `text` and keep only the printable-ASCII characters that +/// survive, in order. +/// +/// The result is pure ASCII, so its length in characters equals its length in +/// bytes. Truncation and trimming are the fingerprint spec's business and happen +/// in fingerprint_spec::canonicalize_value, not here. +[[nodiscard]] inline std::string canonicalize_printable_ascii(std::string_view text) +{ + std::string out; + out.reserve(text.size()); + + // The printable-ASCII starter whose cluster is still open, or 0 for none. + char32_t pending = 0; + bool annihilated = false; + std::uint32_t seen_classes = 0; + + const auto flush = [&]() { + if (pending != 0 && !annihilated) { + out.push_back(static_cast(pending)); + } + pending = 0; + annihilated = false; + seen_classes = 0; + }; + + const auto consume = [&](char32_t code_point) { + const std::uint8_t combining = detail::combining_class(code_point); + + if (combining == 0) { + // A starter closes the previous cluster and opens its own. + flush(); + const char32_t mapped = detail::map_ascii_singleton(code_point); + if (mapped >= printable_ascii_min && mapped <= printable_ascii_max) { + pending = mapped; + } + return; + } + + // A combining mark. Marks are never printable ASCII, so the only thing + // one can do is decide the fate of the base it is attached to. + if (pending == 0 || annihilated) { + return; + } + + const int slot = detail::combining_class_slot(combining); + if (slot < 0) { + // No composing mark uses this class, so it can neither compose with + // the base nor block a mark that would. + return; + } + + const std::uint32_t bit = std::uint32_t{1} << slot; + if ((seen_classes & bit) != 0) { + // Blocked by an earlier mark of the same class. + return; + } + seen_classes |= bit; + + if (detail::composes(pending, code_point)) { + annihilated = true; + } + }; + + std::size_t offset = 0; + while (offset < text.size()) { + const char32_t code_point = detail::decode_utf8(text, offset); + + // A mark with its own canonical decomposition is expanded in place. Both + // halves keep their original position, which is what a stable canonical + // sort would do, so the blocking analysis stays correct. + if (const auto* decomposition = detail::decompose_mark(code_point)) { + for (std::uint8_t index = 0; index != decomposition->length; ++index) { + consume(decomposition->to[index]); + } + continue; + } + + consume(code_point); + } + + flush(); + return out; +} + +} // namespace moonbase::detail::unicode diff --git a/modules/moonbase_licensing/moonbase/detail/unicode/nfc_tables.hpp b/modules/moonbase_licensing/moonbase/detail/unicode/nfc_tables.hpp new file mode 100644 index 0000000..6a9b736 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/detail/unicode/nfc_tables.hpp @@ -0,0 +1,141 @@ +#pragma once + +// Generated by scripts/gen-nfc-tables.py from Unicode 16.0.0. Do not edit by hand. +// +// Supporting data for detail/unicode/nfc_ascii.hpp, which answers exactly one +// question: after NFC, which printable-ASCII characters are still visible? See +// that header, and the generator, for why this is not a general NFC +// implementation and must never be reused as one. +// +// Stability: the Unicode Normalization Stability Policy freezes the composition +// table, the singleton mappings and the mark decompositions below, so those can +// never change. Only the combining-class ranges grow, as new scripts are +// encoded. A stale range can only matter for a value that mixes a printable +// ASCII base with a combining mark from a script this table predates, which no +// UUID, machine-id, DMI string, SMBIOS string or host name contains. + +#include +#include + +namespace moonbase::detail::unicode::tables { + +inline constexpr const char* unicode_version = "16.0.0"; + +// --------------------------------------------------------------------------- +// Canonical combining class, as run-collapsed ranges over the code points with +// a non-zero class. Needed both to tell a combining mark from a starter and to +// find the head of each equal-class run when testing whether a mark is blocked. + +struct combining_class_range { + char32_t first; + char32_t last; + std::uint8_t combining_class; +}; + +inline constexpr combining_class_range combining_class_ranges[] = { + {0x0300,0x0314,230}, {0x0315,0x0315,232}, {0x0316,0x0319,220}, {0x031A,0x031A,232}, {0x031B,0x031B,216}, {0x031C,0x0320,220}, + {0x0321,0x0322,202}, {0x0323,0x0326,220}, {0x0327,0x0328,202}, {0x0329,0x0333,220}, {0x0334,0x0338,1}, {0x0339,0x033C,220}, + {0x033D,0x0344,230}, {0x0345,0x0345,240}, {0x0346,0x0346,230}, {0x0347,0x0349,220}, {0x034A,0x034C,230}, {0x034D,0x034E,220}, + {0x0350,0x0352,230}, {0x0353,0x0356,220}, {0x0357,0x0357,230}, {0x0358,0x0358,232}, {0x0359,0x035A,220}, {0x035B,0x035B,230}, + {0x035C,0x035C,233}, {0x035D,0x035E,234}, {0x035F,0x035F,233}, {0x0360,0x0361,234}, {0x0362,0x0362,233}, {0x0363,0x036F,230}, + {0x1AB0,0x1AB4,230}, {0x1AB5,0x1ABA,220}, {0x1ABB,0x1ABC,230}, {0x1ABD,0x1ABD,220}, {0x1ABF,0x1AC0,220}, {0x1AC1,0x1AC2,230}, + {0x1AC3,0x1AC4,220}, {0x1AC5,0x1AC9,230}, {0x1ACA,0x1ACA,220}, {0x1ACB,0x1ACE,230}, {0x1DC0,0x1DC1,230}, {0x1DC2,0x1DC2,220}, + {0x1DC3,0x1DC9,230}, {0x1DCA,0x1DCA,220}, {0x1DCB,0x1DCC,230}, {0x1DCD,0x1DCD,234}, {0x1DCE,0x1DCE,214}, {0x1DCF,0x1DCF,220}, + {0x1DD0,0x1DD0,202}, {0x1DD1,0x1DF5,230}, {0x1DF6,0x1DF6,232}, {0x1DF7,0x1DF8,228}, {0x1DF9,0x1DF9,220}, {0x1DFA,0x1DFA,218}, + {0x1DFB,0x1DFB,230}, {0x1DFC,0x1DFC,233}, {0x1DFD,0x1DFD,220}, {0x1DFE,0x1DFE,230}, {0x1DFF,0x1DFF,220}, {0x20D0,0x20D1,230}, + {0x20D2,0x20D3,1}, {0x20D4,0x20D7,230}, {0x20D8,0x20DA,1}, {0x20DB,0x20DC,230}, {0x20E1,0x20E1,230}, {0x20E5,0x20E6,1}, + {0x20E7,0x20E7,230}, {0x20E8,0x20E8,220}, {0x20E9,0x20E9,230}, {0x20EA,0x20EB,1}, {0x20EC,0x20EF,220}, {0x20F0,0x20F0,230}, + {0xFE20,0xFE26,230}, {0xFE27,0xFE2D,220}, {0xFE2E,0xFE2F,230}, +}; + +inline constexpr std::size_t combining_class_range_count = + sizeof(combining_class_ranges) / sizeof(combining_class_ranges[0]); + +// --------------------------------------------------------------------------- +// The 26 combining marks that can form a primary composite with a printable +// ASCII base, and a bitmask per base saying which. All of them fall in +// U+0300..U+0338, so a small index array resolves a mark to its bit in O(1). + +inline constexpr char32_t composing_mark_first = 0x0300; +inline constexpr char32_t composing_mark_last = 0x0338; + +inline constexpr std::int8_t composing_mark_index[] = { + 0, 1, 2, 3, 4, -1, 5, 6, 7, 8, 9, 10, 11, -1, -1, 12, + -1, 13, -1, -1, -1, -1, -1, -1, -1, -1, -1, 14, -1, -1, -1, -1, + -1, -1, -1, 15, 16, 17, 18, 19, 20, -1, -1, -1, -1, 21, 22, -1, + 23, 24, -1, -1, -1, -1, -1, -1, 25, +}; + +// Indexed by (base - 0x20) for base in U+0020..U+007E. Bit i corresponds to the +// mark whose composing_mark_index value is i. +inline constexpr std::uint32_t composition_masks[] = { + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x02000000, 0x02000000, + 0x02000000, 0x00000000, 0x00000000, 0x0012BBFF, 0x01008040, 0x00080846, + 0x01288840, 0x00B8B9FF, 0x00000040, 0x00080876, 0x004888C4, 0x0090B9FF, + 0x00000004, 0x01088802, 0x01288802, 0x00008042, 0x0128884B, 0x0010FDFF, + 0x00000042, 0x00000000, 0x0108B842, 0x000C8846, 0x012C8840, 0x00B1FFBF, + 0x00008008, 0x000080C7, 0x000000C0, 0x000081DF, 0x01008846, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x0012BBFF, + 0x01008040, 0x00080846, 0x01288840, 0x00B8B9FF, 0x00000040, 0x00080876, + 0x014888C4, 0x0090B9BF, 0x00000804, 0x01088802, 0x01288802, 0x00008042, + 0x0128884B, 0x0010FDFF, 0x00000042, 0x00000000, 0x0108B842, 0x000C8846, + 0x012C88C0, 0x00B1FFBF, 0x00008008, 0x000082C7, 0x000000C0, 0x000083DF, + 0x01008846, 0x00000000, 0x00000000, 0x00000000, 0x00000000, +}; + +inline constexpr char32_t composition_mask_first = 0x0020; +inline constexpr char32_t composition_mask_last = 0x007E; + +// The distinct combining classes those marks use. A mark is blocked only by an +// earlier mark of the same class, so a class absent from this list can neither +// compose with an ASCII base nor block something that would, and the cluster +// walk skips it. Position in this array is the bit used to remember that the +// class has already been seen in the current cluster. +inline constexpr std::uint8_t composing_mark_classes[] = { + 1, 202, 216, 220, 230, +}; + +inline constexpr std::size_t composing_mark_class_count = + sizeof(composing_mark_classes) / sizeof(composing_mark_classes[0]); + +// --------------------------------------------------------------------------- +// Non-ASCII code points whose NFC form is a single printable-ASCII character. +// These are singleton decompositions, which are always composition-excluded, so +// NFC leaves the ASCII result exposed and it must survive the filter. + +struct ascii_singleton { + char32_t from; + char32_t to; +}; + +inline constexpr ascii_singleton ascii_singletons[] = { + {0x037E,0x003B}, {0x1FEF,0x0060}, {0x212A,0x004B}, +}; + +inline constexpr std::size_t ascii_singleton_count = + sizeof(ascii_singletons) / sizeof(ascii_singletons[0]); + +// --------------------------------------------------------------------------- +// Combining marks with a canonical decomposition of their own. They must be +// expanded in place before the blocking analysis, or a mark that decomposes to +// a composing one would fail to annihilate its base. + +struct mark_decomposition { + char32_t from; + char32_t to[2]; + std::uint8_t length; +}; + +inline constexpr mark_decomposition mark_decompositions[] = { + {0x0340,{0x0300,0x0000},1}, {0x0341,{0x0301,0x0000},1}, + {0x0343,{0x0313,0x0000},1}, {0x0344,{0x0308,0x0301},2}, +}; + +inline constexpr std::size_t mark_decomposition_count = + sizeof(mark_decompositions) / sizeof(mark_decompositions[0]); + +} // namespace moonbase::detail::unicode::tables diff --git a/modules/moonbase_licensing/moonbase/device_id_resolver.hpp b/modules/moonbase_licensing/moonbase/device_id_resolver.hpp new file mode 100644 index 0000000..9f2f881 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/device_id_resolver.hpp @@ -0,0 +1,223 @@ +#pragma once + +// How this machine is identified to Moonbase. +// +// A license token carries a `sig` claim equal to the device id, recomputed and +// compared on every local validation. The default implementation +// (moonbase_device_id_resolver.hpp) follows the cross-SDK fingerprint spec, so a +// license activated by any conforming Moonbase SDK validates here and vice +// versa. Custom resolvers are compared literally and need not follow the spec. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "moonbase/errors.hpp" +#include "moonbase/fingerprint_spec.hpp" + +namespace moonbase { + +/// A device id plus the provenance needed to reason about it: safe to log, show +/// in an about box, or attach to a support ticket. +/// +/// Parameter *names* only, deliberately. Their values are hardware serial +/// numbers, and a hash of one is no safer to publish: an unsalted digest is a +/// stable global correlator for the machine, and low-entropy values such as host +/// names or sequential serials fall to a dictionary. machine-id(5) is explicit +/// that the Linux machine id is confidential and must only ever leave the host +/// through an application-specific *keyed* hash. Which parameters contributed is +/// the useful diagnostic anyway; what they read is not. +struct device_id_description { + std::string device_id; + /// Fingerprint spec version that produced it. + int version = 0; + std::string platform; + fingerprint_spec::device_id_source source = fingerprint_spec::device_id_source::identity; + /// Names of the identity parameters that went into the material, in order. + std::vector param_names; +}; + +class device_id_resolver { +public: + virtual ~device_id_resolver() = default; + + /// A human-readable label sent alongside the device id at activation. Not + /// part of the hashed material (except in the opt-in host-name fallback), so + /// it can change freely without invalidating a license. + [[nodiscard]] virtual std::string device_name() const = 0; + + /// The device id this machine binds on activation. + [[nodiscard]] virtual std::string device_id() const = 0; + + /// Does this machine also answer to a device id it used to have? + /// + /// Deliberately separate from device_id(), which stays single-valued: what a + /// device binds on activation and what a validator accepts are different + /// questions, and conflating them is what forces an all-or-nothing migration. + /// The default is no history, so a resolver that has never changed algorithm + /// need not think about this. + /// + /// A virtual with a default rather than a second interface plus a + /// dynamic_cast: plugin builds commonly disable RTTI, and the reference SDK + /// duck-types this for the same reason. + [[nodiscard]] virtual bool accepts_device_id(const std::string& /*device_id*/) const { return false; } + + /// Provenance for diagnostics, when the resolver can explain itself. + [[nodiscard]] virtual std::optional describe_device() const { return std::nullopt; } +}; + +/// A fixed identity. Useful in tests, and for apps that source the device id +/// from somewhere else entirely. +class static_device_id_resolver : public device_id_resolver { +public: + static_device_id_resolver(std::string name, std::string id) + : name_(std::move(name)), id_(std::move(id)) + { + } + + [[nodiscard]] std::string device_name() const override { return name_; } + [[nodiscard]] std::string device_id() const override { return id_; } + +private: + std::string name_; + std::string id_; +}; + +/* + * A note for anyone tempted to add a resolver that remembers a previously + * computed device id on disk, to survive a transient read failure (a sandbox + * refusing a firmware-table read, an unreadable /sys, a blocked IOKit call): + * + * It cannot be done safely at this layer. Any such cache is an unsigned file in + * the application's own storage, so the attacker controls both its contents + * *and* whether the fresh read is degraded. Recording the device id and + * replaying it when the read looks weaker therefore reduces to "write the id you + * want, then break one source": a scriptable license bypass, cheaper than + * patching the binary. + * + * Corroborating the cache against the parameters that still read does not fix + * it, because an attacker's own machine legitimately produces matching evidence, + * so they pass the check while substituting any id they like. + * + * The only sound construction stores protected *inputs* and recomputes the id, + * making a forged id require a SHA-256 preimage. That needs the raw parameter + * values to derive a key from, which this layer deliberately never sees, since + * it must not write hardware serials to disk. So it belongs inside the resolver + * that reads them, if it is ever worth building. + * + * This SDK ships store.hpp, so an on-disk cache is a much shorter change here + * than in the reference implementation. That makes the warning more important, + * not less. The memoization in moonbase_device_id_resolver is process-lifetime + * only. It is not persistence. + */ + +/// Binds the current fingerprint while still recognising ids this device was +/// bound to before: the migration path off an older algorithm without a flag day. +/// +/// device_id() always returns the *current* resolver's id, so every new +/// activation binds the current algorithm. The historical resolvers are consulted +/// only when a validator is deciding whether to accept an already-issued license. +/// A fleet therefore migrates as licenses are naturally re-activated, instead of +/// every device re-activating at once, which would burn a second activation seat +/// per device and reset device-scoped trials. +/// +/// \code +/// auto resolver = std::make_shared( +/// std::make_shared(), // binds +/// std::make_shared()); // also accepted +/// \endcode +/// +/// Historical ids are computed lazily, only on a mismatch, and then memoized, so +/// the happy path never pays for them. A historical resolver that throws is +/// skipped: it may simply not work on this platform any more, which just means it +/// cannot vouch for the license. +/// +/// Every accepted id is recomputed from the machine's own hardware. Nothing is +/// read from disk, so widening what a validator accepts does not widen what an +/// attacker can assert. +class migrating_device_id_resolver : public device_id_resolver { +public: + migrating_device_id_resolver( + std::shared_ptr current, + std::vector> previous) + : current_(std::move(current)), previous_(std::move(previous)) + { + if (!current_) { + throw configuration_error("A current device id resolver is required"); + } + } + + template < + typename... Previous, + typename = std::enable_if_t< + (std::is_convertible_v> && ...)>> + explicit migrating_device_id_resolver(std::shared_ptr current, Previous... previous) + : migrating_device_id_resolver( + std::move(current), + std::vector>{std::move(previous)...}) + { + } + + [[nodiscard]] std::string device_name() const override { return current_->device_name(); } + [[nodiscard]] std::string device_id() const override { return current_->device_id(); } + + [[nodiscard]] bool accepts_device_id(const std::string& device_id) const override + { + // An empty id is what a historical resolver that could not read anything + // reduces to. It must never match, or a machine with no identity would + // accept a license bound to another such machine. + if (device_id.empty()) { + return false; + } + + // A mutex and a flag rather than std::once_flag, matching + // moonbase_device_id_resolver: the allocations here can throw, and an + // exception escaping std::call_once deadlocks under ThreadSanitizer, + // whose pthread_once interceptor does not model the reset that path + // performs. The flag is set only after the work completes, so a throw + // simply means the next caller retries. + { + const std::lock_guard lock(previous_ids_mutex_); + if (!previous_ids_computed_) { + previous_ids_.reserve(previous_.size()); + for (const auto& resolver : previous_) { + if (!resolver) { + continue; + } + try { + previous_ids_.push_back(resolver->device_id()); + } catch (...) { + // Cannot vouch for the license on this machine; carry on. + } + } + previous_ids_computed_ = true; + } + } + + return std::any_of( + previous_ids_.begin(), previous_ids_.end(), [&device_id](const std::string& previous) { + return !previous.empty() && previous == device_id; + }); + } + + /// Forwarded so the current resolver stays describable through this wrapper. + [[nodiscard]] std::optional describe_device() const override + { + return current_->describe_device(); + } + +private: + std::shared_ptr current_; + std::vector> previous_; + + mutable std::mutex previous_ids_mutex_; + mutable bool previous_ids_computed_ = false; + mutable std::vector previous_ids_; +}; + +} // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/errors.hpp b/modules/moonbase_licensing/moonbase/errors.hpp index e7df001..00c58ab 100644 --- a/modules/moonbase_licensing/moonbase/errors.hpp +++ b/modules/moonbase_licensing/moonbase/errors.hpp @@ -6,6 +6,7 @@ namespace moonbase { +// New values are appended, never inserted: consumers persist and compare these. enum class error_type { api_error, license_invalid, @@ -13,6 +14,11 @@ enum class error_type { storage_error, configuration_error, operation_not_supported, + /// The license is valid but bound to a different device, or to an older + /// fingerprint version. + license_device_mismatch, + /// No stable hardware identifier could be read, so no device id exists. + device_identity_unavailable, }; class moonbase_error : public std::runtime_error { @@ -62,6 +68,86 @@ class license_invalid_error : public moonbase_error { : moonbase_error(error_type::license_invalid, message) { } + +protected: + // For subclasses that are a more specific kind of "this license is not + // usable here" and want their own error_type. + license_invalid_error(error_type type, const std::string& message) + : moonbase_error(type, message) + { + } +}; + +// The token verified, but its `sig` claim is not this device's id and no +// historical resolver recognised it. +// +// Derives from license_invalid_error deliberately. Existing `catch +// (license_invalid_error&)` sites keep working across the upgrade, and, more +// importantly, licensing's offline grace period keys off that type: a mismatch +// that escaped it would let a license copied from another machine keep running +// for the whole grace window. Code switching on type() must add the new case. +class license_device_mismatch_error : public license_invalid_error { +public: + explicit license_device_mismatch_error(const std::string& message) + : license_invalid_error(error_type::license_device_mismatch, message) + { + } +}; + +// The device fingerprint had nothing machine-specific to hash: either no +// parameter could be read, or the only ones that could are model-level (vendor, +// product and board names, shared by every unit of a product line). +// +// The spec makes both an error rather than hashing what is there, because either +// would hand a whole class of machines the *same* device id, and a license bound +// to it would then validate on all of them. Substituting the host name is nearly +// as bad: it is user-renameable, duplicated across imaged fleets, and +// regenerated on every container start. +// +// Reachable on platforms with no defined identity parameters (Android, BSD, +// anything unknown); when every source fails, such as a container with no DMI or +// a blocked firmware-table read; and on machines whose per-device identifiers are +// simply absent, such as a Linux install with no machine-id or a VM whose SMBIOS +// carries an unset UUID alongside a blank baseboard serial. Opt into the weaker +// host-name id with moonbase_device_id_resolver_options::fallback. +class insufficient_device_identity_error : public moonbase_error { +public: + explicit insufficient_device_identity_error( + std::string platform, + std::string reason = "no identity parameter could be read") + : moonbase_error( + error_type::device_identity_unavailable, + "Could not identify this device (platform: " + platform + "): " + reason), + platform_(std::move(platform)), + reason_(std::move(reason)) + { + } + + [[nodiscard]] const std::string& platform() const noexcept { return platform_; } + [[nodiscard]] const std::string& reason() const noexcept { return reason_; } + +private: + std::string platform_; + std::string reason_; +}; + +// Two fingerprint parameters shared a name, which the material grammar cannot +// express. Unreachable from the built-in readers, so it always means a +// caller-supplied parameter list is wrong: a configuration error, not a +// machine-state one. No matching error_type, because no other Moonbase SDK +// reports this on its error enum. +class duplicate_fingerprint_parameter_error : public configuration_error { +public: + explicit duplicate_fingerprint_parameter_error(std::string name) + : configuration_error("Duplicate fingerprint parameter name: " + name), + parameter_name_(std::move(name)) + { + } + + [[nodiscard]] const std::string& parameter_name() const noexcept { return parameter_name_; } + +private: + std::string parameter_name_; }; class license_expired_error : public moonbase_error { diff --git a/modules/moonbase_licensing/moonbase/fingerprint.hpp b/modules/moonbase_licensing/moonbase/fingerprint.hpp index 9d511ab..847933b 100644 --- a/modules/moonbase_licensing/moonbase/fingerprint.hpp +++ b/modules/moonbase_licensing/moonbase/fingerprint.hpp @@ -1,30 +1,30 @@ #pragma once -#include -#include +// Compatibility header. +// +// moonbase::fingerprint_provider was renamed to moonbase::device_id_resolver in +// 4.0.0, when this SDK adopted the cross-SDK device fingerprint spec. The old +// names still work, so a custom provider keeps compiling across the upgrade, but +// they are deprecated and will be removed in 5.0.0. +// +// New code should include for the interface, +// for the spec implementation, and +// for the algorithm primitives. +// +// Define MOONBASE_DISABLE_DEPRECATED_ALIASES to compile the aliases out now, +// which is the quickest way to find every remaining use in a codebase. + +#include "moonbase/device_id_resolver.hpp" namespace moonbase { -class fingerprint_provider { -public: - virtual ~fingerprint_provider() = default; - [[nodiscard]] virtual std::string device_name() const = 0; - [[nodiscard]] virtual std::string device_id() const = 0; -}; - -class static_fingerprint_provider : public fingerprint_provider { -public: - static_fingerprint_provider(std::string name, std::string id) - : name_(std::move(name)), id_(std::move(id)) - { - } - - [[nodiscard]] std::string device_name() const override { return name_; } - [[nodiscard]] std::string device_id() const override { return id_; } - -private: - std::string name_; - std::string id_; -}; +#if !defined(MOONBASE_DISABLE_DEPRECATED_ALIASES) + +using fingerprint_provider [[deprecated("renamed to moonbase::device_id_resolver")]] = device_id_resolver; + +using static_fingerprint_provider + [[deprecated("renamed to moonbase::static_device_id_resolver")]] = static_device_id_resolver; + +#endif } // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/fingerprint_spec.hpp b/modules/moonbase_licensing/moonbase/fingerprint_spec.hpp new file mode 100644 index 0000000..6f97f80 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/fingerprint_spec.hpp @@ -0,0 +1,856 @@ +#pragma once + +// The Moonbase device fingerprint specification, version 2. +// +// See FINGERPRINT_SPEC.md at the repo root for the normative text and +// tests/vectors/fingerprint-vectors.json for the conformance suite. When the +// spec prose and the vectors disagree, the vectors win: they are what every SDK +// can actually execute. +// +// A license token carries a `sig` claim equal to the device id, and each SDK +// recomputes that id locally and compares it 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 header is byte-exact and deterministic so +// they cannot: given the same platform tag and parameters, it produces the same +// device id as @moonbase.sh/licensing does. +// +// Everything here is pure. There are no OS headers and nothing reads the +// machine, so the whole file compiles and is tested on every platform. That is +// deliberate: it means the Windows SMBIOS parser and the macOS ioreg parser are +// exercised by CI on Linux and macOS runners too, rather than only where they +// happen to run. The platform reads themselves live in +// moonbase_device_id_resolver.hpp. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +// Macros only, no OS surface: this is how Apple platforms are told apart, and +// mac and ios are separate platform tags with different identity parameters. +#include +#endif + +#include "moonbase/detail/crypto/crypto.hpp" +#include "moonbase/detail/unicode/nfc_ascii.hpp" +#include "moonbase/errors.hpp" + +namespace moonbase::fingerprint_spec { + +/// First line of the hashed material. Always carries the same version number as +/// the device id stamp. +inline constexpr std::string_view prefix = "moonbase:fingerprint:v2"; + +/// Spec version, present in both the material prefix and the id stamp. +inline constexpr int version = 2; + +/// Longest permitted canonical value, in characters. +inline constexpr std::size_t max_value_length = 128; + +/// Where a device id's identity came from, and therefore what it may be compared +/// to. Each non-hardware source carries its own stamp so the limitation travels +/// with the value. +enum class device_id_source { + /// Hardware identity. Comparable across every conforming SDK. `mbd2_` + identity, + /// The opt-in host-name fallback. `mbd2n_` + device_name, + /// Scoped identity: stable for this device within one *scope*, and not + /// comparable across scopes or SDKs. The scope is the platform's, not ours: + /// iOS scopes identifierForVendor to the App Store vendor (else the bundle id + /// minus its last component), Android scopes ANDROID_ID to the app signing + /// key. So it is narrower than "publisher": one vendor's two differently + /// signed Android apps do not share an id. `mbd2s_` + scoped, +}; + +using parameter = std::pair; +using parameter_list = std::vector; + +struct device_id_stamp { + /// Fingerprint spec version that produced the digest. + int version = 0; + /// The literal source tag: "", "n", "s", or one a newer SDK introduced. + std::string source_tag; + /// What source_tag means, or nothing when this SDK does not define that tag. + std::optional source; + /// The 64-character lowercase-hex SHA-256. + std::string digest; +}; + +namespace internal { + +// Exactly these parameters describe the individual machine. Everything else a +// platform collects is model-level: vendor, product and board names are +// byte-identical across every unit of a product line, so a material built only +// from those would give every machine of that model the same device id, and each +// would validate the others' licenses. +inline constexpr std::array identifying_params{ + "ioPlatformUuid", + "machineId", + "systemUuid", + "baseboardSerialNumber", + // Scoped sources: identifying within the platform's own scope, which is all + // these platforms allow. See the spec's "Scoped identity" section. + "identifierForVendor", + "androidId", + "deviceName", +}; + +// OEM filler that is not really a value. Compared case-insensitively against the +// canonical value, and only for identifying parameters: a descriptive field +// reading "Default string" is still a fair description of the model, whereas a +// serial number reading it is not a serial number. +inline constexpr std::array not_programmed_values{ + "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", +}; + +[[nodiscard]] inline char to_lower(char value) noexcept +{ + return (value >= 'A' && value <= 'Z') ? static_cast(value - 'A' + 'a') : value; +} + +[[nodiscard]] inline bool equals_ignoring_ascii_case(std::string_view left, std::string_view right) noexcept +{ + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0; index != left.size(); ++index) { + if (to_lower(left[index]) != to_lower(right[index])) { + return false; + } + } + return true; +} + +// The tags this version defines. The grammar a parser *accepts* is deliberately +// wider (see parse_device_id_stamp): a tag introduced by a newer SDK must still +// parse, or a perfectly valid id gets reported as "not a Moonbase device id". +// +// One table, consulted in both directions, so the stamper and the parser cannot +// drift apart. +struct source_tag_entry { + device_id_source source; + std::string_view tag; +}; + +inline constexpr std::array source_tags{{ + {device_id_source::identity, ""}, + {device_id_source::device_name, "n"}, + {device_id_source::scoped, "s"}, +}}; + +[[nodiscard]] inline std::string_view tag_for_source(device_id_source source) noexcept +{ + for (const auto& entry : source_tags) { + if (entry.source == source) { + return entry.tag; + } + } + return {}; +} + +[[nodiscard]] inline std::optional source_for_tag(std::string_view tag) noexcept +{ + for (const auto& entry : source_tags) { + if (entry.tag == tag) { + return entry.source; + } + } + return std::nullopt; +} + +[[nodiscard]] inline bool is_lowercase_hex(std::string_view value) noexcept +{ + return std::all_of(value.begin(), value.end(), [](char character) { + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'); + }); +} + +} // namespace internal + +/// Canonicalize a raw value: NFC, then printable ASCII only, then capped at 128 +/// characters, then space-trimmed at both ends. In that order. +/// +/// Interior spaces are preserved and nothing else is altered: no case folding, +/// no reordering. Truncation runs before trimming, so a value whose 128-character +/// prefix ends in spaces does not keep them. +/// +/// Dropping everything outside U+0020..U+007E does more work than it looks. It +/// makes the material grammar unambiguous, since a value can no longer contain an +/// LF and so cannot forge an extra `name=value` line. It makes the decoding of +/// raw firmware strings very nearly irrelevant, since every byte two decoders +/// would disagree about is discarded. And it absorbs the trailing newline that +/// sysfs reads carry. +[[nodiscard]] inline std::string canonicalize_value(std::string_view value) +{ + auto printable = moonbase::detail::unicode::canonicalize_printable_ascii(value); + + // Pure ASCII by now, so characters and bytes are the same thing. + if (printable.size() > max_value_length) { + printable.resize(max_value_length); + } + + const auto first = printable.find_first_not_of(' '); + if (first == std::string::npos) { + return {}; + } + const auto last = printable.find_last_not_of(' '); + return printable.substr(first, last - first + 1); +} + +/// The platform tag for the host this was compiled for. +[[nodiscard]] inline std::string_view platform_tag() noexcept +{ + // Android must be tested before Linux: it defines both. +#if defined(__ANDROID__) + return "android"; +#elif defined(__APPLE__) + // macOS and iOS are separate tags: they have entirely different identity + // parameters, and an iOS id is scoped where a macOS one is not, so conflating + // them would let two incomparable ids claim the same provenance. Every Apple + // platform other than macOS maps to `ios`, because they all offer the same + // single identifier and nothing else. + // + // Mac Catalyst counts as macOS. The spec states the rule as a runtime pair, + // because Swift's compile-time tests get it wrong, but in C++ the compile-time + // macros reproduce that table exactly: + // + // isMacCatalystApp isiOSAppOnMac running as tag macro state + // false false a real iPhone / iPad ios MACCATALYST 0 + // true false Mac Catalyst mac MACCATALYST 1 + // true true iOS app on Apple silicon ios MACCATALYST 0 + // + // A Catalyst binary is always the middle row and an iOS binary is never it, so + // TARGET_OS_MACCATALYST decides it without a runtime query. Note + // TARGET_OS_IPHONE is 1 for Catalyst too, which is why it cannot be the test. + // + // This matters because a Catalyst app can read *both* identifierForVendor and + // IOKit, so without a rule two SDKs on one Mac would disagree about which to + // use. Hardware identity wins, and the Catalyst app then agrees with an + // Electron or web SDK on the same machine. +#if TARGET_OS_OSX || TARGET_OS_MACCATALYST + return "mac"; +#else + return "ios"; +#endif +#elif defined(_WIN32) + return "windows"; +#elif defined(__linux__) + return "linux"; +#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) + return "bsd"; +#else + return "unknown"; +#endif +} + +/// The identifying parameter names, in spec order. +/// +/// Returned by value so a consumer cannot widen the set the material check +/// accepts. Handing out a reference to the table would let one line of caller +/// code turn "this machine has no identity" into "this model has an identity". +[[nodiscard]] inline std::vector identifying_param_names() +{ + return {internal::identifying_params.begin(), internal::identifying_params.end()}; +} + +[[nodiscard]] inline bool is_identifying_param(std::string_view name) noexcept +{ + return std::find(internal::identifying_params.begin(), internal::identifying_params.end(), name) + != internal::identifying_params.end(); +} + +/// Is this canonical value OEM filler rather than a real identifier? +/// +/// An identifying value that is really 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, so hashing it would hand every unit the same device id. +[[nodiscard]] inline bool is_not_programmed(std::string_view canonical_value) noexcept +{ + if (canonical_value.empty()) { + return false; + } + + // A blank UUID field or a zeroed machine-id. + const bool all_zeroes = std::all_of( + canonical_value.begin(), canonical_value.end(), [](char character) { return character == '0'; }); + const bool all_f = std::all_of(canonical_value.begin(), canonical_value.end(), [](char character) { + return character == 'f' || character == 'F'; + }); + if (all_zeroes || all_f) { + return true; + } + + return std::any_of( + internal::not_programmed_values.begin(), + internal::not_programmed_values.end(), + [canonical_value](std::string_view filler) { + return internal::equals_ignoring_ascii_case(canonical_value, filler); + }); +} + +/// Constants shared across devices that are rejected for `androidId` only. +/// +/// Keyed by parameter on purpose. The shared placeholder list applies to every +/// identifying parameter, so putting this there would change the device id of a +/// machine that happens to report the same string as, say, a baseboard serial, +/// which the spec forbids without a version bump. A value that is meaningless as +/// an androidId can be a perfectly good Windows serial. +namespace internal { + +inline constexpr std::array rejected_android_ids{ + // 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. + // Valid hex, so the format rule cannot catch it. + "9774d56d682e549c", +}; + +} // namespace internal + +/// Does this canonical value look like a real Android SSAID? +/// +/// `^[0-9a-f]{1,16}$` plus the placeholder rule. That regex is what makes the +/// classic mistake *mechanically* impossible rather than merely documented: +/// reading the `Settings.Secure.ANDROID_ID` static field yields the key name +/// "android_id", which is identical on every device and is not hex, so it can +/// never reach the material. JUCE's SystemStats::getUniqueDeviceID() has exactly +/// that defect. +/// +/// The bound is 1..16 rather than exactly 16 because AOSP before 8.0 generated the +/// value with Long.toHexString, which drops leading zeros; requiring 16 would +/// reject legitimate ids on roughly one in sixteen pre-Oreo devices. +[[nodiscard]] inline bool is_valid_android_id(std::string_view canonical_value) noexcept +{ + if (canonical_value.empty() || canonical_value.size() > 16 + || !internal::is_lowercase_hex(canonical_value) || is_not_programmed(canonical_value)) { + return false; + } + + return std::none_of( + internal::rejected_android_ids.begin(), + internal::rejected_android_ids.end(), + [canonical_value](std::string_view rejected) { + return internal::equals_ignoring_ascii_case(canonical_value, rejected); + }); +} + +/// Can this identifying value stand for the machine? +/// +/// Not an unprogrammed placeholder, and matching the format the spec pins for that +/// parameter where it pins one. Only `androidId` has a pinned format, deliberately: +/// the rule exists to make one specific, currently-shipping defect impossible +/// rather than merely documented, and every extra rule is a new way to reject a +/// value some real device legitimately reports. +[[nodiscard]] inline bool is_usable_identity(std::string_view name, std::string_view canonical_value) noexcept +{ + if (is_not_programmed(canonical_value)) { + return false; + } + if (name == "androidId") { + return is_valid_android_id(canonical_value); + } + return true; +} + +/// Canonicalize every value and drop the pairs that do not survive. +/// +/// A pair is dropped when its canonical value is empty, or when its name is +/// identifying and the value is an unprogrammed placeholder. Both filters run +/// before the duplicate check, so two entries sharing a name where one is empty +/// are not a duplicate. +/// +/// \throws duplicate_fingerprint_parameter_error when two surviving pairs share a +/// name. The material grammar cannot express it, so it is a collection bug. +[[nodiscard]] inline parameter_list canonicalize_params(const parameter_list& params) +{ + parameter_list kept; + kept.reserve(params.size()); + + for (const auto& param : params) { + // Not a structured binding: capturing one in a lambda is C++20, and this + // header has to compile as C++17 on every supported compiler. + const std::string& name = param.first; + + auto value = canonicalize_value(param.second); + if (value.empty()) { + continue; + } + if (is_identifying_param(name) && !is_usable_identity(name, value)) { + continue; + } + + const bool already_kept = std::any_of( + kept.begin(), kept.end(), [&name](const parameter& kept_param) { return kept_param.first == name; }); + if (already_kept) { + throw duplicate_fingerprint_parameter_error(name); + } + + kept.emplace_back(name, std::move(value)); + } + + return kept; +} + +/// Assemble the material to be hashed. +/// +/// Lines are **joined** by a single LF, never terminated by one: 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, and +/// the vectors check it explicitly. +/// +/// \throws insufficient_device_identity_error when nothing survives +/// canonicalization, or when nothing that survives identifies this +/// individual machine. Neither may be hashed anyway: each would hand a +/// whole class of machines the same device id. +/// \throws duplicate_fingerprint_parameter_error via canonicalize_params. +[[nodiscard]] inline std::string build_fingerprint_material( + std::string_view platform, + const parameter_list& params) +{ + const auto kept = canonicalize_params(params); + if (kept.empty()) { + throw insufficient_device_identity_error(std::string(platform)); + } + + // Enforced here rather than in the resolver so it also binds a custom reader + // and a native bridge assembling material directly. On these platforms the + // host name is a constant (since iOS 17 gethostname() returns "localhost", and + // UIDevice.name the model name), so accepting it would be worse than failing: + // one activation would validate across the whole install base. + const bool scoped_platform = platform == "ios" || platform == "android"; + const bool has_device_name = std::any_of( + kept.begin(), kept.end(), [](const parameter& param) { return param.first == "deviceName"; }); + if (scoped_platform && has_device_name) { + throw insufficient_device_identity_error( + std::string(platform), + "the host-name fallback is not available on this platform, where the host name is the" + " same on every device"); + } + + const bool identifies_this_machine = std::any_of( + kept.begin(), kept.end(), [](const parameter& param) { return is_identifying_param(param.first); }); + if (!identifies_this_machine) { + std::string names; + for (const auto& [name, value] : kept) { + if (!names.empty()) { + names += ", "; + } + names += name; + } + throw insufficient_device_identity_error( + std::string(platform), + "only model-level parameters could be read (" + names + + "), none of which identify this individual machine"); + } + + std::string material; + material.append(prefix); + material += "\nplatform="; + material.append(platform); + for (const auto& [name, value] : kept) { + material += '\n'; + material += name; + material += '='; + material += value; + } + + return material; +} + +/// Hash material into a bare digest: 64 lowercase hex characters of SHA-256. +[[nodiscard]] inline std::string fingerprint_digest(std::string_view material) +{ + return moonbase::detail::sha256_hex(material); +} + +/// Prefix a digest with its version and source, producing the wire-form device id. +[[nodiscard]] inline std::string stamp_device_id( + std::string_view digest, + device_id_source source = device_id_source::identity) +{ + std::string out = "mbd"; + out += std::to_string(version); + out.append(internal::tag_for_source(source)); + out += '_'; + out.append(digest); + return out; +} + +/// Hash material and stamp it: the device id sent to Moonbase and stored in `sig`. +[[nodiscard]] inline std::string fingerprint_device_id( + std::string_view material, + device_id_source source = device_id_source::identity) +{ + return stamp_device_id(fingerprint_digest(material), source); +} + +/// Recover the version and source from a device id, or nothing if it is not a +/// Moonbase stamp. +/// +/// Because the version is recoverable from the id, a validator can tell an +/// out-of-date SDK (the binding is newer than what it computes) from a stale +/// binding (the binding is older), and say something better than "wrong device". +/// A bare digest, a custom resolver's id, or an id from an SDK predating +/// versioned fingerprints all return nothing and are compared literally. +/// +/// Strict by design: uppercase hex, a truncated digest and a missing separator +/// all fail to parse rather than being coerced. +[[nodiscard]] inline std::optional parse_device_id_stamp(std::string_view device_id) +{ + constexpr std::string_view lead = "mbd"; + if (device_id.size() < lead.size() || device_id.substr(0, lead.size()) != lead) { + return std::nullopt; + } + + std::size_t cursor = lead.size(); + const std::size_t digits_begin = cursor; + while (cursor != device_id.size() && device_id[cursor] >= '0' && device_id[cursor] <= '9') { + ++cursor; + } + + const std::size_t digit_count = cursor - digits_begin; + // At least one digit, and few enough that the value cannot overflow an int. + // JavaScript would happily produce a float for a 40-digit version; no + // validator will ever see one, and refusing is the safer disagreement. + if (digit_count == 0 || digit_count > 9) { + return std::nullopt; + } + + // [a-z]*, not a fixed set: a tag this SDK does not define must still parse, so + // that a newer SDK can introduce one without a version bump. Digits cannot + // appear in it and '_' terminates it, so the split from the version is + // unambiguous. Uppercase is not a tag, so mbd2S_ correctly fails to parse. + const std::size_t tag_begin = cursor; + while (cursor != device_id.size() && device_id[cursor] >= 'a' && device_id[cursor] <= 'z') { + ++cursor; + } + const auto source_tag = device_id.substr(tag_begin, cursor - tag_begin); + + if (cursor == device_id.size() || device_id[cursor] != '_') { + return std::nullopt; + } + ++cursor; + + const auto digest = device_id.substr(cursor); + if (digest.size() != 64 || !internal::is_lowercase_hex(digest)) { + return std::nullopt; + } + + int parsed_version = 0; + for (std::size_t index = digits_begin; index != digits_begin + digit_count; ++index) { + parsed_version = parsed_version * 10 + (device_id[index] - '0'); + } + + return device_id_stamp{ + parsed_version, + std::string(source_tag), + internal::source_for_tag(source_tag), + std::string(digest)}; +} + +// --------------------------------------------------------------------------- +// Source parsers. Pure, so the platform readers stay thin and every one of these +// is tested on every platform. + +/// Normalize a platform UUID for the material: hyphens removed, uppercased. +[[nodiscard]] inline std::string normalize_platform_uuid(std::string_view raw) +{ + std::string out; + out.reserve(raw.size()); + for (const char character : raw) { + if (character == '-') { + continue; + } + out.push_back( + (character >= 'a' && character <= 'z') ? static_cast(character - 'a' + 'A') : character); + } + return out; +} + +/// Extract IOPlatformUUID from `ioreg -rd1 -c IOPlatformExpertDevice` output. +/// +/// Kept for consumers that already have ioreg output; the resolver itself reads +/// IOKit directly, which works inside the App Sandbox where spawning a process +/// does not. +[[nodiscard]] inline std::string parse_ioreg_platform_uuid(std::string_view ioreg_output) +{ + constexpr std::string_view key = "\"IOPlatformUUID\""; + const auto key_at = ioreg_output.find(key); + if (key_at == std::string_view::npos) { + return {}; + } + + const auto skip_spaces = [&ioreg_output](std::size_t from) { + while (from != ioreg_output.size() + && (ioreg_output[from] == ' ' || ioreg_output[from] == '\t' || ioreg_output[from] == '\r' + || ioreg_output[from] == '\n')) { + ++from; + } + return from; + }; + + auto cursor = skip_spaces(key_at + key.size()); + if (cursor == ioreg_output.size() || ioreg_output[cursor] != '=') { + return {}; + } + cursor = skip_spaces(cursor + 1); + if (cursor == ioreg_output.size() || ioreg_output[cursor] != '"') { + return {}; + } + ++cursor; + + const auto end = ioreg_output.find('"', cursor); + if (end == std::string_view::npos || end == cursor) { + return {}; + } + + return normalize_platform_uuid(ioreg_output.substr(cursor, end - cursor)); +} + +/// Does this canonical value look like a real machine-id(5)? +[[nodiscard]] inline bool is_valid_machine_id(std::string_view canonical_value) noexcept +{ + return canonical_value.size() == 32 && internal::is_lowercase_hex(canonical_value) + && !is_not_programmed(canonical_value); +} + +/// Pick the first machine-id source holding a valid id. +/// +/// Each source is validated before selection 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. +[[nodiscard]] inline std::string select_machine_id(std::initializer_list sources) +{ + for (const auto source : sources) { + auto candidate = canonicalize_value(source); + if (is_valid_machine_id(candidate)) { + return candidate; + } + } + return {}; +} + +namespace internal { + +struct smbios_structure { + unsigned char type = 0; + /// The formatted area, indexed from the structure header (byte 0 = type). + const unsigned char* formatted = nullptr; + std::size_t formatted_size = 0; + /// Resolved string table; a string-index field holding N maps to strings[N - 1]. + std::vector strings; +}; + +// Firmware string bytes are decoded as Latin-1, matching the reference SDK's +// Buffer.toString('latin1'). SMBIOS strings are nominally ASCII but OEMs ship +// worse, and canonicalization discards everything above U+007E anyway, so the +// choice is very nearly immaterial. Very nearly: it matters when firmware bytes +// happen to form a valid UTF-8 combining mark after an ASCII byte, where a UTF-8 +// decoder would let the mark annihilate that character and a Latin-1 decoder +// would not. Matching the reference removes the last way two conforming SDKs +// could disagree. +[[nodiscard]] inline std::string latin1_to_utf8(const unsigned char* data, std::size_t size) +{ + std::string out; + out.reserve(size); + for (std::size_t index = 0; index != size; ++index) { + const unsigned char byte = data[index]; + if (byte < 0x80) { + out.push_back(static_cast(byte)); + } else { + out.push_back(static_cast(0xC0U | (byte >> 6U))); + out.push_back(static_cast(0x80U | (byte & 0x3FU))); + } + } + return out; +} + +/// Walk an SMBIOS structure table (with no leading RawSMBIOSData header). +[[nodiscard]] inline std::vector parse_smbios_structures( + const unsigned char* data, + std::size_t size) +{ + std::vector structures; + std::size_t offset = 0; + + while (offset + 4 <= size) { + const unsigned char type = data[offset]; + const std::size_t length = data[offset + 1]; + // A structure shorter than its own header, or one running off the end of + // the table, means the table is malformed from here on. + if (length < 4 || offset + length > size) { + break; + } + + smbios_structure structure; + structure.type = type; + structure.formatted = data + offset; + structure.formatted_size = length; + + // The string table follows the formatted area: NUL-terminated strings + // ending in a double-NUL. A structure with no strings is just the + // double-NUL. + std::size_t cursor = offset + length; + if (cursor + 1 < size && data[cursor] == 0 && data[cursor + 1] == 0) { + cursor += 2; + } else { + while (cursor < size) { + std::size_t end = cursor; + while (end < size && data[end] != 0) { + ++end; + } + structure.strings.push_back(latin1_to_utf8(data + cursor, end - cursor)); + cursor = end + 1; + if (cursor < size && data[cursor] == 0) { + cursor += 1; + break; + } + } + } + + structures.push_back(std::move(structure)); + + if (type == 127) { // End-of-table. + break; + } + + offset = cursor; + } + + return structures; +} + +/// Resolve a string-index field. Index 0, or one past the end of the table, +/// means "no string". +[[nodiscard]] inline std::string resolve_smbios_string( + const smbios_structure& structure, + std::size_t field_offset) +{ + // Bounded 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. + if (field_offset >= structure.formatted_size) { + return {}; + } + + const std::size_t index = structure.formatted[field_offset]; + if (index == 0 || index > structure.strings.size()) { + return {}; + } + + return structure.strings[index - 1]; +} + +/// Format a 16-byte UUID field as uppercase hex. +/// +/// The raw bytes in order: no hyphens, and deliberately **not** the +/// SMBIOS-canonical little-endian swap of the first three fields. The value will +/// therefore not match what dmidecode or Win32_ComputerSystemProduct display, +/// which is intentional and specified. An SDK reading the UUID through WMI must +/// undo that swap. +[[nodiscard]] inline std::string format_smbios_uuid( + const smbios_structure& structure, + std::size_t field_offset) +{ + if (field_offset + 16 > structure.formatted_size) { + return {}; + } + + const unsigned char* bytes = structure.formatted + field_offset; + + // All-00 or all-FF means "not set", so fleets of VMs with unset UUIDs cannot + // collide on one device id. + bool all_zeroes = true; + bool all_ones = true; + for (std::size_t index = 0; index != 16; ++index) { + all_zeroes = all_zeroes && bytes[index] == 0x00; + all_ones = all_ones && bytes[index] == 0xFF; + } + if (all_zeroes || all_ones) { + return {}; + } + + static constexpr char hex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(32); + for (std::size_t index = 0; index != 16; ++index) { + out.push_back(hex[bytes[index] >> 4U]); + out.push_back(hex[bytes[index] & 0x0FU]); + } + return out; +} + +} // namespace internal + +/// Extract the Windows identity parameters from a raw SMBIOS structure table. +/// +/// Takes the first type-1 (System) and the first type-2 (Baseboard) structure; +/// later structures of the same type are ignored. 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 or +/// vCPU count, so collecting them would change the device id every time a VM is +/// resized. +/// +/// Parameters are emitted even when their value is empty. Describing the +/// firmware is this function's job; deciding what counts is canonicalization's. +[[nodiscard]] inline parameter_list parse_smbios_params(const unsigned char* data, std::size_t size) +{ + parameter_list params; + if (data == nullptr || size == 0) { + return params; + } + + const auto structures = internal::parse_smbios_structures(data, size); + const auto find_first = [&structures](unsigned char type) { + return std::find_if(structures.begin(), structures.end(), [type](const internal::smbios_structure& s) { + return s.type == type; + }); + }; + + if (const auto system = find_first(1); system != structures.end()) { + params.emplace_back("systemManufacturer", internal::resolve_smbios_string(*system, 0x04)); + params.emplace_back("systemProductName", internal::resolve_smbios_string(*system, 0x05)); + params.emplace_back("systemUuid", internal::format_smbios_uuid(*system, 0x08)); + } + + if (const auto baseboard = find_first(2); baseboard != structures.end()) { + params.emplace_back("baseboardManufacturer", internal::resolve_smbios_string(*baseboard, 0x04)); + params.emplace_back("baseboardProduct", internal::resolve_smbios_string(*baseboard, 0x05)); + params.emplace_back("baseboardSerialNumber", internal::resolve_smbios_string(*baseboard, 0x07)); + } + + return params; +} + +[[nodiscard]] inline parameter_list parse_smbios_params(const std::vector& data) +{ + return parse_smbios_params(data.data(), data.size()); +} + +} // namespace moonbase::fingerprint_spec diff --git a/modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp b/modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp new file mode 100644 index 0000000..db9c9c2 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp @@ -0,0 +1,395 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +#include "moonbase/detail/crypto/crypto.hpp" +#include "moonbase/device_id_resolver.hpp" + +namespace moonbase { + +// The device id this SDK computed before it adopted the cross-SDK fingerprint +// spec: a bare, unstamped SHA-256 over `moonbase-cpp:fingerprint:v1` material. +// +// FROZEN. Everything below is load-bearing for licenses already issued against +// it, and "fixing" any of it would break exactly the bindings this class exists +// to keep accepting. That includes the parts that are plainly wrong: +// +// * The material is LF-*terminated*, where the spec joins lines with LF. +// * Values get an ASCII trim only: no NFC, no non-ASCII filtering, no length +// cap, no placeholder rejection, no duplicate-name detection. +// * Linux reads board_serial (mode 0400, so the id depends on privilege), +// otherwise bios_* (which change on a firmware update), plus lscpu (whose +// labels are translated, so the id depends on LANG). +// * Windows collects SMBIOS type 4, whose structure count tracks the vCPU +// count, and builds the RSMB provider signature byte-reversed, so +// GetSystemFirmwareTable almost certainly returned 0 and every Windows +// device id fell through to the host-name branch in device_id(). +// * An empty parameter set silently hashes the host name, handing every +// unidentifiable machine on a platform one shared id. +// +// Use it only as a historical resolver inside a migrating_device_id_resolver, so +// existing licenses keep validating while new activations bind the spec id. See +// "Migrating from 3.x" in the README. +class legacy_cpp_device_id_resolver : public device_id_resolver { +public: + using identity_parameter = std::pair; + + [[nodiscard]] static std::string platform_tag() + { +#if defined(__APPLE__) + return "mac"; +#elif defined(_WIN32) + return "windows"; +#elif defined(__ANDROID__) + return "android"; +#elif defined(__linux__) + return "linux"; +#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__) + return "bsd"; +#else + return "unknown"; +#endif + } + + [[nodiscard]] static std::string hash_identity_parameters( + const std::vector& parameters, + std::string_view platform = platform_tag()) + { + std::string material; + material += "moonbase-cpp:fingerprint:v1\n"; + material += "platform="; + material.append(platform.data(), platform.size()); + material += "\n"; + + for (const auto& parameter : parameters) { + auto name = trim_ascii(parameter.first); + auto value = trim_ascii(parameter.second); + if (!name.empty() && !value.empty()) { + material += name; + material += "="; + material += value; + material += "\n"; + } + } + + return detail::sha256_hex(material); + } + + [[nodiscard]] static std::vector identity_parameters() + { + std::vector parameters; + +#if defined(_WIN32) + append_windows_identity_parameters(parameters); +#elif defined(__APPLE__) + auto uuid = trim_ascii(command_output( + "ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | " + "awk -F\\\" '/IOPlatformUUID/{print $4; exit}'")); + uuid.erase(std::remove(uuid.begin(), uuid.end(), '-'), uuid.end()); + append_parameter(parameters, "ioPlatformUuid", uuid); +#elif defined(__linux__) && !defined(__ANDROID__) + const auto board_serial = trim_ascii(read_file("/sys/class/dmi/id/board_serial")); + if (!board_serial.empty()) { + append_parameter(parameters, "boardSerial", board_serial); + } else { + append_parameter(parameters, "biosDate", read_file("/sys/class/dmi/id/bios_date")); + append_parameter(parameters, "biosRelease", read_file("/sys/class/dmi/id/bios_release")); + append_parameter(parameters, "biosVendor", read_file("/sys/class/dmi/id/bios_vendor")); + append_parameter(parameters, "biosVersion", read_file("/sys/class/dmi/id/bios_version")); + } + + const auto cpu_data = command_output("lscpu 2>/dev/null"); + if (!cpu_data.empty()) { + append_parameter(parameters, "cpuFamily", linux_cpu_field(cpu_data, "CPU family:")); + append_parameter(parameters, "cpuModel", linux_cpu_field(cpu_data, "Model:")); + append_parameter(parameters, "cpuModelName", linux_cpu_field(cpu_data, "Model name:")); + append_parameter(parameters, "cpuVendor", linux_cpu_field(cpu_data, "Vendor ID:")); + } +#endif + + return parameters; + } + + [[nodiscard]] std::string device_name() const override + { +#if defined(_WIN32) + char buffer[128]{}; + DWORD size = static_cast(sizeof(buffer)) - 1; + if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer, &size)) { + return std::string(buffer, size); + } + return {}; +#else + std::array buffer{}; + if (gethostname(buffer.data(), buffer.size() - 1) == 0) { + auto name = std::string(buffer.data()); +#if defined(__APPLE__) + const auto suffix = std::string(".local"); + if (name.size() >= suffix.size()) { + auto tail = name.substr(name.size() - suffix.size()); + std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (tail == suffix) { + name.erase(name.size() - suffix.size()); + } + } +#endif + return name; + } + return {}; +#endif + } + + [[nodiscard]] std::string device_id() const override + { + auto parameters = identity_parameters(); + if (parameters.empty()) { + append_parameter(parameters, "deviceName", device_name()); + } + return hash_identity_parameters(parameters); + } + +private: + [[nodiscard]] static std::string read_file(const std::string& path) + { + std::ifstream file(path); + if (!file) { + return {}; + } + std::ostringstream out; + out << file.rdbuf(); + return out.str(); + } + + [[nodiscard]] static std::string command_output(const std::string& command) + { +#if defined(_WIN32) + (void)command; + return {}; +#else + std::array buffer{}; + std::string result; + std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); + if (!pipe) { + return {}; + } + while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { + result += buffer.data(); + } + return result; +#endif + } + + [[nodiscard]] static std::string trim_ascii(std::string value) + { + while (!value.empty() && + (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { + value.pop_back(); + } + while (!value.empty() && + (value.front() == '\n' || value.front() == '\r' || value.front() == ' ' || value.front() == '\t')) { + value.erase(value.begin()); + } + return value; + } + + [[nodiscard]] static std::string linux_cpu_field(const std::string& lscpu_output, const std::string& key) + { + const auto key_index = lscpu_output.find(key); + if (key_index == std::string::npos) { + return {}; + } + + const auto colon = lscpu_output.find(':', key_index); + if (colon == std::string::npos) { + return {}; + } + + const auto end = lscpu_output.find('\n', colon); + return trim_ascii(lscpu_output.substr( + colon + 1, + end == std::string::npos ? std::string::npos : end - colon - 1)); + } + + static void append_parameter( + std::vector& parameters, + std::string name, + std::string value) + { + name = trim_ascii(std::move(name)); + value = trim_ascii(std::move(value)); + if (!name.empty() && !value.empty()) { + parameters.emplace_back(std::move(name), std::move(value)); + } + } + +#if defined(_WIN32) + [[nodiscard]] static std::string windows_string_from_offset( + const std::vector& content, + const std::vector& strings, + std::size_t byte_offset) + { + if (byte_offset >= content.size()) { + return {}; + } + + const auto index = static_cast(content[byte_offset]); + if (index == 0 || index > strings.size()) { + return {}; + } + + return std::string(strings[index - 1]); + } + + [[nodiscard]] static std::size_t windows_bounded_string_length(const char* value, std::size_t max_length) + { + std::size_t length = 0; + while (length < max_length && value[length] != '\0') { + ++length; + } + return length; + } + + static void append_windows_identity_parameters(std::vector& parameters) + { + constexpr DWORD signature = + static_cast('R') | + (static_cast('S') << 8U) | + (static_cast('M') << 16U) | + (static_cast('B') << 24U); + + const auto table_size = GetSystemFirmwareTable(signature, 0, nullptr, 0); + if (table_size == 0) { + return; + } + + std::vector smbios(table_size); + if (GetSystemFirmwareTable(signature, 0, smbios.data(), table_size) != table_size) { + return; + } + + struct raw_smbios_data { + std::uint8_t unused[4]; + std::uint32_t length; + }; + + struct smbios_header { + std::uint8_t id; + std::uint8_t length; + std::uint16_t handle; + }; + + if (smbios.size() < sizeof(raw_smbios_data)) { + return; + } + + raw_smbios_data raw{}; + std::memcpy(&raw, smbios.data(), sizeof(raw)); + if (smbios.size() < sizeof(raw_smbios_data) + raw.length) { + return; + } + + std::vector content( + smbios.begin() + static_cast(sizeof(raw_smbios_data)), + smbios.begin() + static_cast(sizeof(raw_smbios_data) + raw.length)); + + std::size_t offset = 0; + while (offset < content.size()) { + if (content.size() - offset < sizeof(smbios_header)) { + break; + } + + smbios_header header{}; + std::memcpy(&header, content.data() + offset, sizeof(header)); + if (header.length == 0 || content.size() - offset < header.length) { + break; + } + + std::vector strings; + auto string_offset = offset + header.length; + while (string_offset < content.size()) { + const auto* str = reinterpret_cast(content.data() + string_offset); + const auto max_length = content.size() - string_offset; + const auto length = windows_bounded_string_length(str, max_length); + if (length == 0) { + break; + } + strings.emplace_back(str, length); + string_offset += std::min(length + 1, max_length); + } + + const auto end_of_table = std::min( + content.size(), + std::max(offset + static_cast(header.length) + 2, string_offset + 1)); + + const auto from_offset = [&](std::size_t byte_offset) { + return windows_string_from_offset(content, strings, offset + byte_offset); + }; + + switch (header.id) { + case 1: { + append_parameter(parameters, "systemManufacturer", from_offset(0x04)); + append_parameter(parameters, "systemProductName", from_offset(0x05)); + + if (offset + 0x08 + 16 <= content.size()) { + std::ostringstream hex; + hex << std::uppercase << std::hex << std::setfill('0'); + for (std::size_t index = 0; index != 16; ++index) { + hex << std::setw(2) << static_cast(content[offset + 0x08 + index]); + } + append_parameter(parameters, "systemUuid", hex.str()); + } + break; + } + + case 2: + append_parameter(parameters, "baseboardManufacturer", from_offset(0x04)); + append_parameter(parameters, "baseboardProduct", from_offset(0x05)); + append_parameter(parameters, "baseboardVersion", from_offset(0x06)); + append_parameter(parameters, "baseboardSerialNumber", from_offset(0x07)); + append_parameter(parameters, "baseboardAssetTag", from_offset(0x08)); + break; + + case 4: + append_parameter(parameters, "processorManufacturer", from_offset(0x07)); + append_parameter(parameters, "processorVersion", from_offset(0x10)); + append_parameter(parameters, "processorAssetTag", from_offset(0x21)); + append_parameter(parameters, "processorPartNumber", from_offset(0x22)); + break; + + default: + break; + } + + offset = end_of_table; + } + } +#endif +}; + +} // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/licensing.hpp b/modules/moonbase_licensing/moonbase/licensing.hpp index 37fc0a6..ea2306d 100644 --- a/modules/moonbase_licensing/moonbase/licensing.hpp +++ b/modules/moonbase_licensing/moonbase/licensing.hpp @@ -9,6 +9,13 @@ #include #include "moonbase/client.hpp" +// Before 4.0.0 this header pulled in fingerprint.hpp and default_fingerprint.hpp, +// so a consumer including only could name +// fingerprint_provider, static_fingerprint_provider and +// default_fingerprint_provider. Those aliases are deprecated, not gone, so keep +// providing them here until they are removed in 5.0.0: dropping the includes would +// turn a documented deprecation warning into a hard compile error for exactly the +// consumers the aliases exist to protect. #include "moonbase/default_fingerprint.hpp" #include "moonbase/detail/base64.hpp" #include "moonbase/errors.hpp" @@ -17,6 +24,7 @@ #ifndef MOONBASE_DISABLE_CURL_TRANSPORT #include "moonbase/http_curl.hpp" #endif +#include "moonbase/moonbase_device_id_resolver.hpp" #include "moonbase/store.hpp" #include "moonbase/types.hpp" #include "moonbase/validator.hpp" @@ -28,18 +36,18 @@ class licensing { explicit licensing( licensing_options options, std::shared_ptr store = nullptr, - std::shared_ptr fingerprints = nullptr, + std::shared_ptr device_ids = nullptr, std::shared_ptr transport = nullptr) : options_(normalize_and_validate(std::move(options))), store_(std::move(store)), - fingerprints_(std::move(fingerprints)), + device_ids_(std::move(device_ids)), transport_(std::move(transport)) { if (!store_) { store_ = std::make_shared(); } - if (!fingerprints_) { - fingerprints_ = std::make_shared(); + if (!device_ids_) { + device_ids_ = std::make_shared(); } if (!transport_) { #ifdef MOONBASE_DISABLE_CURL_TRANSPORT @@ -49,8 +57,8 @@ class licensing { transport_ = std::make_shared(); #endif } - validator_ = std::make_shared(options_, fingerprints_); - client_ = std::make_shared(options_, fingerprints_, validator_, transport_); + validator_ = std::make_shared(options_, device_ids_); + client_ = std::make_shared(options_, device_ids_, validator_, transport_); } [[nodiscard]] activation_request request_activation() const @@ -85,8 +93,8 @@ class licensing { [[nodiscard]] std::string generate_device_token() const { const nlohmann::json payload{ - {"id", fingerprints_->device_id()}, - {"name", fingerprints_->device_name()}, + {"id", device_ids_->device_id()}, + {"name", device_ids_->device_name()}, {"productId", options_.product_id}, // The Moonbase API expects this to always be "JWT". {"format", "JWT"}, @@ -240,8 +248,33 @@ class licensing { [[nodiscard]] license_validator& validator() noexcept { return *validator_; } [[nodiscard]] const license_validator& validator() const noexcept { return *validator_; } - [[nodiscard]] moonbase::fingerprint_provider& fingerprint() noexcept { return *fingerprints_; } - [[nodiscard]] const moonbase::fingerprint_provider& fingerprint() const noexcept { return *fingerprints_; } + [[nodiscard]] moonbase::device_id_resolver& device_resolver() noexcept { return *device_ids_; } + [[nodiscard]] const moonbase::device_id_resolver& device_resolver() const noexcept { return *device_ids_; } + + // How this machine's device id was derived, for diagnostics. Names of the + // contributing identity parameters only, never their values. Empty when the + // resolver does not describe itself, as a custom one need not. + // + // Throws insufficient_device_identity_error when this machine has no readable + // identity, the same as device_resolver().device_id() would. + [[nodiscard]] std::optional describe_device() const + { + return device_ids_->describe_device(); + } + +#if !defined(MOONBASE_DISABLE_DEPRECATED_ALIASES) + [[deprecated("renamed to device_resolver()")]] [[nodiscard]] moonbase::device_id_resolver& + fingerprint() noexcept + { + return *device_ids_; + } + + [[deprecated("renamed to device_resolver()")]] [[nodiscard]] const moonbase::device_id_resolver& + fingerprint() const noexcept + { + return *device_ids_; + } +#endif private: static licensing_options normalize_and_validate(licensing_options options) @@ -261,7 +294,7 @@ class licensing { licensing_options options_; std::shared_ptr store_; - std::shared_ptr fingerprints_; + std::shared_ptr device_ids_; std::shared_ptr transport_; std::shared_ptr validator_; std::shared_ptr client_; diff --git a/modules/moonbase_licensing/moonbase/moonbase.hpp b/modules/moonbase_licensing/moonbase/moonbase.hpp index f46a6c4..b8fa50f 100644 --- a/modules/moonbase_licensing/moonbase/moonbase.hpp +++ b/modules/moonbase_licensing/moonbase/moonbase.hpp @@ -1,10 +1,18 @@ #pragma once #include "moonbase/client.hpp" +// default_fingerprint.hpp and fingerprint.hpp are the deprecated-alias headers +// for the pre-4.0.0 names. Kept in the umbrella so that including +// still compiles code written against 3.x; the aliases +// only warn where they are actually used. #include "moonbase/default_fingerprint.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" #include "moonbase/fingerprint.hpp" +#include "moonbase/fingerprint_spec.hpp" #include "moonbase/http.hpp" +#include "moonbase/legacy_fingerprint.hpp" +#include "moonbase/moonbase_device_id_resolver.hpp" #ifndef MOONBASE_DISABLE_CURL_TRANSPORT #include "moonbase/http_curl.hpp" #endif diff --git a/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp new file mode 100644 index 0000000..3e97495 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp @@ -0,0 +1,574 @@ +#pragma once + +// The default device id resolver: the Moonbase device fingerprint spec, v2. +// +// Builds the `moonbase:fingerprint:v2` material from native hardware +// identifiers (IOPlatformUUID via IOKit on macOS, machine-id plus world-readable +// DMI on Linux, SMBIOS on Windows) and stamps its SHA-256 as `mbd2_`. Every +// Moonbase SDK that implements the spec produces the same id on a given machine, +// so a license activated by one validates in the others. +// +// No subprocess is spawned on any platform, and no root-only file is read. That +// matters for plugins: IOKit works inside the App Sandbox and under a hardened +// runtime, where spawning `ioreg` does not, and it keeps the device id +// independent of whether the process happens to run elevated. +// +// The algorithm itself lives in fingerprint_spec.hpp, which has no OS headers +// and is tested on every platform. This header is only the reads. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + +#if defined(__APPLE__) +#include +#if TARGET_OS_IPHONE && !TARGET_OS_MACCATALYST +// identifierForVendor lives in UIKit, but reaching it needs no Objective-C source +// and no framework wrapper: the Objective-C runtime's C API is callable straight +// from C++, which keeps this SDK usable without JUCE or any other framework. +#define MOONBASE_FINGERPRINT_USE_UIKIT 1 +#include +#include +#endif +#endif + +#if defined(__ANDROID__) +// Plain JNI, part of the NDK rather than any framework. The one thing a native +// library cannot obtain by itself is the application Context, so the host hands +// that in once; see moonbase::android::set_jni_environment below. +#include +#endif + +#if defined(__APPLE__) && !defined(MOONBASE_FINGERPRINT_NO_IOKIT) +#include +// Mac Catalyst included: it runs on macOS, can read IOKit, and takes the `mac` +// platform tag, so it must use hardware identity to agree with an Electron or web +// SDK on the same machine. TARGET_OS_IPHONE is 1 for Catalyst, so it cannot be the +// test; see platform_tag() in fingerprint_spec.hpp for the full rule. +#if ((defined(TARGET_OS_OSX) && TARGET_OS_OSX) \ + || (defined(TARGET_OS_MACCATALYST) && TARGET_OS_MACCATALYST)) \ + && !defined(MOONBASE_FINGERPRINT_NO_IOKIT) +#define MOONBASE_FINGERPRINT_USE_IOKIT 1 +#include +#include +#include +#endif +#endif + +#include "moonbase/device_id_resolver.hpp" +#include "moonbase/errors.hpp" +#include "moonbase/fingerprint_spec.hpp" + +namespace moonbase { + +#if defined(__ANDROID__) +namespace android { + +/// The JNI handles the Android device id reader needs. +/// +/// Everything else in this SDK reads the machine on its own. Android is the one +/// exception, and not for want of trying: Settings.Secure.getString needs a +/// ContentResolver, which needs an application Context, and there is no supported +/// way for a native library to obtain one by itself. So the host hands it in once +/// and the SDK does the rest, rather than this SDK depending on a framework. +struct jni_handles { + JavaVM* vm = nullptr; + jobject context = nullptr; +}; + +namespace detail { + +inline jni_handles& mutable_jni_environment() +{ + static jni_handles handles; + return handles; +} + +[[nodiscard]] inline jni_handles jni_environment() +{ + return mutable_jni_environment(); +} + +} // namespace detail + +/// Supply the JNI handles, once, during startup. Typically from JNI_OnLoad: +/// +/// jint JNI_OnLoad(JavaVM* vm, void*) { +/// moonbase::android::set_jni_environment(vm, applicationContext); +/// return JNI_VERSION_1_6; +/// } +/// +/// The JUCE module does this for you. Until it is called, Android resolves to +/// insufficient_device_identity_error rather than to a constant, which is the +/// honest answer for a machine the SDK cannot identify. +/// +/// `context` must outlive the SDK, so pass a global reference or the Application +/// object rather than an Activity. +inline void set_jni_environment(JavaVM* vm, jobject context) +{ + detail::mutable_jni_environment() = jni_handles{vm, context}; +} + +} // namespace android +#endif + + +/// What a single read of the machine produced. +struct device_identity { + fingerprint_spec::parameter_list params; + std::string device_name; +}; + +using device_identity_reader = std::function; + +/// What to do when no hardware identity is readable. +enum class device_id_fallback { + /// Throw insufficient_device_identity_error. The default. + none, + /// Hash the host name instead, producing a deliberately weaker id stamped + /// `mbd2n_`. Opt-in, because a host name is user-renameable, frequently + /// duplicated across imaged machines, and regenerated on every container start. + /// + /// **Ignored on iOS and Android**, which throw regardless: there the host name + /// is identical on every device (since iOS 17 gethostname() returns + /// "localhost", and UIDevice.name the model name), so the fallback would give a + /// whole install base one id rather than merely a weak one. The refusal lives + /// in build_fingerprint_material, so it binds a custom reader and a native + /// bridge assembling material directly, not just this resolver. + device_name, +}; + +struct moonbase_device_id_resolver_options { + device_id_fallback fallback = device_id_fallback::none; + /// Overrides the identity source. Primarily for testing. + device_identity_reader reader; + /// Overrides the detected platform tag. Primarily for testing. + std::string platform; +}; + +class moonbase_device_id_resolver : public device_id_resolver { +public: + explicit moonbase_device_id_resolver(moonbase_device_id_resolver_options options = {}) + : options_(std::move(options)) + { + if (options_.platform.empty()) { + options_.platform = std::string(fingerprint_spec::platform_tag()); + } + } + + /// The host name, with a trailing ".local" removed on macOS. + /// + /// Never throws: a machine with no readable identity still has to be able to + /// label itself, since activation sends the name alongside the id. + [[nodiscard]] std::string device_name() const override { return identity().device_name; } + + /// \throws insufficient_device_identity_error when nothing identifies this + /// machine and the host-name fallback is not enabled. + [[nodiscard]] std::string device_id() const override { return description().device_id; } + + /// \throws insufficient_device_identity_error, as device_id() does. + [[nodiscard]] std::optional describe_device() const override + { + // By value, so a caller that edits a diagnostic (or logs it through + // something that normalizes in place) cannot change the id every later + // call returns. + return description(); + } + + // ------------------------------------------------------------------ + // Host reads, exposed so a consumer can inspect what this machine offers + // without going through the resolver's memoization. + + [[nodiscard]] static device_identity read_host_identity() + { + device_identity identity; + identity.device_name = read_host_name(); + +#if defined(MOONBASE_FINGERPRINT_USE_IOKIT) + identity.params.emplace_back("ioPlatformUuid", read_io_platform_uuid()); +#elif defined(_WIN32) + identity.params = fingerprint_spec::parse_smbios_params(read_windows_smbios_table()); +#elif defined(__ANDROID__) + identity.params.emplace_back("androidId", read_android_id()); +#elif defined(MOONBASE_FINGERPRINT_USE_UIKIT) + identity.params.emplace_back("identifierForVendor", read_identifier_for_vendor()); +#elif defined(__linux__) + // All five sources are world-readable files, so the result does not + // depend on privilege, on any installed CLI, or on the locale. + const auto etc_machine_id = read_file("/etc/machine-id"); + const auto dbus_machine_id = read_file("/var/lib/dbus/machine-id"); + + identity.params.emplace_back( + "machineId", fingerprint_spec::select_machine_id({etc_machine_id, dbus_machine_id})); + identity.params.emplace_back("sysVendor", read_file("/sys/class/dmi/id/sys_vendor")); + identity.params.emplace_back("productName", read_file("/sys/class/dmi/id/product_name")); + identity.params.emplace_back("boardVendor", read_file("/sys/class/dmi/id/board_vendor")); + identity.params.emplace_back("boardName", read_file("/sys/class/dmi/id/board_name")); +#endif + + return identity; + } + + [[nodiscard]] static std::string read_host_name() + { +#if defined(_WIN32) + std::array buffer{}; + auto size = static_cast(buffer.size()) - 1; + if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) { + return std::string(buffer.data(), size); + } + return {}; +#else + std::array buffer{}; + if (gethostname(buffer.data(), buffer.size() - 1) != 0) { + return {}; + } + std::string name(buffer.data()); + +#if defined(__APPLE__) + constexpr std::string_view suffix = ".local"; + if (name.size() >= suffix.size()) { + auto tail = name.substr(name.size() - suffix.size()); + std::transform(tail.begin(), tail.end(), tail.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + if (tail == suffix) { + name.erase(name.size() - suffix.size()); + } + } +#endif + return name; +#endif + } + +private: + // Both memos are a mutex plus a flag rather than std::once_flag, and that is + // deliberate: description() lets insufficient_device_identity_error escape so + // an unreadable machine retries instead of caching the failure, and + // std::call_once is a bad place to throw from. libstdc++ implements it on + // pthread_once, and ThreadSanitizer's pthread_once interceptor does not model + // the reset that the exception path performs, so the *second* call deadlocks. + // The plain mutex gives the same semantics with none of that: the flag is only + // set after the value is stored, so a throw leaves it false and the next + // caller tries again. + // + // Returning a reference is safe because neither value is ever mutated once its + // flag is set, and the lock establishes the happens-before edge for the reader. + + [[nodiscard]] const device_identity& identity() const + { + // Read at most once. Both halves of an activation request ask for it, the + // name and then the id, and the validator asks for the id on every single + // token check. Reading per call would also let the name and the id come + // from two different reads of the machine. + const std::lock_guard lock(identity_mutex_); + if (!identity_read_) { + try { + identity_ = options_.reader ? options_.reader() : read_host_identity(); + } catch (...) { + // Reads are best-effort. An unreadable machine is insufficient + // identity, which device_id() reports, not an exception thrown + // out of device_name(). + identity_ = device_identity{}; + } + identity_read_ = true; + } + return identity_; + } + + [[nodiscard]] const device_id_description& description() const + { + // A machine that is momentarily unreadable retries on the next call rather + // than caching the failure: describe() throws, described_ stays false, and + // the guard releases the lock on the way out. A sticky failure would + // outlive the condition that caused it. + const std::lock_guard lock(description_mutex_); + if (!described_) { + const auto& read = identity(); + try { + description_ = describe(read.params, fingerprint_spec::device_id_source::identity); + } catch (const insufficient_device_identity_error&) { + if (options_.fallback != device_id_fallback::device_name) { + throw; + } + description_ = describe( + {{"deviceName", read.device_name}}, fingerprint_spec::device_id_source::device_name); + } + described_ = true; + } + return description_; + } + + [[nodiscard]] device_id_description describe( + const fingerprint_spec::parameter_list& params, + fingerprint_spec::device_id_source source) const + { + const auto material = fingerprint_spec::build_fingerprint_material(options_.platform, params); + + device_id_description described; + described.device_id = fingerprint_spec::fingerprint_device_id(material, source); + described.version = fingerprint_spec::version; + described.platform = options_.platform; + described.source = source; + for (const auto& param : fingerprint_spec::canonicalize_params(params)) { + described.param_names.push_back(param.first); + } + return described; + } + + [[nodiscard]] static std::string read_file(const char* path) + { + std::ifstream file(path, std::ios::binary); + if (!file) { + return {}; + } + std::ostringstream out; + out << file.rdbuf(); + return out.str(); + } + +#if defined(MOONBASE_FINGERPRINT_USE_IOKIT) + [[nodiscard]] static std::string read_io_platform_uuid() + { + // MACH_PORT_NULL rather than kIOMainPortDefault or kIOMasterPortDefault: + // both constants are defined as MACH_PORT_NULL, but the first only exists + // in the macOS 12+ SDK and the second is deprecated there, so naming + // either breaks somebody's -Werror build. Passing MACH_PORT_NULL selects + // the default port and compiles against every SDK. + const io_service_t service = + IOServiceGetMatchingService(MACH_PORT_NULL, IOServiceMatching("IOPlatformExpertDevice")); + if (service == IO_OBJECT_NULL) { + return {}; + } + + const CFTypeRef property = IORegistryEntryCreateCFProperty( + service, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0); + IOObjectRelease(service); + + if (property == nullptr) { + return {}; + } + + std::string uuid; + if (CFGetTypeID(property) == CFStringGetTypeID()) { + std::array buffer{}; + if (CFStringGetCString( + static_cast(property), + buffer.data(), + static_cast(buffer.size()), + kCFStringEncodingUTF8)) { + uuid = buffer.data(); + } + } + CFRelease(property); + + // Spec: all hyphens removed and uppercased. + return fingerprint_spec::normalize_platform_uuid(uuid); + } +#endif + +#if defined(MOONBASE_FINGERPRINT_USE_UIKIT) + /// identifierForVendor, uppercased with hyphens removed like ioPlatformUuid. + /// + /// Reached through the Objective-C runtime's C API rather than Objective-C + /// source, so this header stays plain C++ and this SDK needs no framework to + /// fingerprint an iOS device. Empty when iOS declines to provide one, which it + /// does until the device is first unlocked after boot; absence is transient and + /// surfaces as insufficient identity rather than as a constant. + [[nodiscard]] static std::string read_identifier_for_vendor() + { + using send_id = id (*)(id, SEL); + using send_cstr = const char* (*)(id, SEL); + const auto msg_id = reinterpret_cast(objc_msgSend); + const auto msg_cstr = reinterpret_cast(objc_msgSend); + + // UIDevice everywhere except watchOS, which exposes the same property on + // WKInterfaceDevice. + Class device_class = objc_getClass("UIDevice"); + if (device_class == nullptr) { + device_class = objc_getClass("WKInterfaceDevice"); + } + if (device_class == nullptr) { + return {}; + } + + id device = msg_id(reinterpret_cast(device_class), sel_registerName("currentDevice")); + if (device == nullptr) { + return {}; + } + + id uuid = msg_id(device, sel_registerName("identifierForVendor")); + if (uuid == nullptr) { + return {}; + } + + id text = msg_id(uuid, sel_registerName("UUIDString")); + if (text == nullptr) { + return {}; + } + + const char* utf8 = msg_cstr(text, sel_registerName("UTF8String")); + if (utf8 == nullptr) { + return {}; + } + + return fingerprint_spec::normalize_platform_uuid(utf8); + } +#endif + +#if defined(__ANDROID__) + /// Settings.Secure.getString(contentResolver, ANDROID_ID), lowercased. + /// + /// Plain JNI, so this needs no framework either. Deliberately not the static + /// field Settings.Secure.ANDROID_ID: that is the key name "android_id", + /// identical on every device, and hashing it would give a whole install base + /// one device id. JUCE's SystemStats::getUniqueDeviceID() has exactly that + /// defect. The spec's ^[0-9a-f]{1,16}$ rule makes the mistake mechanically + /// impossible here regardless. + /// + /// Empty until the host supplies JNI handles (see + /// moonbase::android::set_jni_environment), and empty when Android returns + /// null, which it can before the user is set up. + [[nodiscard]] static std::string read_android_id() + { + const auto jni = android::detail::jni_environment(); + if (jni.vm == nullptr || jni.context == nullptr) { + return {}; + } + + JNIEnv* env = nullptr; + if (jni.vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK + || env == nullptr) { + return {}; + } + + const auto fail = [env] { + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + return std::string{}; + }; + + jclass secure = env->FindClass("android/provider/Settings$Secure"); + if (secure == nullptr) { + return fail(); + } + + const auto get_string = env->GetStaticMethodID( + secure, + "getString", + "(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;"); + if (get_string == nullptr) { + return fail(); + } + + const auto get_resolver = env->GetMethodID( + env->GetObjectClass(jni.context), "getContentResolver", "()Landroid/content/ContentResolver;"); + if (get_resolver == nullptr) { + return fail(); + } + + jobject resolver = env->CallObjectMethod(jni.context, get_resolver); + if (resolver == nullptr) { + return fail(); + } + + jstring key = env->NewStringUTF("android_id"); + auto value = static_cast( + env->CallStaticObjectMethod(secure, get_string, resolver, key)); + if (env->ExceptionCheck() || value == nullptr) { + return fail(); + } + + const char* utf8 = env->GetStringUTFChars(value, nullptr); + std::string out = utf8 != nullptr ? utf8 : ""; + if (utf8 != nullptr) { + env->ReleaseStringUTFChars(value, utf8); + } + + // Lowercased per the spec; the ^[0-9a-f]{1,16}$ rule is case-sensitive. + for (auto& character : out) { + character = (character >= 'A' && character <= 'Z') + ? static_cast(character - 'A' + 'a') + : character; + } + return out; + } +#endif + +#if defined(_WIN32) + [[nodiscard]] static std::vector read_windows_smbios_table() + { + // 'RSMB', the raw SMBIOS firmware table provider. + // + // Spelled out rather than written as the multi-character literal 'RSMB', + // which is implementation-defined and warns under -Wmultichar. Note the + // byte order: MSDN documents "this identifier is little endian, you must + // reverse the characters" for the *FirmwareTableID* parameter, not for + // the provider signature, and its own sample passes 'RSMB' unreversed. + // Reversing it here yields 0x424D5352, which no provider matches, so the + // call returns 0 and the whole SMBIOS path silently disappears. + constexpr DWORD rsmb = 0x52534D42; + + const DWORD size = GetSystemFirmwareTable(rsmb, 0, nullptr, 0); + if (size == 0) { + return {}; + } + + std::vector raw(size); + const DWORD written = GetSystemFirmwareTable(rsmb, 0, raw.data(), size); + if (written == 0 || written > size) { + return {}; + } + raw.resize(written); + + // Skip the RawSMBIOSData header (Used20CallingMethod, three version + // bytes, then a DWORD Length); parsing starts at the first structure. + // WMI's SMBiosData already excludes this header. + constexpr std::size_t header_size = 8; + if (raw.size() <= header_size) { + return {}; + } + + std::uint32_t declared_length = 0; + std::memcpy(&declared_length, raw.data() + 4, sizeof(declared_length)); + + const std::size_t available = raw.size() - header_size; + const auto table_size = std::min(static_cast(declared_length), available); + + return std::vector( + raw.begin() + static_cast(header_size), + raw.begin() + static_cast(header_size + table_size)); + } +#endif + + moonbase_device_id_resolver_options options_; + + mutable std::mutex identity_mutex_; + mutable bool identity_read_ = false; + mutable device_identity identity_; + mutable std::mutex description_mutex_; + mutable bool described_ = false; + mutable device_id_description description_; +}; + +} // namespace moonbase diff --git a/modules/moonbase_licensing/moonbase/validator.hpp b/modules/moonbase_licensing/moonbase/validator.hpp index 6854581..c4d88b6 100644 --- a/modules/moonbase_licensing/moonbase/validator.hpp +++ b/modules/moonbase_licensing/moonbase/validator.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -16,8 +17,8 @@ #include "moonbase/detail/base64.hpp" #include "moonbase/detail/crypto/crypto.hpp" #include "moonbase/detail/time.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/errors.hpp" -#include "moonbase/fingerprint.hpp" #include "moonbase/types.hpp" namespace moonbase { @@ -183,16 +184,138 @@ inline nlohmann::json object_claim_or_empty(const nlohmann::json& payload, const return payload.at(key); } +// A factual note when the binding's stamp differs from what this SDK computes, or +// empty when it does not. +// +// Deliberately conditional about the machine. Reaching this point means nothing +// reproduced the bound id: a migrating_device_id_resolver, the only thing that +// could prove continuity, has already declined or was never configured. So the +// stamp says only which algorithm created the binding, never that it was *this* +// machine, because a token copied from another computer carries exactly the same +// relationship. +[[nodiscard]] inline std::string describe_version_difference( + const fingerprint_spec::device_id_stamp& expected, + const std::optional& bound) +{ + const auto expected_version = std::to_string(expected.version); + + // Direction matters. An older binding may need migrating; a newer one means + // this SDK is behind, and re-activating would rebind the device to an + // algorithm the issuing SDK has already moved on from. + if (bound && bound->version > expected.version) { + return "The binding was created by device fingerprint v" + std::to_string(bound->version) + + ", which is newer than the v" + expected_version + " this SDK computes." + + " Update the SDK rather than re-activating, which would rebind the device to the" + " older algorithm."; + } + + const auto bound_version = bound + ? "device fingerprint v" + std::to_string(bound->version) + : std::string("an SDK predating versioned device fingerprints"); + + return "The binding was created by " + bound_version + ", while this SDK computes v" + + expected_version + ", so this may instead be the same machine bound under the older" + " algorithm. Re-activate to find out, or configure a migrating_device_id_resolver to keep" + " accepting the previous id."; +} + +// Same fingerprint version, different source tag: the two ids were built from +// different *kinds* of identity, so they were never going to match even on one +// machine. Saying only "not for this device" would point at the wrong remedy. +// +// Ordered by how badly a wrong message would mislead. +[[nodiscard]] inline std::string describe_source_difference( + const fingerprint_spec::device_id_stamp& expected, + const fingerprint_spec::device_id_stamp& bound) +{ + using source = fingerprint_spec::device_id_source; + + // A scoped id is stable only within one platform-defined scope. Comparing it + // with anything from another scope is meaningless in both directions, so this + // must not borrow the version path's "may be the same machine" phrasing: that + // would be actively false. + // + // Which *side* is scoped decides the wording. A custom resolver or a native + // bridge can make this SDK the scoped one, and saying "the binding is scoped" + // there would describe the wrong id and point at the wrong remedy. + if (bound.source == source::scoped) { + return "The binding uses an app-scoped device identity, which cannot be compared with the id" + " this SDK computes, not even on the same device. Re-activate here to bind this build."; + } + + if (expected.source == source::scoped) { + return "This SDK computes an app-scoped device identity, which cannot be compared with the one" + " the binding carries, not even on the same device. Re-activate here to bind this app."; + } + + // An unrecognised tag can only have come from a newer SDK. Before the parser + // accepted arbitrary tags this fell through to the version branch and was + // reported as predating versioned fingerprints, which was exactly backwards. + if (!bound.source.has_value() || !expected.source.has_value()) { + const auto& unknown = !bound.source.has_value() ? bound : expected; + return "The binding carries the device identity tag \"" + unknown.source_tag + + "\", which this SDK does not recognise. It was created by a newer Moonbase SDK, so" + " update rather than re-activating."; + } + + // Hardware identity versus the opt-in host-name fallback. Direction matters as + // much as it does for versions, and the remedies are opposites. + if (bound.source == source::device_name) { + 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. Re-activate to find out."; + } + + return "The binding was created from hardware identity, while this SDK has fallen back to the" + " host name. Check why hardware identity cannot be read here rather than re-activating," + " which would rebind the device to the weaker id."; +} + +// The stamp difference behind a mismatch, or empty when there is none to report. +[[nodiscard]] inline std::string describe_stamp_difference( + const std::string& expected, + const std::string& bound) +{ + const auto expected_stamp = fingerprint_spec::parse_device_id_stamp(expected); + if (!expected_stamp) { + // A custom resolver's id, compared literally. Nothing to say about stamps. + return {}; + } + + const auto bound_stamp = fingerprint_spec::parse_device_id_stamp(bound); + + if (!bound_stamp || bound_stamp->version != expected_stamp->version) { + return describe_version_difference(*expected_stamp, bound_stamp); + } + + if (bound_stamp->source_tag != expected_stamp->source_tag) { + return describe_source_difference(*expected_stamp, *bound_stamp); + } + + return {}; +} + +// Explain a `sig` mismatch. Leads with the only thing that is certain, that the +// bound id is not this device's, and appends the stamp difference when there is one. +[[nodiscard]] inline license_device_mismatch_error device_mismatch_error( + const std::string& expected, + const std::string& bound) +{ + const std::string detail = "This license is not for this device"; + const auto note = describe_stamp_difference(expected, bound); + return license_device_mismatch_error(note.empty() ? detail : detail + ". " + note); +} + } // namespace detail class license_validator { public: - license_validator(licensing_options options, std::shared_ptr fingerprints) + license_validator(licensing_options options, std::shared_ptr device_ids) : options_(std::move(options)), - fingerprints_(std::move(fingerprints)), + device_ids_(std::move(device_ids)), key_(options_.public_key) { - if (!fingerprints_) { + if (!device_ids_) { throw configuration_error("A fingerprint provider is required"); } } @@ -302,17 +425,23 @@ class license_validator { throw license_expired_error("License has expired"); } - const auto expected_signature = fingerprints_->device_id(); + const auto expected_signature = device_ids_->device_id(); const auto actual_signature = detail::require_string(payload, "sig"); - if (actual_signature != expected_signature) { - throw license_invalid_error("License does not match the current device"); + // The literal comparison first, so an app that has not configured a + // migration pays nothing. Only on a mismatch does a + // migrating_device_id_resolver get the chance to vouch for an id this + // machine used to be bound to, which is the one thing that can establish + // continuity. + if (actual_signature != expected_signature + && !device_ids_->accepts_device_id(actual_signature)) { + throw detail::device_mismatch_error(expected_signature, actual_signature); } return result; } licensing_options options_; - std::shared_ptr fingerprints_; + std::shared_ptr device_ids_; detail::rsa_public_key key_; }; diff --git a/modules/moonbase_licensing/moonbase_licensing.h b/modules/moonbase_licensing/moonbase_licensing.h index e194bf4..edcefc6 100644 --- a/modules/moonbase_licensing/moonbase_licensing.h +++ b/modules/moonbase_licensing/moonbase_licensing.h @@ -9,7 +9,7 @@ ID: moonbase_licensing vendor: Moonbase - version: 3.1.0 + version: 3.3.0 name: Moonbase Licensing description: Moonbase license activation for JUCE apps and plugins, with a built-in activation UI. Talks to the Moonbase API natively — no juce::OnlineUnlockStatus. Zero third-party dependencies: JUCE WebInputStream transport, bundled nlohmann/json, and OS-native RS256 verification (Security.framework / CNG / libcrypto). website: https://moonbase.sh @@ -17,7 +17,7 @@ minimumCppStandard: 17 dependencies: juce_core juce_events juce_data_structures juce_graphics juce_gui_basics juce_animation - OSXFrameworks: Security + OSXFrameworks: Security IOKit iOSFrameworks: Security windowsLibs: bcrypt linuxLibs: crypto @@ -45,8 +45,13 @@ // Module version (keep in sync with the `version:` field above). Also used as // the SDK version when it isn't otherwise defined for this build, so the base // client's User-Agent reports a real version instead of 0.0.0. +// +// Both this and the `version:` field are rewritten by scripts/bump-version.sh +// and committed through .releaserc.json's git assets. The CI consistency job +// compares them against CMakeLists.txt VERSION, because drift here silently +// misreports SDK traffic to the API. #ifndef MOONBASE_LICENSING_VERSION - #define MOONBASE_LICENSING_VERSION "3.1.0" + #define MOONBASE_LICENSING_VERSION "3.3.0" #endif #ifndef MOONBASE_CPP_VERSION #define MOONBASE_CPP_VERSION MOONBASE_LICENSING_VERSION @@ -67,7 +72,7 @@ //============================================================================== // Native integration + built-in UI. #include "juce/juce_http_transport.h" -#include "juce/juce_fingerprint_provider.h" +#include "juce/legacy_juce_device_id_resolver.h" #include "juce/JuceMetadata.h" #include "juce/LicenseGate.h" #include "juce/ActivationConfig.h" diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index e848f74..1de2a52 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -16,6 +16,7 @@ fi repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cmake_file="$repo_root/CMakeLists.txt" readme_file="$repo_root/README.md" +module_file="$repo_root/modules/moonbase_licensing/moonbase_licensing.h" if [[ ! -f "$cmake_file" ]]; then echo "bump-version.sh: CMakeLists.txt not found at $cmake_file" >&2 @@ -47,3 +48,33 @@ if ! grep -Eq "GIT_TAG[[:space:]]+v${new_version}([^0-9]|$)" "$readme_file"; the fi echo "bumped README.md GIT_TAG to v$new_version" + +# The JUCE module carries its own version in two places: the `version:` field the +# Projucer reads out of the BEGIN_JUCE_MODULE_DECLARATION block, and the +# MOONBASE_LICENSING_VERSION define that doubles as MOONBASE_CPP_VERSION (and so +# as the client's User-Agent) in module-only builds. Both must track the project +# version, and the file must be listed in .releaserc.json's git assets, or the +# rewrite happens in the release workflow's working tree and is thrown away. + +if [[ ! -f "$module_file" ]]; then + echo "bump-version.sh: moonbase_licensing.h not found at $module_file" >&2 + exit 1 +fi + +sed -E -i.bak \ + -e "s/^([[:space:]]*version:[[:space:]]+)[0-9]+\.[0-9]+\.[0-9]+/\1${new_version}/" \ + -e "s/(#define MOONBASE_LICENSING_VERSION \")[0-9]+\.[0-9]+\.[0-9]+(\")/\1${new_version}\2/" \ + "$module_file" +rm -f "${module_file}.bak" + +if ! grep -Eq "^[[:space:]]*version:[[:space:]]+${new_version}([[:space:]]|$)" "$module_file"; then + echo "bump-version.sh: failed to update the module declaration version to $new_version" >&2 + exit 1 +fi + +if ! grep -Fq "#define MOONBASE_LICENSING_VERSION \"${new_version}\"" "$module_file"; then + echo "bump-version.sh: failed to update MOONBASE_LICENSING_VERSION to $new_version" >&2 + exit 1 +fi + +echo "bumped moonbase_licensing.h version to $new_version" diff --git a/scripts/gen-nfc-tables.py b/scripts/gen-nfc-tables.py new file mode 100755 index 0000000..ef971dc --- /dev/null +++ b/scripts/gen-nfc-tables.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +"""Generate the Unicode tables used by detail/unicode/nfc_ascii.hpp. + +The device fingerprint spec canonicalizes every value as + + NFC -> keep only U+0020..U+007E -> truncate to 128 -> trim spaces + +and this SDK cannot depend on ICU. It does not need to: because the +printable-ASCII filter runs immediately after NFC, the only thing NFC can still +change about the output is *which printable-ASCII characters remain visible*. +That reduces to three enumerable effects, and this script derives all three from +the Python standard library's Unicode database. + + 1. An ASCII starter is consumed by a following combining mark. + "e" + U+0301 composes to a non-ASCII precomposed character, which the + filter then drops whole, so the "e" must not survive either. + 2. A non-ASCII code point whose NFC form *is* a printable-ASCII character. + Singleton decompositions are always composition-excluded, so the ASCII + result stays exposed. A naive "strip everything non-ASCII" loses these. + 3. A combining mark that has its own canonical decomposition, which must be + expanded before the blocking analysis in (1). + +Usage: + scripts/gen-nfc-tables.py # write the header + scripts/gen-nfc-tables.py --stdout # print it instead + scripts/gen-nfc-tables.py --verify # differential-test the algorithm +""" + +from __future__ import annotations + +import argparse +import itertools +import pathlib +import random +import sys +import unicodedata + +MAX_CODE_POINT = 0x110000 +PRINTABLE_MIN = 0x20 +PRINTABLE_MAX = 0x7E + +HEADER_PATH = ( + pathlib.Path(__file__).resolve().parent.parent + / "include" + / "moonbase" + / "detail" + / "unicode" + / "nfc_tables.hpp" +) + +# The blocks whose combining classes the shipped table carries. +# +# Deliberately not every combining mark in Unicode. The full table is 393 ranges +# and 8.5 KB of the header, five times these blocks, and it exists only to get the +# *blocking* rule right when an ASCII base carries marks from several scripts at +# once. Every composing mark and every self-decomposing mark lives in the first +# block below (asserted in collect()), so the behaviour the conformance vectors +# actually require is unaffected. +# +# The cost is one known divergence class, which --verify pins rather than ignores: +# a mark outside these blocks is treated as a starter, so it ends the cluster and a +# later composing mark never gets the chance to annihilate the base. That needs an +# ASCII base followed by a foreign-script combining mark followed by a Latin +# composing mark, which no IOKit UUID, machine-id, sysfs DMI string, SMBIOS string +# or host name contains. Every input of one or two code points is still exact. +COMBINING_BLOCKS = [ + (0x0300, 0x036F), # Combining Diacritical Marks + (0x1AB0, 0x1ACE), # Combining Diacritical Marks Extended + (0x1DC0, 0x1DFF), # Combining Diacritical Marks Supplement + (0x20D0, 0x20F0), # Combining Diacritical Marks for Symbols + (0xFE20, 0xFE2F), # Combining Half Marks +] + + +def in_covered_blocks(code_point: int) -> bool: + return any(low <= code_point <= high for low, high in COMBINING_BLOCKS) + + +def combining_class_ranges() -> list[tuple[int, int, int]]: + """Non-zero combining classes within the covered blocks, run-collapsed.""" + ranges: list[tuple[int, int, int]] = [] + for low, high in COMBINING_BLOCKS: + for cp in range(low, high + 1): + ccc = unicodedata.combining(chr(cp)) + if ccc == 0: + continue + if ranges and ranges[-1][1] == cp - 1 and ranges[-1][2] == ccc: + ranges[-1] = (ranges[-1][0], cp, ccc) + else: + ranges.append((cp, cp, ccc)) + return ranges + + +def ascii_compositions() -> dict[int, set[int]]: + """base -> marks that form a primary composite with it, for printable-ASCII bases. + + A pair counts only if NFC actually recomposes it: that filters out the + composition exclusions and any decomposition whose first element is not a + starter, which are exactly the cases where the base would survive. + """ + compositions: dict[int, set[int]] = {} + for cp in range(MAX_CODE_POINT): + decomposed = unicodedata.normalize("NFD", chr(cp)) + if len(decomposed) != 2: + continue + base, mark = ord(decomposed[0]), ord(decomposed[1]) + if not (PRINTABLE_MIN <= base <= PRINTABLE_MAX): + continue + if unicodedata.normalize("NFC", decomposed) != chr(cp): + continue + compositions.setdefault(base, set()).add(mark) + return compositions + + +def ascii_singletons() -> list[tuple[int, int]]: + """Non-ASCII code points whose NFC form is a single printable-ASCII character.""" + singletons: list[tuple[int, int]] = [] + for cp in range(MAX_CODE_POINT): + if PRINTABLE_MIN <= cp <= PRINTABLE_MAX: + continue + composed = unicodedata.normalize("NFC", chr(cp)) + if len(composed) != 1: + continue + result = ord(composed) + if result != cp and PRINTABLE_MIN <= result <= PRINTABLE_MAX: + singletons.append((cp, result)) + return singletons + + +def mark_decompositions() -> list[tuple[int, tuple[int, ...]]]: + """Combining marks that canonically decompose to other marks.""" + decompositions: list[tuple[int, tuple[int, ...]]] = [] + for cp in range(MAX_CODE_POINT): + if unicodedata.combining(chr(cp)) == 0: + continue + decomposed = unicodedata.normalize("NFD", chr(cp)) + if decomposed != chr(cp): + decompositions.append((cp, tuple(ord(c) for c in decomposed))) + return decompositions + + +def collect() -> dict: + compositions = ascii_compositions() + marks = sorted({mark for marks in compositions.values() for mark in marks}) + + # The C++ cluster walk assumes every composing mark is a non-starter, so that + # a starter always terminates the preceding cluster. Verify rather than trust: + # a ccc-0 composing partner would need a differently shaped algorithm. + for mark in marks: + assert unicodedata.combining(chr(mark)) != 0, f"U+{mark:04X} composes but has ccc 0" + # And it must be inside the blocks the reduced table covers, or the + # behaviour the vectors require would silently stop working. + assert in_covered_blocks(mark), f"composing mark U+{mark:04X} is outside COMBINING_BLOCKS" + + for source, _ in mark_decompositions(): + assert in_covered_blocks(source), f"decomposable mark U+{source:04X} is outside COMBINING_BLOCKS" + + # And no composite of a printable-ASCII base is itself printable ASCII, so + # "the base composed" always means "the result is filtered away". + for base, base_marks in compositions.items(): + for mark in base_marks: + composed = unicodedata.normalize("NFC", chr(base) + chr(mark)) + assert len(composed) == 1, f"U+{base:04X}+U+{mark:04X} did not compose" + assert not (PRINTABLE_MIN <= ord(composed) <= PRINTABLE_MAX), ( + f"U+{base:04X}+U+{mark:04X} composes to printable ASCII" + ) + + assert len(marks) <= 32, f"{len(marks)} marks will not fit in a uint32_t mask" + + # A mark is blocked only by an earlier mark of the *same* combining class, so + # a class no composing mark uses can neither compose nor block one and can be + # ignored outright. Tracking only these classes turns "which classes have I + # seen in this cluster" into a handful of bits. + mark_classes = sorted({unicodedata.combining(chr(mark)) for mark in marks}) + assert len(mark_classes) <= 32, f"{len(mark_classes)} classes will not fit in a uint32_t mask" + + return { + "unicode_version": unicodedata.unidata_version, + "ccc_ranges": combining_class_ranges(), + "compositions": compositions, + "marks": marks, + "mark_classes": mark_classes, + "singletons": ascii_singletons(), + "mark_decompositions": mark_decompositions(), + } + + +def wrap(entries: list[str], per_line: int, indent: str = " ") -> str: + lines = [] + for chunk in range(0, len(entries), per_line): + lines.append(indent + " ".join(entries[chunk : chunk + per_line])) + return "\n".join(lines) + + +def render(data: dict) -> str: + marks: list[int] = data["marks"] + compositions: dict[int, set[int]] = data["compositions"] + mark_bit = {mark: index for index, mark in enumerate(marks)} + + masks = [] + for base in range(PRINTABLE_MIN, PRINTABLE_MAX + 1): + mask = 0 + for mark in compositions.get(base, ()): + mask |= 1 << mark_bit[mark] + masks.append(mask) + + mark_first, mark_last = marks[0], marks[-1] + mark_index = [-1] * (mark_last - mark_first + 1) + for mark in marks: + mark_index[mark - mark_first] = mark_bit[mark] + + ccc_ranges = data["ccc_ranges"] + singletons = data["singletons"] + decompositions = data["mark_decompositions"] + + out = f"""#pragma once + +// Generated by scripts/gen-nfc-tables.py from Unicode {data["unicode_version"]}. Do not edit by hand. +// +// Supporting data for detail/unicode/nfc_ascii.hpp, which answers exactly one +// question: after NFC, which printable-ASCII characters are still visible? See +// that header, and the generator, for why this is not a general NFC +// implementation and must never be reused as one. +// +// Stability: the Unicode Normalization Stability Policy freezes the composition +// table, the singleton mappings and the mark decompositions below, so those can +// never change. Only the combining-class ranges grow, as new scripts are +// encoded. A stale range can only matter for a value that mixes a printable +// ASCII base with a combining mark from a script this table predates, which no +// UUID, machine-id, DMI string, SMBIOS string or host name contains. + +#include +#include + +namespace moonbase::detail::unicode::tables {{ + +inline constexpr const char* unicode_version = "{data["unicode_version"]}"; + +// --------------------------------------------------------------------------- +// Canonical combining class, as run-collapsed ranges over the code points with +// a non-zero class. Needed both to tell a combining mark from a starter and to +// find the head of each equal-class run when testing whether a mark is blocked. + +struct combining_class_range {{ + char32_t first; + char32_t last; + std::uint8_t combining_class; +}}; + +inline constexpr combining_class_range combining_class_ranges[] = {{ +{wrap([f"{{0x{first:04X},0x{last:04X},{ccc}}}," for first, last, ccc in ccc_ranges], 6)} +}}; + +inline constexpr std::size_t combining_class_range_count = + sizeof(combining_class_ranges) / sizeof(combining_class_ranges[0]); + +// --------------------------------------------------------------------------- +// The {len(marks)} combining marks that can form a primary composite with a printable +// ASCII base, and a bitmask per base saying which. All of them fall in +// U+{mark_first:04X}..U+{mark_last:04X}, so a small index array resolves a mark to its bit in O(1). + +inline constexpr char32_t composing_mark_first = 0x{mark_first:04X}; +inline constexpr char32_t composing_mark_last = 0x{mark_last:04X}; + +inline constexpr std::int8_t composing_mark_index[] = {{ +{wrap([f"{value}," for value in mark_index], 16)} +}}; + +// Indexed by (base - 0x20) for base in U+0020..U+007E. Bit i corresponds to the +// mark whose composing_mark_index value is i. +inline constexpr std::uint32_t composition_masks[] = {{ +{wrap([f"0x{mask:08X}," for mask in masks], 6)} +}}; + +inline constexpr char32_t composition_mask_first = 0x{PRINTABLE_MIN:04X}; +inline constexpr char32_t composition_mask_last = 0x{PRINTABLE_MAX:04X}; + +// The distinct combining classes those marks use. A mark is blocked only by an +// earlier mark of the same class, so a class absent from this list can neither +// compose with an ASCII base nor block something that would, and the cluster +// walk skips it. Position in this array is the bit used to remember that the +// class has already been seen in the current cluster. +inline constexpr std::uint8_t composing_mark_classes[] = {{ +{wrap([f"{value}," for value in data["mark_classes"]], 8)} +}}; + +inline constexpr std::size_t composing_mark_class_count = + sizeof(composing_mark_classes) / sizeof(composing_mark_classes[0]); + +// --------------------------------------------------------------------------- +// Non-ASCII code points whose NFC form is a single printable-ASCII character. +// These are singleton decompositions, which are always composition-excluded, so +// NFC leaves the ASCII result exposed and it must survive the filter. + +struct ascii_singleton {{ + char32_t from; + char32_t to; +}}; + +inline constexpr ascii_singleton ascii_singletons[] = {{ +{wrap([f"{{0x{source:04X},0x{target:04X}}}," for source, target in singletons], 4)} +}}; + +inline constexpr std::size_t ascii_singleton_count = + sizeof(ascii_singletons) / sizeof(ascii_singletons[0]); + +// --------------------------------------------------------------------------- +// Combining marks with a canonical decomposition of their own. They must be +// expanded in place before the blocking analysis, or a mark that decomposes to +// a composing one would fail to annihilate its base. + +struct mark_decomposition {{ + char32_t from; + char32_t to[2]; + std::uint8_t length; +}}; + +inline constexpr mark_decomposition mark_decompositions[] = {{ +{wrap([ + "{{0x{:04X},{{0x{:04X},0x{:04X}}},{}}},".format( + source, target[0], target[1] if len(target) > 1 else 0, len(target) + ) + for source, target in decompositions +], 2)} +}}; + +inline constexpr std::size_t mark_decomposition_count = + sizeof(mark_decompositions) / sizeof(mark_decompositions[0]); + +}} // namespace moonbase::detail::unicode::tables +""" + return out + + +# --------------------------------------------------------------------------- +# Verification: a Python transcription of the C++ algorithm, differential-tested +# against real NFC followed by the printable-ASCII filter. + + +class Reference: + def __init__(self, data: dict) -> None: + self.marks = data["marks"] + self.mark_bit = {mark: index for index, mark in enumerate(self.marks)} + self.compositions = data["compositions"] + self.mark_classes = set(data["mark_classes"]) + self.singletons = dict(data["singletons"]) + self.decompositions = dict(data["mark_decompositions"]) + + def combining(self, code_point: int) -> int: + """Combining class as the *shipped table* reports it. + + Outside the covered blocks this is 0, which is exactly how the C++ lookup + behaves, so this reference diverges from real NFC in the same places the + SDK does. + """ + if not in_covered_blocks(code_point): + return 0 + return unicodedata.combining(chr(code_point)) + + def composes(self, base: int, mark: int) -> bool: + return mark in self.compositions.get(base, ()) + + def canonicalize(self, text: str) -> str: + points = [self.singletons.get(ord(c), ord(c)) for c in text] + + out: list[str] = [] + index = 0 + while index < len(points): + starter = points[index] + if self.combining(starter) != 0: + index += 1 + continue + + marks: list[int] = [] + cursor = index + 1 + while cursor < len(points) and self.combining(points[cursor]) != 0: + marks.extend(self.decompositions.get(points[cursor], (points[cursor],))) + cursor += 1 + + if PRINTABLE_MIN <= starter <= PRINTABLE_MAX: + annihilated = False + seen: set[int] = set() + for mark in marks: + ccc = self.combining(mark) + # Mirrors the C++ walk exactly, including the restriction to + # classes a composing mark actually uses. + if ccc not in self.mark_classes or ccc in seen: + continue + seen.add(ccc) + if self.composes(starter, mark): + annihilated = True + break + if not annihilated: + out.append(chr(starter)) + + index = cursor + + return "".join(out) + + +def truth(text: str) -> str: + return "".join(c for c in unicodedata.normalize("NFC", text) if PRINTABLE_MIN <= ord(c) <= PRINTABLE_MAX) + + +def is_subsequence(needle: str, haystack: str) -> bool: + """Is every character of `needle` present in `haystack`, in order?""" + iterator = iter(haystack) + return all(character in iterator for character in needle) + + +def has_uncovered_mark(text: str) -> bool: + """Does the string contain a real combining mark the shipped table omits? + + This is the sole licence for diverging from real NFC. Anything else is a bug. + """ + return any(unicodedata.combining(c) != 0 and not in_covered_blocks(ord(c)) for c in text) + + +def verify(data: dict) -> int: + reference = Reference(data) + failures = 0 + accepted = 0 + accepted_example: str | None = None + checked = 0 + long_only = 0 + + def check(text: str) -> None: + nonlocal failures, accepted, accepted_example, checked, long_only + checked += 1 + got, want = reference.canonicalize(text), truth(text) + if got == want: + return + + codes = " ".join(f"U+{ord(c):04X}" for c in text) + + # A divergence is only permitted where the reduced combining-class table + # is the cause. Anything else means the algorithm itself is wrong. + if not has_uncovered_mark(text): + failures += 1 + if failures <= 20: + print(f" UNEXPECTED MISMATCH {codes}: got {got!r}, want {want!r}", file=sys.stderr) + return + + # And it must fail in the conservative direction: bases survive that real + # NFC would have removed, never the reverse. A *subsequence* test, not a + # substring one: the surviving base can sit anywhere in the string, so + # "98" vs "9x8" is the expected shape rather than a violation. + if not is_subsequence(want, got): + failures += 1 + if failures <= 20: + print(f" WRONG-DIRECTION DIVERGENCE {codes}: got {got!r}, want {want!r}", file=sys.stderr) + return + + # The guarantee that makes this trade safe: no realistic fingerprint value + # is affected, and every short input is exact. + if len(text) <= 2: + long_only += 1 + print(f" SHORT-INPUT DIVERGENCE {codes}: got {got!r}, want {want!r}", file=sys.stderr) + + accepted += 1 + if accepted_example is None: + accepted_example = f"{codes}: reduced {got!r}, real NFC {want!r}" + + print("verifying: every single code point") + for cp in range(MAX_CODE_POINT): + check(chr(cp)) + + print("verifying: every printable-ASCII base x every combining mark") + all_marks = [cp for cp in range(MAX_CODE_POINT) if unicodedata.combining(chr(cp)) != 0] + for base in range(PRINTABLE_MIN, PRINTABLE_MAX + 1): + for mark in all_marks: + check(chr(base) + chr(mark)) + + print("verifying: every code point x a spread of composing marks") + probes = [0x0301, 0x0308, 0x0300, 0x0327, 0x0338, 0x0323, 0x0344, 0x0341, 0x0483, 0x0591, 0x1AB0] + for cp in range(MAX_CODE_POINT): + for mark in probes: + check(chr(cp) + chr(mark)) + + print("verifying: base x mark x mark, exhaustively for a sample of bases") + for base in "aeiouAEIOUcnsyzCNSYZ=": + for first, second in itertools.product(all_marks, repeat=2): + check(base + chr(first) + chr(second)) + + print("verifying: random strings") + rng = random.Random(20260729) + alphabet = ( + [chr(cp) for cp in range(PRINTABLE_MIN, PRINTABLE_MAX + 1)] + + [chr(mark) for mark in all_marks[:200]] + + [chr(cp) for cp, _ in data["singletons"]] + + ["é", "中", " ", "퟿", "￿", "\U0001F600"] + ) + for _ in range(400_000): + length = rng.randint(1, 8) + check("".join(rng.choice(alphabet) for _ in range(length))) + + print(f"\nchecked {checked:,} strings") + print(f" unexpected mismatches: {failures}") + print(f" accepted divergences: {accepted:,} ({accepted / checked:.4%})") + print(" all of the form: ASCII base + a combining mark outside COMBINING_BLOCKS") + print(" + a Latin composing mark, where the reduced table keeps the base.") + if accepted_example: + print(f" example: {accepted_example}") + print(f" short-input divergences: {long_only} (must be 0)") + + if long_only: + print("\nFAILED: an input of one or two code points diverged. The claim that every" + "\nrealistic fingerprint value is exact no longer holds.", file=sys.stderr) + + return 1 if (failures or long_only) else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stdout", action="store_true", help="print the header instead of writing it") + parser.add_argument("--verify", action="store_true", help="differential-test the algorithm against NFC") + args = parser.parse_args() + + data = collect() + + if args.verify: + return verify(data) + + header = render(data) + if args.stdout: + sys.stdout.write(header) + return 0 + + HEADER_PATH.parent.mkdir(parents=True, exist_ok=True) + HEADER_PATH.write_text(header, encoding="utf-8") + print(f"wrote {HEADER_PATH} (Unicode {data['unicode_version']})") + print(f" {len(data['ccc_ranges'])} combining-class ranges") + print(f" {len(data['marks'])} composing marks over {len(data['compositions'])} ASCII bases") + print(f" {len(data['singletons'])} ASCII-exposing singletons") + print(f" {len(data['mark_decompositions'])} decomposable marks") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sync-fingerprint-vectors.sh b/scripts/sync-fingerprint-vectors.sh new file mode 100755 index 0000000..a3d5057 --- /dev/null +++ b/scripts/sync-fingerprint-vectors.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# Refresh the vendored device-fingerprint conformance artifacts from the canonical +# copies published with @moonbase.sh/licensing: +# +# FINGERPRINT_SPEC.md <- packages/licensing/FINGERPRINT_SPEC.md +# tests/vectors/fingerprint-vectors.json <- packages/licensing/fingerprint-vectors.json +# +# The vectors are the executable half of the spec: when the prose and the vectors +# disagree, the vectors win, because they are what every SDK can actually run. +# This SDK ships its own copies so the test suite works with no npm, no network +# and no sibling checkout. +# +# The spec is not copied byte-for-byte. It gains a provenance header, and its +# relative links to ./fingerprint-vectors.json are rewritten to the path the file +# actually lives at here. Both transforms are applied deterministically below so +# --check can verify the result rather than just the source. +# +# CI runs this with --check. Unlike sync-juce-module.sh, a missing source is not +# an error: the JavaScript repo is not present on a CI runner, so --check can only +# compare when someone runs it locally. In CI the authority is instead the npm +# package, which the fingerprint-parity workflow points --from at. +# +# Usage: +# scripts/sync-fingerprint-vectors.sh [--check] [--vectors-only] [--from ] +# +# --vectors-only skips the spec comparison. The parity workflow uses it because it +# points --from at an *installed npm package*, and this SDK may legitimately +# implement a spec revision that has not been published yet (it did so for the +# scoped-identity extension). The vectors are what decides conformance, so they +# must always match; the prose can lead. +# +# --from names the *vectors* file; the spec is looked up beside it. The default +# source is $MOONBASE_JS_ROOT (or ../moonbase.js next to this repo) plus +# /packages/licensing/. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +vectors_dst="${repo_root}/tests/vectors/fingerprint-vectors.json" +spec_dst="${repo_root}/FINGERPRINT_SPEC.md" +js_root="${MOONBASE_JS_ROOT:-${repo_root}/../moonbase.js}" +vectors_src="${js_root}/packages/licensing/fingerprint-vectors.json" + +check_only=0 +vectors_only=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --check) + check_only=1 + shift + ;; + --vectors-only) + vectors_only=1 + shift + ;; + --from) + if [[ $# -lt 2 ]]; then + echo "error: --from needs a path" >&2 + exit 2 + fi + vectors_src="$2" + shift 2 + ;; + *) + echo "error: unknown argument '$1'" >&2 + echo "usage: sync-fingerprint-vectors.sh [--check] [--vectors-only] [--from ]" >&2 + exit 2 + ;; + esac +done + +spec_src="$(dirname "${vectors_src}")/FINGERPRINT_SPEC.md" + +if [[ ! -f "${vectors_src}" ]]; then + if [[ "${check_only}" -eq 1 ]]; then + echo "note: canonical vectors not found at ${vectors_src}; skipping the comparison." + echo " set MOONBASE_JS_ROOT or pass --from to check against a local checkout." + exit 0 + fi + echo "error: canonical vectors not found at ${vectors_src}" >&2 + echo " set MOONBASE_JS_ROOT or pass --from ." >&2 + exit 1 +fi + +# Render the vendored spec: provenance header, then the canonical text with its +# relative vector links repointed at this repo's layout. +render_spec() { + cat <<'HDR' + + +HDR + # ./fingerprint-vectors.json lives at tests/vectors/ here, so the canonical + # sibling-file links would 404. + sed 's|](\./fingerprint-vectors\.json)|](tests/vectors/fingerprint-vectors.json)|g' "$1" +} + +vectors_in_sync=0 +spec_in_sync=0 + +if [[ -f "${vectors_dst}" ]] && diff -q "${vectors_src}" "${vectors_dst}" >/dev/null 2>&1; then + vectors_in_sync=1 +fi + +if [[ "${vectors_only}" -eq 1 ]]; then + spec_in_sync=1 +elif [[ -f "${spec_src}" ]]; then + if [[ -f "${spec_dst}" ]] && diff -q <(render_spec "${spec_src}") "${spec_dst}" >/dev/null 2>&1; then + spec_in_sync=1 + fi +else + # An --from pointing at an npm install may not ship the spec; the vectors are + # the part that decides conformance, so treat the spec as satisfied. + echo "note: no FINGERPRINT_SPEC.md beside ${vectors_src}; checking the vectors only." + spec_in_sync=1 +fi + +if [[ "${check_only}" -eq 1 ]]; then + status=0 + if [[ "${vectors_in_sync}" -eq 0 ]]; then + echo "error: ${vectors_dst} differs from ${vectors_src}." >&2 + status=1 + fi + if [[ "${spec_in_sync}" -eq 0 ]]; then + echo "error: ${spec_dst} differs from the rendered ${spec_src}." >&2 + status=1 + fi + if [[ "${status}" -ne 0 ]]; then + echo " run scripts/sync-fingerprint-vectors.sh and commit the result," >&2 + echo " then re-run the conformance tests: a changed vector file may mean" >&2 + echo " the spec moved and this SDK has to move with it." >&2 + exit 1 + fi + echo "fingerprint spec and vectors are in sync." + exit 0 +fi + +mkdir -p "$(dirname "${vectors_dst}")" +cp "${vectors_src}" "${vectors_dst}" +echo "synced ${vectors_src} -> ${vectors_dst}" + +if [[ "${vectors_only}" -eq 0 && -f "${spec_src}" ]]; then + render_spec "${spec_src}" > "${spec_dst}" + echo "synced ${spec_src} -> ${spec_dst} (provenance header + rewritten vector links)" +fi diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index f242855..e05e56e 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -7,7 +7,7 @@ #include #include "moonbase/client.hpp" -#include "moonbase/fingerprint.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/validator.hpp" #include "test_helpers.hpp" @@ -19,14 +19,14 @@ namespace { struct client_fixture { moonbase::tests::generated_key key = moonbase::tests::generate_key(); licensing_options options; - std::shared_ptr fingerprints; + std::shared_ptr fingerprints; std::shared_ptr validator; std::shared_ptr transport; license_client client; explicit client_fixture(std::deque responses) : options(), - fingerprints(std::make_shared("Test Device", "device-id")), + fingerprints(std::make_shared("Test Device", "device-id")), validator(), transport(std::make_shared(std::move(responses))), client(make_options(), fingerprints, make_validator(), transport) diff --git a/tests/device_id_resolver_tests.cpp b/tests/device_id_resolver_tests.cpp new file mode 100644 index 0000000..0ac8785 --- /dev/null +++ b/tests/device_id_resolver_tests.cpp @@ -0,0 +1,291 @@ +// The migration machinery: recognising ids this device used to have, and telling +// a stale binding apart from an out-of-date SDK. + +#include +#include +#include + +#include + +#include "moonbase/device_id_resolver.hpp" +#include "moonbase/errors.hpp" +#include "moonbase/fingerprint_spec.hpp" +#include "moonbase/licensing.hpp" +#include "moonbase/validator.hpp" +#include "test_helpers.hpp" + +namespace fp = moonbase::fingerprint_spec; + +namespace { + +/// A historical resolver that cannot read this machine any more. +class throwing_resolver : public moonbase::device_id_resolver { +public: + [[nodiscard]] std::string device_name() const override { return "unreadable"; } + [[nodiscard]] std::string device_id() const override + { + throw moonbase::insufficient_device_identity_error("linux"); + } +}; + +/// Counts how often its id is computed, to pin the laziness and the memoization. +class counting_resolver : public moonbase::device_id_resolver { +public: + explicit counting_resolver(std::string id) : id_(std::move(id)) {} + + [[nodiscard]] std::string device_name() const override { return "counted"; } + [[nodiscard]] std::string device_id() const override + { + ++calls; + return id_; + } + + mutable int calls = 0; + +private: + std::string id_; +}; + +std::shared_ptr fixed(std::string name, std::string id) +{ + return std::make_shared(std::move(name), std::move(id)); +} + +const std::string spec_id = "mbd2_" + std::string(64, 'a'); +const std::string legacy_id = std::string(64, 'b'); + +} // namespace + +TEST_CASE("migrating resolver binds the current id and accepts historical ones") +{ + const moonbase::migrating_device_id_resolver resolver( + fixed("Studio Mac", spec_id), fixed("Studio Mac", legacy_id)); + + // Every new activation must bind the current algorithm, or the fleet never + // finishes migrating. + CHECK(resolver.device_id() == spec_id); + CHECK(resolver.device_name() == "Studio Mac"); + + CHECK(resolver.accepts_device_id(legacy_id)); + CHECK(!resolver.accepts_device_id("mbd2_" + std::string(64, 'c'))); + + SUBCASE("an empty historical id never matches") + { + // A historical resolver that could not read anything reduces to "". If + // that matched, a machine with no identity would accept a license bound + // to another such machine. + const moonbase::migrating_device_id_resolver blank( + fixed("Studio Mac", spec_id), fixed("Studio Mac", "")); + CHECK(!blank.accepts_device_id("")); + } +} + +TEST_CASE("historical ids are computed lazily, once, and a throwing one is skipped") +{ + auto counted = std::make_shared(legacy_id); + const moonbase::migrating_device_id_resolver resolver( + fixed("Studio Mac", spec_id), + std::vector>{ + std::make_shared(), counted}); + + // The happy path must not pay for the migration. + CHECK(resolver.device_id() == spec_id); + CHECK(counted->calls == 0); + + CHECK(resolver.accepts_device_id(legacy_id)); + CHECK(counted->calls == 1); + + // Memoized afterwards, including across a rejection. + CHECK(!resolver.accepts_device_id("something-else")); + CHECK(resolver.accepts_device_id(legacy_id)); + CHECK(counted->calls == 1); +} + +TEST_CASE("migrating resolver forwards diagnostics and requires a current resolver") +{ + moonbase::moonbase_device_id_resolver_options options; + options.platform = "linux"; + options.reader = []() -> moonbase::device_identity { + return {{{"machineId", "b08dfa6083e7567a1921a715000001fb"}}, "host-1"}; + }; + auto current = std::make_shared(options); + + const moonbase::migrating_device_id_resolver resolver(current, fixed("host-1", legacy_id)); + const auto described = resolver.describe_device(); + REQUIRE(described.has_value()); + CHECK(described->platform == "linux"); + CHECK(described->param_names == std::vector{"machineId"}); + + // A plain resolver has no history to report, so the base default applies. + CHECK(!fixed("x", "y")->describe_device().has_value()); + CHECK(!fixed("x", "y")->accepts_device_id("y")); + + CHECK_THROWS_AS( + moonbase::migrating_device_id_resolver(nullptr, fixed("x", "y")), moonbase::configuration_error); +} + +TEST_CASE("a version difference is reported without claiming machine continuity") +{ + using moonbase::detail::describe_stamp_difference; + + const auto v2 = "mbd2_" + std::string(64, 'a'); + const auto v1 = "mbd1_" + std::string(64, 'b'); + const auto v7 = "mbd7_" + std::string(64, 'b'); + + SUBCASE("same version says nothing about versions") + { + CHECK(describe_stamp_difference(v2, "mbd2_" + std::string(64, 'e')).empty()); + } + + SUBCASE("a stamp-shaped id that is not a valid stamp counts as unstamped") + { + // Uppercase hex, a short digest and a stray character all fail to parse, + // and an unparseable id is compared literally rather than coerced. + for (const auto bound : {"mbd2_" + std::string(64, 'z'), + "mbd2_" + std::string(64, 'A'), + "mbd2_" + std::string(63, 'a')}) { + INFO("bound: " << bound); + CHECK( + describe_stamp_difference(v2, bound).find("predating versioned device fingerprints") + != std::string::npos); + } + } + + SUBCASE("a scoped mismatch names the correct side, in both directions") + { + // The one case where the version path's "re-activate to find out" phrasing + // would be actively false: a scoped id cannot be compared with anything from + // another scope, in either direction, even on the same device. + const auto scoped = "mbd2s_" + std::string(64, 'c'); + + const auto bound_is_scoped = describe_stamp_difference(v2, scoped); + CHECK(bound_is_scoped.find("The binding uses an app-scoped") != std::string::npos); + CHECK(bound_is_scoped.find("may instead be the same machine") == std::string::npos); + + // Reachable through a custom resolver or a native bridge. Saying "the + // binding is scoped" here would describe the wrong id and give the wrong + // remedy. + const auto we_are_scoped = describe_stamp_difference(scoped, v2); + CHECK(we_are_scoped.find("This SDK computes an app-scoped") != std::string::npos); + CHECK(we_are_scoped.find("bind this app") != std::string::npos); + } + + SUBCASE("an unrecognised tag says update, not re-activate") + { + // Before the parser accepted arbitrary tags this fell through to the + // version branch and was reported as predating versioned fingerprints, + // which was exactly backwards: it can only come from a *newer* SDK. + const auto note = describe_stamp_difference(v2, "mbd2x_" + std::string(64, 'c')); + CHECK(note.find("\"x\"") != std::string::npos); + CHECK(note.find("newer Moonbase SDK") != std::string::npos); + CHECK(note.find("predating") == std::string::npos); + } + + SUBCASE("hardware versus host-name fallback, in both directions") + { + const auto fallback = "mbd2n_" + std::string(64, 'c'); + const auto hardware = "mbd2_" + std::string(64, 'c'); + + CHECK(describe_stamp_difference(v2, fallback).find("host-name fallback, while this SDK reads") + != std::string::npos); + CHECK(describe_stamp_difference("mbd2n_" + std::string(64, 'a'), hardware) + .find("fallen back to the host name") + != std::string::npos); + } + + SUBCASE("an older binding suggests migrating, conditionally") + { + const auto note = describe_stamp_difference(v2, v1); + CHECK(note.find("v1") != std::string::npos); + // Must stay hypothetical: a token copied from another computer carries + // exactly the same version relationship as one made here by an older SDK. + CHECK(note.find("may instead be the same machine") != std::string::npos); + CHECK(note.find("migrating_device_id_resolver") != std::string::npos); + } + + SUBCASE("an unstamped binding predates versioned fingerprints") + { + const auto note = describe_stamp_difference(v2, std::string(64, 'b')); + CHECK(note.find("predating versioned device fingerprints") != std::string::npos); + } + + SUBCASE("a newer binding says update the SDK, not re-activate") + { + const auto note = describe_stamp_difference(v2, v7); + CHECK(note.find("newer") != std::string::npos); + CHECK(note.find("Update the SDK") != std::string::npos); + CHECK(note.find("re-activate to find out") == std::string::npos); + } + + SUBCASE("a custom resolver's id is compared literally, with no version talk") + { + CHECK(describe_stamp_difference("my-own-device-id", v1).empty()); + } +} + +TEST_CASE("a device mismatch is its own error type but still a license_invalid_error") +{ + const auto error = moonbase::detail::device_mismatch_error( + "mbd2_" + std::string(64, 'a'), "mbd1_" + std::string(64, 'b')); + + CHECK(error.type() == moonbase::error_type::license_device_mismatch); + CHECK(std::string(error.what()).find("not for this device") != std::string::npos); + + // Deriving from license_invalid_error is load-bearing twice over: existing + // catch sites keep working across the upgrade, and licensing's offline grace + // period keys off that type, so a mismatch that escaped it would let a + // license copied from another machine run for the whole grace window. + try { + throw error; + } catch (const moonbase::license_invalid_error& caught) { + CHECK(caught.type() == moonbase::error_type::license_device_mismatch); + } +} + +TEST_CASE("the validator accepts a historical binding but rejects a foreign one") +{ + const auto key = moonbase::tests::generate_key(); + moonbase::licensing_options options; + options.endpoint = "https://demo.moonbase.sh"; + options.product_id = "demo-app"; + options.account_id = "tenant-1"; + options.public_key = key.public_pem; + options.target_platform = moonbase::platform::unknown; + + const auto legacy_bound_token = + moonbase::tests::make_token(key.key.get(), moonbase::tests::default_claims(legacy_id)); + + SUBCASE("without a migration configured, the mismatch is reported with the version note") + { + const moonbase::license_validator validator(options, fixed("Studio Mac", spec_id)); + try { + (void)validator.validate_token(legacy_bound_token); + FAIL("expected a device mismatch"); + } catch (const moonbase::license_device_mismatch_error& ex) { + CHECK(ex.type() == moonbase::error_type::license_device_mismatch); + CHECK(std::string(ex.what()).find("predating versioned device fingerprints") + != std::string::npos); + } + } + + SUBCASE("with a migrating resolver, the same token validates") + { + auto resolver = std::make_shared( + fixed("Studio Mac", spec_id), fixed("Studio Mac", legacy_id)); + const moonbase::license_validator validator(options, resolver); + + const auto license = validator.validate_token(legacy_bound_token); + CHECK(license.id == "license-123"); + } + + SUBCASE("a token bound to some other machine is still rejected") + { + auto resolver = std::make_shared( + fixed("Studio Mac", spec_id), fixed("Studio Mac", legacy_id)); + const moonbase::license_validator validator(options, resolver); + + const auto foreign = moonbase::tests::make_token( + key.key.get(), moonbase::tests::default_claims("mbd2_" + std::string(64, 'f'))); + CHECK_THROWS_AS(validator.validate_token(foreign), moonbase::license_device_mismatch_error); + } +} diff --git a/tests/fingerprint_reader_tests.cpp b/tests/fingerprint_reader_tests.cpp new file mode 100644 index 0000000..2cfb3c7 --- /dev/null +++ b/tests/fingerprint_reader_tests.cpp @@ -0,0 +1,443 @@ +// The reader side of the device fingerprint: the source parsers, and the +// resolver's memoization. +// +// The parsers are pure and compiled on every platform, so the SMBIOS walker is +// fuzzed on the Linux sanitizer runner even though it only ever runs on Windows. +// That matters more than it sounds: it is the only binary blob this SDK parses +// that it did not write, and it is handed straight from firmware. + +#include +#include +#include +#include +#include + +#include + +#include "moonbase/errors.hpp" +#include "moonbase/fingerprint_spec.hpp" +#include "moonbase/moonbase_device_id_resolver.hpp" + +namespace fp = moonbase::fingerprint_spec; + +TEST_CASE("parse_ioreg_platform_uuid strips hyphens and uppercases") +{ + CHECK(fp::parse_ioreg_platform_uuid( + " | | \"IOPlatformUUID\" = \"c1234567-89ab-cdef-0123-456789abcdef\"\n") + == "C123456789ABCDEF0123456789ABCDEF"); + + SUBCASE("absent, empty or malformed input yields nothing") + { + CHECK(fp::parse_ioreg_platform_uuid("nothing here").empty()); + CHECK(fp::parse_ioreg_platform_uuid("").empty()); + CHECK(fp::parse_ioreg_platform_uuid("\"IOPlatformUUID\" = \"\"").empty()); + CHECK(fp::parse_ioreg_platform_uuid("\"IOPlatformUUID\" = \"unterminated").empty()); + CHECK(fp::parse_ioreg_platform_uuid("\"IOPlatformUUID\"").empty()); + } + + SUBCASE("whitespace around the assignment is tolerated") + { + CHECK(fp::parse_ioreg_platform_uuid("\"IOPlatformUUID\"\n\t= \"abc-def\"") == "ABCDEF"); + } +} + +TEST_CASE("select_machine_id validates each source before choosing it") +{ + const std::string valid = "b08dfa6083e7567a1921a715000001fb"; + const std::string other = "ffc0ffee83e7567a1921a715000001fb"; + + CHECK(fp::select_machine_id({valid}) == valid); + CHECK(fp::select_machine_id({"b08dfa6083e7567a1921a715000001fb\n"}) == valid); + + SUBCASE("an invalid first source falls through instead of stranding the rest") + { + // /etc/machine-id legitimately holds "uninitialized" in an initrd or a + // golden image awaiting first boot. Taking the first non-empty source + // would give every machine deployed from that image one device id, and + // would also hide a perfectly good D-Bus id. + CHECK(fp::select_machine_id({"uninitialized\n", other + "\n"}) == other); + CHECK(fp::select_machine_id({std::string(32, '0'), other}) == other); + CHECK(fp::select_machine_id({"", other}) == other); + } + + SUBCASE("rejected forms") + { + for (const std::string candidate : { + std::string("uninitialized"), + std::string(32, '0'), + std::string(32, 'f'), + std::string(32, 'F'), + std::string(31, 'a'), // too short + std::string(33, 'a'), // too long + std::string("B08DFA6083E7567A1921A715000001FB"), // uppercase + std::string("g08dfa6083e7567a1921a715000001fb"), // non-hex + std::string("b08dfa60-83e7-567a-1921-a715000001fb"), // hyphenated + std::string(""), + }) { + INFO("candidate: " << candidate); + CHECK(fp::select_machine_id({candidate}).empty()); + } + } + + SUBCASE("selection and canonicalization never disagree") + { + // The invariant that stops the two rules drifting: a value select_machine_id + // accepts must be one canonicalize_params would keep, or the machine ends + // up with an id built from a parameter the material then discards. + for (const std::string candidate : { + std::string("b08dfa6083e7567a1921a715000001fb"), + std::string(32, 'f'), + std::string("uninitialized"), + std::string(32, '0'), + std::string("g08dfa6083e7567a1921a715000001fb"), + }) { + INFO("candidate: " << candidate); + const bool selected = !fp::select_machine_id({candidate}).empty(); + const bool kept = !fp::canonicalize_params({{"machineId", candidate}}).empty(); + CHECK(selected <= kept); + } + } +} + +TEST_CASE("parse_smbios_params survives malformed tables") +{ + SUBCASE("degenerate buffers") + { + CHECK(fp::parse_smbios_params(nullptr, 0).empty()); + CHECK(fp::parse_smbios_params(std::vector{}).empty()); + CHECK(fp::parse_smbios_params(std::vector{0x01}).empty()); + CHECK(fp::parse_smbios_params(std::vector{0x01, 0x1B, 0x00}).empty()); + } + + SUBCASE("a length below the header size stops the walk") + { + CHECK(fp::parse_smbios_params(std::vector{0x01, 0x03, 0x00, 0x00}).empty()); + } + + SUBCASE("a length running past the end stops the walk") + { + CHECK(fp::parse_smbios_params(std::vector{0x01, 0x40, 0x00, 0x00, 0x00}).empty()); + } + + SUBCASE("a short type-1 bounds field reads by its own length") + { + // length == 4 means the formatted area is the header alone, so every field + // this SDK wants is out of bounds. Bounding by the buffer instead would + // read string indices out of the string pool and resolve garbage. + const std::vector table{ + 0x01, 0x04, 0x00, 0x00, 'A', 'C', 'M', 'E', 0x00, 0x00}; + const auto params = fp::parse_smbios_params(table); + REQUIRE(params.size() == 3); + for (const auto& param : params) { + INFO("parameter: " << param.first); + CHECK(param.second.empty()); + } + } + + SUBCASE("an unterminated string table does not run away") + { + const std::vector table{0x02, 0x08, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 'A', 'B'}; + CHECK_NOTHROW((void)fp::parse_smbios_params(table)); + } +} + +TEST_CASE("parse_smbios_params never crashes on arbitrary bytes") +{ + // A deterministic sweep rather than a real fuzzer: enough to give the ASan and + // UBSan jobs something meaningful to instrument on the one parser here whose + // input is entirely outside this SDK's control. + std::uint32_t state = 0x9E3779B9U; + const auto next = [&state] { + state ^= state << 13U; + state ^= state >> 17U; + state ^= state << 5U; + return state; + }; + + for (int iteration = 0; iteration != 20000; ++iteration) { + std::vector table(next() % 192U); + for (auto& byte : table) { + byte = static_cast(next() & 0xFFU); + } + // Bias towards plausible headers, so the walk gets past its first guard + // often enough to exercise the string-table logic. + if (table.size() >= 4 && (next() & 1U) != 0U) { + table[0] = static_cast((next() % 4U) + 1U); + table[1] = static_cast(4U + (next() % 32U)); + } + + CHECK_NOTHROW((void)fp::parse_smbios_params(table)); + } +} + +TEST_CASE("the resolver reads identity once and shares it across threads") +{ + std::atomic reads{0}; + + moonbase::moonbase_device_id_resolver_options options; + options.platform = "linux"; + options.reader = [&reads]() -> moonbase::device_identity { + reads.fetch_add(1); + return {{{"machineId", "b08dfa6083e7567a1921a715000001fb"}}, "host-1"}; + }; + const moonbase::moonbase_device_id_resolver resolver(options); + + constexpr int thread_count = 8; + std::vector ids(thread_count); + std::vector names(thread_count); + std::vector threads; + threads.reserve(thread_count); + + for (int index = 0; index != thread_count; ++index) { + threads.emplace_back([&, index] { + ids[static_cast(index)] = resolver.device_id(); + names[static_cast(index)] = resolver.device_name(); + }); + } + for (auto& thread : threads) { + thread.join(); + } + + // Reading per call would double the cost of every activation request and let + // the name and the id come from two different reads of the machine. + CHECK(reads.load() == 1); + for (int index = 0; index != thread_count; ++index) { + CHECK(ids[static_cast(index)] == ids[0]); + CHECK(names[static_cast(index)] == "host-1"); + } + CHECK(ids[0].rfind("mbd2_", 0) == 0); +} + +TEST_CASE("a failed identity read is retried, not cached") +{ + std::atomic attempts{0}; + + moonbase::moonbase_device_id_resolver_options options; + options.platform = "linux"; + options.reader = [&attempts]() -> moonbase::device_identity { + // Nothing identifying on the first call, a real id afterwards. A sticky + // failure would outlive the condition that caused it. + if (attempts.fetch_add(1) == 0) { + return {{{"sysVendor", "LENOVO"}}, "host-1"}; + } + return {{{"machineId", "b08dfa6083e7567a1921a715000001fb"}}, "host-1"}; + }; + const moonbase::moonbase_device_id_resolver resolver(options); + + CHECK_THROWS_AS(resolver.device_id(), moonbase::insufficient_device_identity_error); + // The identity read itself is memoized, so the retry sees the same params and + // fails the same way. What must not happen is the exception being replaced by + // a cached success or a different error. + CHECK_THROWS_AS(resolver.device_id(), moonbase::insufficient_device_identity_error); +} + +TEST_CASE("device_name survives a machine with no identity") +{ + moonbase::moonbase_device_id_resolver_options options; + options.platform = "unknown"; + options.reader = []() -> moonbase::device_identity { return {{}, "PC-1"}; }; + const moonbase::moonbase_device_id_resolver resolver(options); + + // Activation sends the name alongside the id, and support needs a label even + // for a machine that cannot be fingerprinted. + CHECK(resolver.device_name() == "PC-1"); + CHECK_THROWS_AS(resolver.device_id(), moonbase::insufficient_device_identity_error); +} + +TEST_CASE("the host-name fallback is opt-in and separately stamped") +{ + moonbase::moonbase_device_id_resolver_options options; + options.platform = "unknown"; + options.fallback = moonbase::device_id_fallback::device_name; + options.reader = []() -> moonbase::device_identity { return {{}, "PC-1"}; }; + const moonbase::moonbase_device_id_resolver resolver(options); + + // Matches the spec's worked example, including the real platform tag in the + // material and the `n` in the stamp so the weaker binding is visible. + CHECK(resolver.device_id() + == "mbd2n_493978eb157552e60a13694bd6861b2a82d0dba2746431a5f7921951ed460045"); + + const auto described = resolver.describe_device(); + REQUIRE(described.has_value()); + CHECK(described->source == fp::device_id_source::device_name); + CHECK(described->param_names == std::vector{"deviceName"}); + + SUBCASE("an empty host name is still insufficient identity") + { + moonbase::moonbase_device_id_resolver_options empty_name = options; + empty_name.reader = []() -> moonbase::device_identity { return {{}, ""}; }; + const moonbase::moonbase_device_id_resolver weak(empty_name); + CHECK_THROWS_AS(weak.device_id(), moonbase::insufficient_device_identity_error); + } +} + +TEST_CASE("Mac Catalyst takes the macOS hardware identity, not the scoped one") +{ + // TARGET_OS_IPHONE is 1 for Mac Catalyst, so the obvious test picks the scoped + // iOS path and emits an ios/mbd2s_ id. A Catalyst app runs on macOS and can + // read IOKit, so it must agree with an Electron or web SDK on the same Mac. +#if defined(__APPLE__) +#if TARGET_OS_OSX || TARGET_OS_MACCATALYST + CHECK(fp::platform_tag() == "mac"); +#else + CHECK(fp::platform_tag() == "ios"); +#endif + + // Whichever branch this build took, the tag must be one the spec defines and + // must match what the resolver's reader was compiled for. + const auto tag = fp::platform_tag(); + CHECK((tag == "mac" || tag == "ios")); +#if defined(MOONBASE_FINGERPRINT_USE_IOKIT) + // IOKit compiled in implies hardware identity, which implies the mac tag. + CHECK(tag == "mac"); +#endif +#endif +} + +TEST_CASE("the host-name fallback is refused on iOS and Android") +{ + // Not merely discouraged: forbidden. Since iOS 17 gethostname() returns the + // literal "localhost" on every device and UIDevice.name returns the model name, + // so an SDK that fell through here when identifierForVendor was momentarily + // absent would hand its entire install base one device id, and one activation + // would unlock every device. Absence is transient; the error is the answer. + for (const char* platform : {"ios", "android"}) { + INFO("platform: " << platform); + + moonbase::moonbase_device_id_resolver_options options; + options.platform = platform; + options.fallback = moonbase::device_id_fallback::device_name; + options.reader = []() -> moonbase::device_identity { return {{}, "localhost"}; }; + const moonbase::moonbase_device_id_resolver resolver(options); + + CHECK_THROWS_AS(resolver.device_id(), moonbase::insufficient_device_identity_error); + } + + SUBCASE("but still honoured where the host name means something") + { + moonbase::moonbase_device_id_resolver_options options; + options.platform = "unknown"; + options.fallback = moonbase::device_id_fallback::device_name; + options.reader = []() -> moonbase::device_identity { return {{}, "PC-1"}; }; + const moonbase::moonbase_device_id_resolver resolver(options); + CHECK(resolver.device_id().rfind("mbd2n_", 0) == 0); + } +} + +TEST_CASE("androidId must look like a real SSAID") +{ + // The rule that makes the JUCE defect mechanically impossible rather than + // merely documented: reading the Settings.Secure.ANDROID_ID static field yields + // the key name "android_id", identical on every device, and it is not hex. + CHECK(!fp::is_valid_android_id("android_id")); + CHECK(!fp::is_valid_android_id("A1B2C3D4E5F60718")); // uppercase + CHECK(!fp::is_valid_android_id("a1b2c3d4e5f607189")); // 17 chars + CHECK(!fp::is_valid_android_id("")); + CHECK(!fp::is_valid_android_id("9774d56d682e549c")); // real, but shared by many devices + CHECK(!fp::is_valid_android_id("0000000000000000")); + + CHECK(fp::is_valid_android_id("a1b2c3d4e5f60718")); + // 1..16, not exactly 16: AOSP before 8.0 used Long.toHexString, which drops + // leading zeros, so a strict 16 would reject roughly one in sixteen pre-Oreo + // devices. + CHECK(fp::is_valid_android_id("1b2c3d4e5f60718")); + CHECK(fp::is_valid_android_id("a")); + + SUBCASE("the shared sentinel is rejected for androidId only") + { + // It must NOT join the global placeholder list: that applies to every + // identifying parameter, so a Windows baseboard serial reading exactly this + // string would start producing a different device id and invalidate an + // existing binding without a spec version bump. + const auto kept = fp::canonicalize_params( + {{"systemManufacturer", "ACME"}, {"baseboardSerialNumber", "9774d56d682e549c"}}); + REQUIRE(kept.size() == 2); + CHECK(kept[1].second == "9774d56d682e549c"); + CHECK(!fp::is_not_programmed("9774d56d682e549c")); + } +} + +TEST_CASE("the mobile fallback ban is enforced at the material, not just the resolver") +{ + // In build_fingerprint_material so it also binds a custom reader and a native + // bridge assembling material directly, not only moonbase_device_id_resolver. + for (const char* platform : {"ios", "android"}) { + INFO("platform: " << platform); + CHECK_THROWS_AS( + fp::build_fingerprint_material(platform, {{"deviceName", "localhost"}}), + moonbase::insufficient_device_identity_error); + } + + // Everywhere else the fallback is still a legitimate opt-in. + CHECK_NOTHROW((void)fp::build_fingerprint_material("unknown", {{"deviceName", "PC-1"}})); +} + +TEST_CASE("describe_device reports names only, and hands out a copy") +{ + moonbase::moonbase_device_id_resolver_options options; + options.platform = "linux"; + options.reader = []() -> moonbase::device_identity { + return {{{"machineId", "b08dfa6083e7567a1921a715000001fb"}, + {"sysVendor", "LENOVO"}, + {"productName", ""}}, + "host-1"}; + }; + const moonbase::moonbase_device_id_resolver resolver(options); + + auto described = resolver.describe_device().value(); + CHECK(described.version == 2); + CHECK(described.platform == "linux"); + CHECK(described.source == fp::device_id_source::identity); + // Dropped parameters are absent, and no value ever appears: these are hardware + // serials, and an unsalted digest of one is a stable global correlator. + CHECK(described.param_names == std::vector{"machineId", "sysVendor"}); + + const auto id_before = resolver.device_id(); + described.device_id = "tampered"; + described.param_names.clear(); + CHECK(resolver.device_id() == id_before); + CHECK(resolver.describe_device()->param_names.size() == 2); +} + +TEST_CASE("the host resolver either produces a stamped id or refuses") +{ + // Tolerant on purpose: a container with no DMI and no machine-id legitimately + // has no device identity. What must never happen is a third outcome, such as + // a bare digest, an unstamped id, or a silent host-name substitution. + const moonbase::moonbase_device_id_resolver resolver; + + try { + const auto id = resolver.device_id(); + INFO("device id: " << id); + CHECK(id.size() == 69); + CHECK(id.rfind("mbd2_", 0) == 0); + + const auto parsed = fp::parse_device_id_stamp(id); + REQUIRE(parsed.has_value()); + CHECK(parsed->version == fp::version); + CHECK(parsed->source == fp::device_id_source::identity); + } catch (const moonbase::insufficient_device_identity_error& ex) { + INFO("no hardware identity on this host: " << ex.what()); + CHECK(ex.type() == moonbase::error_type::device_identity_unavailable); + } +} + +#if defined(_WIN32) +TEST_CASE("Windows reads a real SMBIOS table") +{ + // Not tolerant, deliberately. Before spec adoption this SDK built the RSMB + // provider signature byte-reversed, so GetSystemFirmwareTable returned 0 on + // every machine and every Windows device id silently degraded to a host-name + // hash. Nothing in the conformance vectors can catch that; only asserting on + // a real Windows host can. + const auto identity = moonbase::moonbase_device_id_resolver::read_host_identity(); + CHECK(!identity.params.empty()); + + const auto kept = fp::canonicalize_params(identity.params); + const bool identifies = std::any_of(kept.begin(), kept.end(), [](const fp::parameter& param) { + return fp::is_identifying_param(param.first); + }); + INFO("collected " << identity.params.size() << " parameters, " << kept.size() << " survived"); + CHECK(identifies); +} +#endif diff --git a/tests/fingerprint_spec_tests.cpp b/tests/fingerprint_spec_tests.cpp new file mode 100644 index 0000000..f30fa66 --- /dev/null +++ b/tests/fingerprint_spec_tests.cpp @@ -0,0 +1,482 @@ +// Conformance suite for the Moonbase device fingerprint spec. +// +// Driven by tests/vectors/fingerprint-vectors.json, vendored verbatim from +// @moonbase.sh/licensing. The spec prose is normative but the vectors are +// decisive: they are what every SDK can actually execute, so when the two +// disagree the vectors win. +// +// Everything exercised here is pure, so these cases run identically on Linux, +// macOS and Windows. That is the point of keeping the algorithm free of OS +// headers: the Windows SMBIOS parser and the macOS ioreg parser are covered on +// every runner, not only where they happen to execute. +// +// What this file cannot prove is that the platform *readers* agree with the +// reference SDK on real hardware, because those are the half that touches the +// machine. .github/workflows/fingerprint-parity.yml closes that gap. + +#include +#include +#include +#include + +#include +#include + +#include "moonbase/errors.hpp" +#include "moonbase/fingerprint_spec.hpp" + +#ifndef MOONBASE_FINGERPRINT_VECTORS_PATH +#error "MOONBASE_FINGERPRINT_VECTORS_PATH must be defined (see CMakeLists.txt)" +#endif + +namespace fp = moonbase::fingerprint_spec; + +namespace { + +const nlohmann::json& vectors() +{ + static const nlohmann::json loaded = [] { + std::ifstream file(MOONBASE_FINGERPRINT_VECTORS_PATH); + REQUIRE_MESSAGE(file.is_open(), "cannot open " MOONBASE_FINGERPRINT_VECTORS_PATH); + nlohmann::json json; + file >> json; + return json; + }(); + return loaded; +} + +/// Byte dump, so a failure involving invisible characters is readable. +std::string describe_bytes(const std::string& value) +{ + static constexpr char hex[] = "0123456789abcdef"; + std::string out; + for (const unsigned char byte : value) { + if (byte >= 0x20 && byte <= 0x7E) { + out.push_back(static_cast(byte)); + } else { + out += "\\x"; + out.push_back(hex[byte >> 4U]); + out.push_back(hex[byte & 0x0FU]); + } + } + return out; +} + +std::vector decode_hex(const std::string& text) +{ + const auto nibble = [](char character) { + if (character >= '0' && character <= '9') { + return character - '0'; + } + return (character | 0x20) - 'a' + 10; + }; + + std::vector out; + out.reserve(text.size() / 2); + for (std::size_t index = 0; index + 1 < text.size(); index += 2) { + out.push_back(static_cast(nibble(text[index]) * 16 + nibble(text[index + 1]))); + } + return out; +} + +fp::device_id_source source_from_name(const std::string& name) +{ + if (name == "identity") return fp::device_id_source::identity; + if (name == "deviceName") return fp::device_id_source::device_name; + if (name == "scoped") return fp::device_id_source::scoped; + FAIL("unknown source in vectors: " << name); + return fp::device_id_source::identity; +} + +fp::parameter_list to_params(const nlohmann::json& json) +{ + fp::parameter_list params; + for (const auto& pair : json) { + params.emplace_back(pair.at(0).get(), pair.at(1).get()); + } + return params; +} + +} // namespace + +TEST_CASE("vectors pin the spec version this SDK implements") +{ + // The cross-SDK drift alarm. If the vendored vector file moves to a new spec + // version, this fails before any individual vector does, which is a much + // clearer signal than a wall of digest mismatches. + CHECK(vectors().at("version").get() == fp::version); + CHECK(vectors().at("materialPrefix").get() == fp::prefix); + + const auto stamp_prefix = vectors().at("stampPrefix").get(); + CHECK(fp::stamp_device_id(std::string(64, 'a')).rfind(stamp_prefix, 0) == 0); + + // Every tag the vectors define must be the one this SDK emits for that source. + const auto digest = std::string(64, 'a'); + for (const auto& [name, tag] : vectors().at("sourceTags").items()) { + INFO("source: " << name); + CHECK(fp::stamp_device_id(digest, source_from_name(name)) + == "mbd" + std::to_string(fp::version) + tag.get() + "_" + digest); + } +} + +TEST_CASE("canonicalizeValue vectors") +{ + for (const auto& vector : vectors().at("canonicalizeValue")) { + const auto input = vector.at("input").get(); + const auto expected = vector.at("expected").get(); + + INFO("vector: " << vector.at("description").get()); + INFO("input: " << describe_bytes(input)); + CHECK(fp::canonicalize_value(input) == expected); + } +} + +TEST_CASE("material vectors") +{ + for (const auto& vector : vectors().at("material")) { + const auto platform = vector.at("platform").get(); + const auto expected_device_id = vector.at("deviceId").get(); + // Read the declared source rather than sniffing the stamp: a prefix sniff + // silently mislabels any tag it was not taught about. + const auto source = source_from_name(vector.at("source").get()); + + INFO("vector: " << vector.at("description").get()); + + const auto material = fp::build_fingerprint_material(platform, to_params(vector.at("params"))); + CHECK(material == vector.at("material").get()); + CHECK(fp::fingerprint_digest(material) == vector.at("digest").get()); + CHECK(fp::fingerprint_device_id(material, source) == expected_device_id); + } +} + +TEST_CASE("material is LF-joined and never LF-terminated") +{ + // Its own case, because appending a newline after every line is the single + // most likely way to produce an SDK that looks correct and agrees with + // nothing, and a bare digest mismatch would not say so. + for (const auto& vector : vectors().at("material")) { + const auto material = fp::build_fingerprint_material( + vector.at("platform").get(), to_params(vector.at("params"))); + + INFO("vector: " << vector.at("description").get()); + REQUIRE(!material.empty()); + CHECK(material.back() != '\n'); + } +} + +TEST_CASE("materialErrors vectors") +{ + for (const auto& vector : vectors().at("materialErrors")) { + const auto platform = vector.at("platform").get(); + const auto params = to_params(vector.at("params")); + const auto kind = vector.at("error").get(); + + INFO("vector: " << vector.at("description").get()); + INFO("expected error: " << kind); + + if (kind == "InsufficientDeviceIdentity") { + CHECK_THROWS_AS( + fp::build_fingerprint_material(platform, params), + moonbase::insufficient_device_identity_error); + } else if (kind == "DuplicateParameter") { + CHECK_THROWS_AS( + fp::build_fingerprint_material(platform, params), + moonbase::duplicate_fingerprint_parameter_error); + } else { + FAIL("unknown error kind in vectors: " << kind); + } + } +} + +TEST_CASE("smbios vectors") +{ + for (const auto& vector : vectors().at("smbios")) { + const auto table = decode_hex(vector.at("table").get()); + const auto expected = to_params(vector.at("params")); + const auto actual = fp::parse_smbios_params(table); + + INFO("vector: " << vector.at("description").get()); + + REQUIRE(actual.size() == expected.size()); + for (std::size_t index = 0; index != expected.size(); ++index) { + INFO("parameter " << index); + CHECK(actual[index].first == expected[index].first); + // Compared including empty values: describing the firmware is the + // parser's job, and deciding what counts is canonicalization's. + CHECK(actual[index].second == expected[index].second); + } + } +} + +TEST_CASE("stamp vectors") +{ + for (const auto& vector : vectors().at("stamps")) { + const auto device_id = vector.at("deviceId").get(); + const auto parsed = fp::parse_device_id_stamp(device_id); + + INFO("vector: " << vector.at("description").get()); + + if (vector.at("parsed").is_null()) { + CHECK(!parsed.has_value()); + continue; + } + + REQUIRE(parsed.has_value()); + const auto& expected = vector.at("parsed"); + CHECK(parsed->version == expected.at("version").get()); + CHECK(parsed->digest == expected.at("digest").get()); + CHECK(parsed->source_tag == expected.at("sourceTag").get()); + + if (expected.at("source").is_null()) { + // A tag a newer SDK introduced: it must still parse, with the literal + // tag preserved and no meaning attached. + CHECK(!parsed->source.has_value()); + } else { + REQUIRE(parsed->source.has_value()); + CHECK(*parsed->source == source_from_name(expected.at("source").get())); + } + } +} + +TEST_CASE("digest is SHA-256 over UTF-8, lowercase hex") +{ + // Pins all three interchangeable crypto backends (OpenSSL, Security.framework, + // CNG) to one well-known vector. More valuable here than in a single-backend + // SDK: a backend that disagreed would silently invalidate every license. + CHECK(fp::fingerprint_digest("abc") + == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +} + +TEST_CASE("scoped identity is stamped and round-trips") +{ + // A third source tag alongside the hardware id and the host-name fallback, for + // platforms whose only device identifier is scoped to the publisher (iOS + // identifierForVendor, Android ANDROID_ID). The stamp is the whole point: it + // travels with the value so a validator, a server and an analytics pipeline all + // know the id must not be correlated across publishers. + const auto digest = std::string(64, 'a'); + const auto scoped = fp::stamp_device_id(digest, fp::device_id_source::scoped); + + CHECK(scoped == "mbd2s_" + digest); + CHECK(scoped.size() == 70); + + const auto parsed = fp::parse_device_id_stamp(scoped); + REQUIRE(parsed.has_value()); + CHECK(parsed->version == fp::version); + CHECK(parsed->source == fp::device_id_source::scoped); + CHECK(parsed->digest == digest); + + SUBCASE("the three source tags are distinct ids for the same digest") + { + CHECK(fp::stamp_device_id(digest, fp::device_id_source::identity) != scoped); + CHECK(fp::stamp_device_id(digest, fp::device_id_source::device_name) != scoped); + } + + SUBCASE("an unknown source tag parses, with no meaning attached") + { + // Rejecting it would report a perfectly valid id from a newer SDK as "not a + // Moonbase device id". The tag is preserved literally so a diagnostic can + // name it; only its *meaning* is unknown. + const auto unknown = fp::parse_device_id_stamp("mbd2x_" + digest); + REQUIRE(unknown.has_value()); + CHECK(unknown->source_tag == "x"); + CHECK(!unknown->source.has_value()); + CHECK(unknown->version == 2); + + // [a-z]*, so a future two-letter tag needs no version bump either. + const auto two_letter = fp::parse_device_id_stamp("mbd2zz_" + digest); + REQUIRE(two_letter.has_value()); + CHECK(two_letter->source_tag == "zz"); + + // Uppercase is not a tag, and a digit would make the version ambiguous. + CHECK(!fp::parse_device_id_stamp("mbd2S_" + digest).has_value()); + CHECK(!fp::parse_device_id_stamp("mbd2n2_" + digest).has_value()); + } +} + +TEST_CASE("scoped platforms build spec-shaped material") +{ + // iOS: one identifying parameter, hyphens stripped and uppercased exactly as + // ioPlatformUuid is, so the two platforms stay consistent. + const auto material = fp::build_fingerprint_material( + "ios", {{"identifierForVendor", "C1234567-89AB-CDEF-0123-456789ABCDEF"}}); + CHECK(material + == "moonbase:fingerprint:v2\nplatform=ios\nidentifierForVendor=C1234567-89AB-CDEF-0123-456789ABCDEF"); + + CHECK(fp::is_identifying_param("identifierForVendor")); + CHECK(fp::is_identifying_param("androidId")); + + SUBCASE("an absent scoped identifier is insufficient identity, not a constant") + { + // identifierForVendor is nil until first unlock after boot, and ANDROID_ID + // is empty before user setup. Hashing either would give every device in that + // state one shared id. + CHECK_THROWS_AS( + fp::build_fingerprint_material("ios", {{"identifierForVendor", ""}}), + moonbase::insufficient_device_identity_error); + CHECK_THROWS_AS( + fp::build_fingerprint_material("android", {{"androidId", ""}}), + moonbase::insufficient_device_identity_error); + } +} + +TEST_CASE("identifying parameters are fixed, in order, and not widenable") +{ + const std::vector expected{ + "ioPlatformUuid", "machineId", "systemUuid", "baseboardSerialNumber", + "identifierForVendor", "androidId", "deviceName"}; + CHECK(fp::identifying_param_names() == expected); + + // Returned by value, so a consumer cannot turn "this model has no identity" + // into "it does" for the rest of the process. + auto names = fp::identifying_param_names(); + names.emplace_back("sysVendor"); + CHECK_THROWS_AS( + fp::build_fingerprint_material("linux", {{"sysVendor", "LENOVO"}}), + moonbase::insufficient_device_identity_error); + CHECK(!fp::is_identifying_param("sysVendor")); +} + +TEST_CASE("NFC runs before the ASCII filter") +{ + // None of these are in the vector file, and every one of them is a case a + // naive "strip non-ASCII" port gets wrong. Derived exhaustively by + // scripts/gen-nfc-tables.py; see detail/unicode/nfc_ascii.hpp. + SUBCASE("singleton decompositions expose a printable ASCII character") + { + CHECK(fp::canonicalize_value("\xE2\x84\xAA") == "K"); // U+212A KELVIN SIGN + CHECK(fp::canonicalize_value("\xCD\xBE") == ";"); // U+037E GREEK QUESTION MARK + CHECK(fp::canonicalize_value("\xE1\xBF\xAF") == "`"); // U+1FEF GREEK VARIA + } + + SUBCASE("a combining mark annihilates the ASCII base it composes with") + { + CHECK(fp::canonicalize_value("cafe\xCC\x81") == "caf"); // the spec's worked example + CHECK(fp::canonicalize_value("=\xCC\xB8") == ""); // '=' + U+0338 -> U+2260 + } + + SUBCASE("marks that decompose are expanded first") + { + CHECK(fp::canonicalize_value("A\xCD\x81") == ""); // U+0341 -> U+0301 + CHECK(fp::canonicalize_value("e\xCD\x84") == ""); // U+0344 -> U+0308 U+0301 + } + + SUBCASE("a mark that does not compose leaves the base alone") + { + CHECK(fp::canonicalize_value("e\xCC\x85") == "e"); // U+0305 has no composite with 'e' + } + + SUBCASE("blocking depends on order, not just membership") + { + // Same two marks, opposite order. U+0305 and U+0301 share combining class + // 230, so whichever comes first blocks the other. + CHECK(fp::canonicalize_value("e\xCC\x85\xCC\x81") == "e"); + CHECK(fp::canonicalize_value("e\xCC\x81\xCC\x85") == ""); + } +} + +TEST_CASE("malformed UTF-8 is dropped without swallowing neighbours") +{ + // Firmware strings are the realistic source. Exactly one byte is consumed per + // malformed byte, so a bad prefix cannot eat the character after it, and a + // truncated combining mark cannot annihilate the character before it. + CHECK(fp::canonicalize_value("AB\xC3") == "AB"); // truncated 2-byte lead + CHECK(fp::canonicalize_value("A\xFF" "B") == "AB"); // never a valid lead + CHECK(fp::canonicalize_value("A\xC1\xA0" "B") == "AB"); // overlong 'a' + CHECK(fp::canonicalize_value("A\xED\xA0\x80" "B") == "AB"); // encoded surrogate + CHECK(fp::canonicalize_value("A\x80" "B") == "AB"); // bare continuation byte + CHECK(fp::canonicalize_value("e\xCC") == "e"); // truncated combining mark +} + +TEST_CASE("unprogrammed placeholders are recognised case-insensitively") +{ + for (const char* filler : {"to be filled by o.e.m.", + "TO BE FILLED BY O.E.M.", + "To Be Filled By OEM", + "Default string", + "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"}) { + INFO("filler: " << filler); + CHECK(fp::is_not_programmed(filler)); + } + + SUBCASE("all-zero and all-f values are placeholders too") + { + CHECK(fp::is_not_programmed("00000000000000000000000000000000")); + CHECK(fp::is_not_programmed("ffffffffffffffffffffffffffffffff")); + CHECK(fp::is_not_programmed("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")); + CHECK(fp::is_not_programmed("0")); + } + + SUBCASE("an empty value is not a placeholder") + { + // It is dropped by the empty rule instead. Reporting it here would make + // the all-zero test above accidentally match everything. + CHECK(!fp::is_not_programmed("")); + } + + SUBCASE("real values are not placeholders") + { + CHECK(!fp::is_not_programmed("BSN-42")); + CHECK(!fp::is_not_programmed("b08dfa6083e7567a1921a715000001fb")); + CHECK(!fp::is_not_programmed("none of your business")); + } + + SUBCASE("the rule applies to identifying parameters only") + { + // A model may genuinely be named "None"; a serial number reading it is + // not a serial number. + const auto kept = fp::canonicalize_params({{"machineId", "b08dfa60"}, {"productName", "None"}}); + REQUIRE(kept.size() == 2); + CHECK(kept[1].first == "productName"); + + const auto dropped = + fp::canonicalize_params({{"machineId", "b08dfa60"}, {"baseboardSerialNumber", "None"}}); + CHECK(dropped.size() == 1); + } +} + +TEST_CASE("duplicate detection runs after the drop filters") +{ + // Two entries sharing a name are not a duplicate when one of them does not + // survive, which is what lets a reader emit a parameter unconditionally. + CHECK_NOTHROW(fp::canonicalize_params({{"systemUuid", ""}, {"systemUuid", "AAAA"}})); + CHECK_THROWS_AS( + fp::canonicalize_params({{"systemUuid", "AAAA"}, {"systemUuid", "BBBB"}}), + moonbase::duplicate_fingerprint_parameter_error); +} + +TEST_CASE("insufficient identity distinguishes nothing-read from model-only") +{ + using moonbase::insufficient_device_identity_error; + + try { + (void)fp::build_fingerprint_material("linux", {}); + FAIL("expected insufficient_device_identity_error"); + } catch (const insufficient_device_identity_error& ex) { + CHECK(ex.platform() == "linux"); + CHECK(std::string(ex.what()).find("platform: linux") != std::string::npos); + } + + try { + (void)fp::build_fingerprint_material("linux", {{"sysVendor", "LENOVO"}, {"boardName", "20HRCTO1WW"}}); + FAIL("expected insufficient_device_identity_error"); + } catch (const insufficient_device_identity_error& ex) { + // The message names the parameters that did read, because "no identity" + // on a machine that clearly reported something is otherwise baffling. + CHECK(ex.reason().find("sysVendor") != std::string::npos); + CHECK(ex.reason().find("boardName") != std::string::npos); + CHECK(ex.reason().find("model-level") != std::string::npos); + } +} diff --git a/tests/fingerprint_tests.cpp b/tests/fingerprint_tests.cpp index 0aee116..7e35717 100644 --- a/tests/fingerprint_tests.cpp +++ b/tests/fingerprint_tests.cpp @@ -1,20 +1,33 @@ +// Golden vectors for the pre-4.0.0 device id, FROZEN. +// +// These three digests are the only thing standing between an innocent-looking +// tidy-up of legacy_fingerprint.hpp and every license issued under the old +// algorithm failing to validate. legacy_cpp_device_id_resolver exists solely so a +// migrating_device_id_resolver can keep accepting those bindings, which requires +// it to reproduce them exactly, defects and all. +// +// If a change here makes one of these fail, the change is wrong. Do not update +// the expected values. +// +// The current algorithm is covered by tests/fingerprint_spec_tests.cpp against +// the shipped conformance vectors. + #include -#include "moonbase/default_fingerprint.hpp" -#include "moonbase/fingerprint.hpp" +#include "moonbase/legacy_fingerprint.hpp" using namespace moonbase; -TEST_CASE("default fingerprint hashes structured identity parameters") +TEST_CASE("legacy v1 fingerprint hashes structured identity parameters") { - CHECK(default_fingerprint_provider::hash_identity_parameters( + CHECK(legacy_cpp_device_id_resolver::hash_identity_parameters( { {"ioPlatformUuid", "ABC123"}, {"cpuModel", "M1"}, }, "mac") == "0789f32ee2a491fec91d99f8fcdcb62957c84748c53577bb794cd55e89e1828c"); - CHECK(default_fingerprint_provider::hash_identity_parameters( + CHECK(legacy_cpp_device_id_resolver::hash_identity_parameters( { {"boardSerial", "SERIAL-1"}, {"cpuVendor", "GenuineIntel"}, @@ -22,9 +35,9 @@ TEST_CASE("default fingerprint hashes structured identity parameters") "linux") == "d519035802e134e02a42c31344c80071b4872c5744a787900f8b4d2795630719"); } -TEST_CASE("default fingerprint hashing trims empty or padded parameters") +TEST_CASE("legacy v1 fingerprint hashing trims empty or padded parameters") { - CHECK(default_fingerprint_provider::hash_identity_parameters( + CHECK(legacy_cpp_device_id_resolver::hash_identity_parameters( { {" boardSerial ", " SERIAL-1\n"}, {"empty", " \t"}, @@ -32,3 +45,21 @@ TEST_CASE("default fingerprint hashing trims empty or padded parameters") }, "linux") == "d519035802e134e02a42c31344c80071b4872c5744a787900f8b4d2795630719"); } + +TEST_CASE("legacy v1 material is LF-terminated, unlike the spec's LF-joined form") +{ + // Pinning the shape, not just the digest, so the reason these digests differ + // from spec v2 stays visible: v1 ends every line with a newline, including + // the last, and carries a moonbase-cpp-specific prefix. + const auto legacy = legacy_cpp_device_id_resolver::hash_identity_parameters( + {{"ioPlatformUuid", "0123456789ABCDEF0123456789ABCDEF"}}, "mac"); + + CHECK(legacy + == detail::sha256_hex( + "moonbase-cpp:fingerprint:v1\nplatform=mac\nioPlatformUuid=0123456789ABCDEF0123456789ABCDEF\n")); + + // And it is a bare digest: no mbd2_ stamp, so a validator can tell a v1 + // binding from a spec one. + CHECK(legacy.size() == 64); + CHECK(legacy.rfind("mbd", 0) != 0); +} diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index fa74cd7..7485034 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -51,8 +51,8 @@ bool settled(const ActivationController& c) struct controller_fixture { moonbase::tests::generated_key key = moonbase::tests::generate_key(); - std::shared_ptr fingerprint = - std::make_shared("Studio Mac", "device-id"); + std::shared_ptr fingerprint = + std::make_shared("Studio Mac", "device-id"); std::shared_ptr transport = std::make_shared(); std::shared_ptr store; juce::File licenseFile; @@ -1065,6 +1065,175 @@ TEST_CASE("destroying the controller mid-request cancels and joins without hangi CHECK(blocking->entered.load()); } +//============================================================================== +// Device identity +//============================================================================== +TEST_CASE("the module default is the cross-SDK spec resolver, except on iOS") +{ + controller_fixture fx; + + auto resolved = fx.config.resolvedDeviceIdResolver(); + REQUIRE(resolved != nullptr); + + if (ActivationConfig::hasScopedIdentityOnly) + { + // iOS and Android get a scoped spec identity, because their only device + // identifiers are scoped by the platform (identifierForVendor to the + // vendor, ANDROID_ID to the app signing key). The mbd2s_ stamp makes that + // legible rather than implicit. Deliberately NOT the host-name fallback: + // since iOS 17 gethostname() returns "localhost" on every device and + // UIDevice.name returns "iPhone", so it would give a whole install base + // one id. + const auto id = resolved->device_id(); + CHECK(id.rfind("mbd2s_", 0) == 0); + CHECK(id.size() == 70); + + const auto stamp = moonbase::fingerprint_spec::parse_device_id_stamp(id); + REQUIRE(stamp.has_value()); + CHECK(stamp->source == moonbase::fingerprint_spec::device_id_source::scoped); + + const auto described = resolved->describe_device(); + REQUIRE(described.has_value()); + CHECK(described->platform == (ActivationConfig::isAndroid ? "android" : "ios")); + CHECK(described->param_names + == std::vector{ + ActivationConfig::isAndroid ? "androidId" : "identifierForVendor"}); + + // And it came from the core resolver, not a JUCE-specific one: the SDK + // reads every platform natively, which is what lets the bridge and any + // non-JUCE consumer get the same id. + CHECK(described->version == moonbase::fingerprint_spec::version); + return; + } + + // Everywhere else: an unconfigured plugin computes the same device id as + // @moonbase.sh/licensing on the same machine, which is the point of 4.0.0. + try + { + const auto id = resolved->device_id(); + CHECK(id.rfind("mbd2_", 0) == 0); + CHECK(id.size() == 69); + } + catch (const moonbase::insufficient_device_identity_error&) + { + // A runner may genuinely have no hardware identity, and refusing is the + // correct answer. What matters is that the default is no longer the old + // SystemStats id, which never throws and never carries a stamp. + MESSAGE("no hardware identity on this host"); + } +} + +TEST_CASE("allowDeviceNameFallback opts into the weaker host-name id") +{ + controller_fixture fx; + fx.config.allowDeviceNameFallback = true; + + if (ActivationConfig::hasScopedIdentityOnly) + { + // Forbidden outright on iOS and Android: the host name there is + // "localhost" or a model name, so the fallback would hand an entire + // install base one device id. The ladder is scoped, then insufficient. + const auto id = fx.config.resolvedDeviceIdResolver()->device_id(); + CHECK(id.rfind("mbd2s_", 0) == 0); + return; + } + + const auto described = fx.config.resolvedDeviceIdResolver()->describe_device(); + REQUIRE(described.has_value()); + + // Whether this host has hardware identity or not, the weaker binding must be + // separately stamped so the server and support can tell them apart. + const bool stamped_weaker = described->device_id.rfind("mbd2n_", 0) == 0; + CHECK((described->source == moonbase::fingerprint_spec::device_id_source::device_name) + == stamped_weaker); +} + +TEST_CASE("an explicit deviceIdResolver overrides the default") +{ + controller_fixture fx; + fx.config.deviceIdResolver = + std::make_shared("Studio Mac", "custom-device-id"); + + CHECK(fx.config.resolvedDeviceIdResolver()->device_id() == "custom-device-id"); + + // A custom resolver's id is compared literally and needs no mbd2_ stamp. + // seedRawToken, not seedStored: the fixture's own resolver would reject a token + // bound to this config's id before it could be written. + fx.seedRawToken(fx.token(default_claims("custom-device-id"))); + ActivationController controller(fx.config); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + CHECK(controller.screen() == Screen::Details); +} + +TEST_CASE("a migrating resolver keeps a license bound under the old id working") +{ + controller_fixture fx; + + // What an already-shipped plugin does on upgrade: bind the spec id on new + // activations, while still accepting the id this device was bound to before. + const std::string legacyId = "old-juce-unique-device-id"; + fx.config.deviceIdResolver = std::make_shared( + std::make_shared("Studio Mac", "mbd2_" + std::string(64, 'a')), + std::make_shared("Studio Mac", legacyId)); + + // seedRawToken: the fixture's resolver is not the migrating one under test. + fx.seedRawToken(fx.token(default_claims(legacyId))); + + ActivationController controller(fx.config); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + + // Without the wrapper this lands on Welcome with the user locked out, costing + // them a re-activation and an activation seat. + CHECK(controller.screen() == Screen::Details); +} + +TEST_CASE("without a migration, an old binding is diagnosed as a device mismatch") +{ + controller_fixture fx; + juce::StringArray diags; + fx.config.onDiagnostic = [&](const juce::String& message) { diags.add(message); }; + fx.config.deviceIdResolver = + std::make_shared("Studio Mac", "mbd2_" + std::string(64, 'a')); + + fx.seedRawToken(fx.token(default_claims("old-juce-unique-device-id"))); + + ActivationController controller(fx.config); + controller.start(); + REQUIRE(pumpUntil([&] { return settled(controller); })); + + // The diagnostic must name the real problem and point at the remedy, rather + // than the old blanket "not valid for this device". + const auto joined = diags.joinIntoString(" | "); + INFO("diagnostics: " << joined); + CHECK(joined.contains("not bound to this device")); + CHECK(joined.contains("migrating_device_id_resolver")); +} + +TEST_CASE("describeDevice reports provenance, and nothing for an opaque resolver") +{ + controller_fixture fx; + ActivationController controller(fx.config); + + if (const auto described = controller.describeDevice()) + { + CHECK(described->version == 2); + CHECK(!described->platform.empty()); + for (const auto& name : described->param_names) + CHECK(!name.empty()); + } + + SUBCASE("a custom resolver that cannot describe itself yields nothing") + { + controller_fixture custom; + custom.config.deviceIdResolver = + std::make_shared("Studio Mac", "custom-device-id"); + ActivationController other(custom.config); + CHECK(!other.describeDevice().has_value()); + } +} + //============================================================================== int main(int argc, char** argv) { diff --git a/tests/licensing_tests.cpp b/tests/licensing_tests.cpp index c4f9a0d..35bbc72 100644 --- a/tests/licensing_tests.cpp +++ b/tests/licensing_tests.cpp @@ -10,7 +10,7 @@ #include -#include "moonbase/fingerprint.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/licensing.hpp" #include "test_helpers.hpp" @@ -21,8 +21,8 @@ namespace { struct facade_fixture { moonbase::tests::generated_key key = moonbase::tests::generate_key(); - std::shared_ptr fingerprints = - std::make_shared("Test Device", "device-id"); + std::shared_ptr fingerprints = + std::make_shared("Test Device", "device-id"); std::shared_ptr transport = std::make_shared(); licensing instance; @@ -398,7 +398,7 @@ class throwing_license_store : public license_store { TEST_CASE("revoke_activation succeeds even if local store cleanup fails") { auto fingerprints = - std::make_shared("Test Device", "device-id"); + std::make_shared("Test Device", "device-id"); auto transport = std::make_shared(); auto store = std::make_shared(); @@ -482,7 +482,7 @@ TEST_CASE("validate_token_online deduplicates concurrent in-process callers") // Set up a fresh facade with a thread-safe counting transport instead of // recording_transport (which is not safe under concurrent send()). auto fingerprints = - std::make_shared("Test Device", "device-id"); + std::make_shared("Test Device", "device-id"); auto transport = std::make_shared(); moonbase::tests::generated_key key = moonbase::tests::generate_key(); @@ -542,7 +542,7 @@ TEST_CASE("validate_token_online deduplicates concurrent in-process callers") TEST_CASE("validate_token_online acquires the store update lock once per online check") { auto fingerprints = - std::make_shared("Test Device", "device-id"); + std::make_shared("Test Device", "device-id"); auto transport = std::make_shared(); auto store = std::make_shared(); @@ -699,7 +699,7 @@ TEST_CASE("revoke_activation acquires the store update lock around its cleanup") // resurrecting the license the user just revoked. The lock makes the // load+delete atomic with respect to any concurrent persist. auto fingerprints = - std::make_shared("Test Device", "device-id"); + std::make_shared("Test Device", "device-id"); auto transport = std::make_shared(); auto store = std::make_shared(); diff --git a/tests/live_tests.cpp b/tests/live_tests.cpp index 9e6271c..c8e3bef 100644 --- a/tests/live_tests.cpp +++ b/tests/live_tests.cpp @@ -47,7 +47,7 @@ TEST_CASE("live API activation flow") options.online_validation_min_interval = std::chrono::seconds(0); const auto unique_id = "moonbase-cpp-test-" + std::to_string(moonbase::tests::now_seconds()); - auto fingerprints = std::make_shared("Moonbase C++ Test", unique_id); + auto fingerprints = std::make_shared("Moonbase C++ Test", unique_id); auto transport = std::make_shared(); licensing sdk(options, nullptr, fingerprints, transport); diff --git a/tests/process_dedup_tests.cpp b/tests/process_dedup_tests.cpp index b70f607..596aa8a 100644 --- a/tests/process_dedup_tests.cpp +++ b/tests/process_dedup_tests.cpp @@ -21,7 +21,7 @@ #include -#include "moonbase/fingerprint.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/http.hpp" #include "moonbase/licensing.hpp" #include "moonbase/store.hpp" @@ -117,7 +117,7 @@ TEST_CASE("validate_token_online deduplicates across forked processes") { license_validator seeder( opts, - std::make_shared("Test Device", "device-id")); + std::make_shared("Test Device", "device-id")); file_license_store seed(store_path); seed.store_local_license(seeder.validate_token(stale_token)); } @@ -135,7 +135,7 @@ TEST_CASE("validate_token_online deduplicates across forked processes") // atexit teardown (which would otherwise double-report). try { auto fingerprint = - std::make_shared("Test Device", "device-id"); + std::make_shared("Test Device", "device-id"); auto transport = std::make_shared(); transport->counter_path = counter_path; transport->response_body = refreshed_token; diff --git a/tests/validator_tests.cpp b/tests/validator_tests.cpp index bee2209..47dd7b9 100644 --- a/tests/validator_tests.cpp +++ b/tests/validator_tests.cpp @@ -4,7 +4,7 @@ #include #include "moonbase/detail/time.hpp" -#include "moonbase/fingerprint.hpp" +#include "moonbase/device_id_resolver.hpp" #include "moonbase/validator.hpp" #include "test_helpers.hpp" @@ -28,7 +28,7 @@ license_validator make_validator(const std::string& public_key, const std::strin { return license_validator( options_for(public_key), - std::make_shared("device-name", device_id)); + std::make_shared("device-name", device_id)); } } // namespace diff --git a/tests/vectors/fingerprint-vectors.json b/tests/vectors/fingerprint-vectors.json new file mode 100644 index 0000000..3f90011 --- /dev/null +++ b/tests/vectors/fingerprint-vectors.json @@ -0,0 +1,938 @@ +{ + "$comment": "Conformance suite for the Moonbase device fingerprint spec (FINGERPRINT_SPEC.md). Every Moonbase SDK must reproduce every vector here. Note that no material ends with a newline.", + "version": 2, + "materialPrefix": "moonbase:fingerprint:v2", + "stampPrefix": "mbd2_", + "sourceTags": { + "identity": "", + "deviceName": "n", + "scoped": "s" + }, + "canonicalizeValue": [ + { + "description": "trailing newline from a sysfs read", + "input": "LENOVO\n", + "expected": "LENOVO" + }, + { + "description": "surrounding whitespace of every kind", + "input": " \t\r\nvalue\n\r \t", + "expected": "value" + }, + { + "description": "interior spaces survive", + "input": " a b ", + "expected": "a b" + }, + { + "description": "interior control characters are removed, not preserved", + "input": "a\nb", + "expected": "ab" + }, + { + "description": "non-ASCII is removed", + "input": "caf\u00e9", + "expected": "caf" + }, + { + "description": "NFC first: a decomposed e-acute composes, then drops whole", + "input": "cafe\u0301", + "expected": "caf" + }, + { + "description": "a non-breaking space is not ASCII and is removed", + "input": "a\u00a0b", + "expected": "ab" + }, + { + "description": "capped at 128 characters", + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "description": "trimming runs after the cap, so no trailing space survives", + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa b", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "description": "an all-whitespace value is empty and gets dropped", + "input": " \t\r\n", + "expected": "" + } + ], + "material": [ + { + "description": "macOS, the single identity parameter", + "platform": "mac", + "source": "identity", + "params": [ + [ + "ioPlatformUuid", + "0123456789ABCDEF0123456789ABCDEF" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=mac\nioPlatformUuid=0123456789ABCDEF0123456789ABCDEF", + "digest": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "deviceId": "mbd2_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32" + }, + { + "description": "Linux, machine-id plus world-readable DMI model fields", + "platform": "linux", + "source": "identity", + "params": [ + [ + "machineId", + "b08dfa6083e7567a1921a715000001fb\n" + ], + [ + "sysVendor", + "LENOVO\n" + ], + [ + "productName", + "20HRCTO1WW\n" + ], + [ + "boardVendor", + "LENOVO\n" + ], + [ + "boardName", + "20HRCTO1WW\n" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=linux\nmachineId=b08dfa6083e7567a1921a715000001fb\nsysVendor=LENOVO\nproductName=20HRCTO1WW\nboardVendor=LENOVO\nboardName=20HRCTO1WW", + "digest": "ba16d78604f90c6c8b00dc1065a70c866884fadfa818ed2db83b2bfe0dc94933", + "deviceId": "mbd2_ba16d78604f90c6c8b00dc1065a70c866884fadfa818ed2db83b2bfe0dc94933" + }, + { + "description": "Windows, first System and Baseboard structures", + "platform": "windows", + "source": "identity", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ], + [ + "baseboardManufacturer", + "ACME" + ], + [ + "baseboardProduct", + "MB-1" + ], + [ + "baseboardSerialNumber", + "BSN-42" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=windows\nsystemManufacturer=ACME\nsystemProductName=Server 9000\nsystemUuid=0123456789ABCDEF0123456789ABCDEF\nbaseboardManufacturer=ACME\nbaseboardProduct=MB-1\nbaseboardSerialNumber=BSN-42", + "digest": "fadd75457e44f669e9865caff122b4706a4501089ac9e73b8735139bf57676ad", + "deviceId": "mbd2_fadd75457e44f669e9865caff122b4706a4501089ac9e73b8735139bf57676ad" + }, + { + "description": "empty and whitespace-only values are dropped, order preserved", + "platform": "linux", + "source": "identity", + "params": [ + [ + "machineId", + " b08dfa60 " + ], + [ + "sysVendor", + "" + ], + [ + "productName", + " " + ], + [ + "boardVendor", + "\n\t" + ], + [ + "boardName", + "20HRCTO1WW" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=linux\nmachineId=b08dfa60\nboardName=20HRCTO1WW", + "digest": "c78ac9d4f2c926745b97393ae4ba520a8411757c39befb44750938b5d740c686", + "deviceId": "mbd2_c78ac9d4f2c926745b97393ae4ba520a8411757c39befb44750938b5d740c686" + }, + { + "description": "parameter order is significant (same pairs, swapped)", + "platform": "linux", + "source": "identity", + "params": [ + [ + "sysVendor", + "LENOVO" + ], + [ + "machineId", + "b08dfa60" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=linux\nsysVendor=LENOVO\nmachineId=b08dfa60", + "digest": "c837942b1105074051fefb82fbf7f340197e03da0b0844dad0835640ac04b580", + "deviceId": "mbd2_c837942b1105074051fefb82fbf7f340197e03da0b0844dad0835640ac04b580" + }, + { + "description": "control characters are dropped, so a value cannot forge an extra line", + "platform": "linux", + "source": "identity", + "params": [ + [ + "machineId", + "ABC\nsysVendor=GenuineIntel" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=linux\nmachineId=ABCsysVendor=GenuineIntel", + "digest": "e19d9f6ec29cd1070db07a72b53131224c37fcfb24762789cc27d15a23fa5296", + "deviceId": "mbd2_e19d9f6ec29cd1070db07a72b53131224c37fcfb24762789cc27d15a23fa5296" + }, + { + "description": "non-ASCII is dropped, making the firmware-string decoding choice immaterial", + "platform": "windows", + "source": "identity", + "params": [ + [ + "systemManufacturer", + "Ac\u00e9me \u4e2d\u6587" + ], + [ + "systemProductName", + "caf\u00e9" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=windows\nsystemManufacturer=Acme\nsystemProductName=caf\nsystemUuid=0123456789ABCDEF0123456789ABCDEF", + "digest": "c6d7b9d15db1d3a1f5bec22422c77e664e262d89bdc4d5c293d72c5611217398", + "deviceId": "mbd2_c6d7b9d15db1d3a1f5bec22422c77e664e262d89bdc4d5c293d72c5611217398" + }, + { + "description": "NFC runs before the ASCII filter, so a decomposed e-acute leaves no bare \"e\"", + "platform": "windows", + "source": "identity", + "params": [ + [ + "systemManufacturer", + "Cafe\u0301 Corp" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=windows\nsystemManufacturer=Caf Corp\nsystemUuid=0123456789ABCDEF0123456789ABCDEF", + "digest": "3e8ce76bfc7b49bffec638c0438a384cdb27e1df1dee624ec7964f4c299a8233", + "deviceId": "mbd2_3e8ce76bfc7b49bffec638c0438a384cdb27e1df1dee624ec7964f4c299a8233" + }, + { + "description": "values are capped at 128 characters", + "platform": "linux", + "source": "identity", + "params": [ + [ + "machineId", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=linux\nmachineId=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "digest": "fe2f0b99e6def565c30601301a82634556f16f0f5ce034b53b9176ed32ebd914", + "deviceId": "mbd2_fe2f0b99e6def565c30601301a82634556f16f0f5ce034b53b9176ed32ebd914" + }, + { + "description": "an unprogrammed serial is dropped, but a real system UUID still identifies the machine", + "platform": "windows", + "source": "identity", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ], + [ + "baseboardSerialNumber", + "Default string" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=windows\nsystemManufacturer=ACME\nsystemProductName=Server 9000\nsystemUuid=0123456789ABCDEF0123456789ABCDEF", + "digest": "15b3d0f8e8e82500790653fbfc087ea0baa45c909afd6f246456d43893f4aee4", + "deviceId": "mbd2_15b3d0f8e8e82500790653fbfc087ea0baa45c909afd6f246456d43893f4aee4" + }, + { + "description": "filler detection applies to identifying params only \u2014 a model may genuinely be named \"None\"", + "platform": "linux", + "source": "identity", + "params": [ + [ + "machineId", + "b08dfa60" + ], + [ + "productName", + "None" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=linux\nmachineId=b08dfa60\nproductName=None", + "digest": "063d7bf0605a0da040b0046007a4993eaf41b557e414bea99105800560f6efdc", + "deviceId": "mbd2_063d7bf0605a0da040b0046007a4993eaf41b557e414bea99105800560f6efdc" + }, + { + "description": "iOS offers only a vendor-scoped identifier, so the id is stamped mbd2s_", + "platform": "ios", + "source": "scoped", + "params": [ + [ + "identifierForVendor", + "0123456789ABCDEF0123456789ABCDEF" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=ios\nidentifierForVendor=0123456789ABCDEF0123456789ABCDEF", + "digest": "298ced47f8d983939db1d5fce6d4b4f2f8766aa19e3e17536fcd1604a81febf1", + "deviceId": "mbd2s_298ced47f8d983939db1d5fce6d4b4f2f8766aa19e3e17536fcd1604a81febf1" + }, + { + "description": "Android ANDROID_ID from Settings.Secure.getString, lowercased and scoped to the signing key", + "platform": "android", + "source": "scoped", + "params": [ + [ + "androidId", + "a1b2c3d4e5f60718\n" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=android\nandroidId=a1b2c3d4e5f60718", + "digest": "ca988ecf5c529964bfaa80734da3dbe070dd41881aa43f8c472f3a5d512b4eff", + "deviceId": "mbd2s_ca988ecf5c529964bfaa80734da3dbe070dd41881aa43f8c472f3a5d512b4eff" + }, + { + "description": "the deviceName fallback material (stamped mbd2n_, see stamps)", + "platform": "unknown", + "source": "deviceName", + "params": [ + [ + "deviceName", + "PC-1" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=unknown\ndeviceName=PC-1", + "digest": "493978eb157552e60a13694bd6861b2a82d0dba2746431a5f7921951ed460045", + "deviceId": "mbd2n_493978eb157552e60a13694bd6861b2a82d0dba2746431a5f7921951ed460045" + }, + { + "description": "a rejected androidId constant is still a valid baseboard serial: per-parameter rejections must not leak across platforms", + "platform": "windows", + "source": "identity", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "baseboardSerialNumber", + "9774d56d682e549c" + ] + ], + "material": "moonbase:fingerprint:v2\nplatform=windows\nsystemManufacturer=ACME\nbaseboardSerialNumber=9774d56d682e549c", + "digest": "cdd01d6f586662f25e5aaa542ccaf373eea6cfb1271a2c94efd5ab828611b477", + "deviceId": "mbd2_cdd01d6f586662f25e5aaa542ccaf373eea6cfb1271a2c94efd5ab828611b477" + } + ], + "materialErrors": [ + { + "description": "no parameters at all must be an error, never a digest \u2014 otherwise every unidentifiable machine on a platform shares one device id", + "platform": "linux", + "params": [], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "all parameters canonicalize to empty, non-ASCII whitespace included", + "platform": "mac", + "params": [ + [ + "ioPlatformUuid", + " \t\r\n" + ], + [ + "systemUuid", + "\u00a0" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "duplicate parameter names are unrepresentable in the material grammar", + "platform": "windows", + "params": [ + [ + "systemUuid", + "AAAA" + ], + [ + "systemUuid", + "BBBB" + ] + ], + "error": "DuplicateParameter" + }, + { + "description": "Linux with no machine-id has only model-level fields left, which every unit of this model shares", + "platform": "linux", + "params": [ + [ + "machineId", + "" + ], + [ + "sysVendor", + "LENOVO" + ], + [ + "productName", + "20HRCTO1WW" + ], + [ + "boardVendor", + "LENOVO" + ], + [ + "boardName", + "20HRCTO1WW" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "an uninitialized all-zero machine-id is not identity either", + "platform": "linux", + "params": [ + [ + "machineId", + "00000000000000000000000000000000" + ], + [ + "sysVendor", + "LENOVO" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "the literal \"uninitialized\" marker from machine-id(5) is shared by every machine deployed from an image", + "platform": "linux", + "params": [ + [ + "machineId", + "uninitialized" + ], + [ + "sysVendor", + "QEMU" + ], + [ + "productName", + "Standard PC" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "Windows with an unset system UUID and a blank baseboard serial identifies only the model \u2014 the common case on cloned VMs", + "platform": "windows", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "" + ], + [ + "baseboardManufacturer", + "ACME" + ], + [ + "baseboardProduct", + "MB-1" + ], + [ + "baseboardSerialNumber", + "" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "macOS with no IOPlatformUUID has nothing else to fall back on", + "platform": "mac", + "params": [ + [ + "ioPlatformUuid", + "" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "an OEM filler serial is not a serial: every unit of the model ships it", + "platform": "windows", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "baseboardSerialNumber", + "To be filled by O.E.M." + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "filler detection is case-insensitive", + "platform": "windows", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "baseboardSerialNumber", + "DEFAULT STRING" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "Not Available is a serial-number filler, not a serial number", + "platform": "windows", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "baseboardSerialNumber", + "Not Available" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "the OEM filler is also written without dots", + "platform": "windows", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "baseboardSerialNumber", + "To Be Filled By OEM" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "an all-F system UUID that reached the material as text is still unset", + "platform": "windows", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemUuid", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "iOS with no identifierForVendor yet has nothing else to offer", + "platform": "ios", + "params": [ + [ + "identifierForVendor", + "" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "Android with no androidId has nothing else to offer", + "platform": "android", + "params": [ + [ + "androidId", + "" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "the literal \"android_id\" is the key name, not an id: reading the static field instead of calling getString gives every device on the platform the same value", + "platform": "android", + "params": [ + [ + "androidId", + "android_id" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "Build.SERIAL returns \"unknown\" without a privileged permission, which is a fleet-wide constant", + "platform": "android", + "params": [ + [ + "androidId", + "unknown" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "9774d56d682e549c is a real ANDROID_ID shared by a large batch of devices whose ro.serialno was unset", + "platform": "android", + "params": [ + [ + "androidId", + "9774d56d682e549c" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "an androidId that is not lowercase hex did not come from Settings.Secure", + "platform": "android", + "params": [ + [ + "androidId", + "A1B2C3D4E5F60718" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "the host-name fallback is forbidden on iOS, where gethostname() returns \"localhost\" on every device", + "platform": "ios", + "params": [ + [ + "deviceName", + "iPhone" + ] + ], + "error": "InsufficientDeviceIdentity" + }, + { + "description": "the host-name fallback is forbidden on Android, where the host name is a model name or an image-wide constant", + "platform": "android", + "params": [ + [ + "deviceName", + "localhost" + ] + ], + "error": "InsufficientDeviceIdentity" + } + ], + "smbios": [ + { + "description": "System + Baseboard, the ordinary case", + "table": "011B0100010200000123456789ABCDEF0123456789ABCDEF06000041434D450053657276657220393030300000020F02000102030400090000000A0041434D45004D422D3100312E300042534E2D343200007F04FEFF0000", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ], + [ + "baseboardManufacturer", + "ACME" + ], + [ + "baseboardProduct", + "MB-1" + ], + [ + "baseboardSerialNumber", + "BSN-42" + ] + ] + }, + { + "description": "Processor structures contribute nothing \u2014 their count tracks vCPUs, so collecting them would change the id when a VM is resized", + "table": "011B0100010200000123456789ABCDEF0123456789ABCDEF06000041434D450053657276657220393030300000041A04000103C601020000000000000003000000000000000000496E74656C0058656F6E0053523342300000041A04000103C601020000000000000003000000000000000000496E74656C0058656F6E00535233423000007F04FEFF0000", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ] + ] + }, + { + "description": "only the first structure of each type contributes", + "table": "011B0100010200000123456789ABCDEF0123456789ABCDEF06000041434D450053657276657220393030300000011B0500010200000123456789ABCDEF0123456789ABCDEF0600004F5448455200536572766572203100007F04FEFF0000", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ] + ] + }, + { + "description": "string index 0 means absent; an index past the end of the string table is absent too", + "table": "011B0100000900000123456789ABCDEF0123456789ABCDEF06000041434D4500007F04FEFF0000", + "params": [ + [ + "systemManufacturer", + "" + ], + [ + "systemProductName", + "" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ] + ] + }, + { + "description": "an all-FF system UUID means \"not set\" and must not become identity", + "table": "011B010001020000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF06000041434D4500536572766572203930303000007F04FEFF0000", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "" + ] + ] + }, + { + "description": "an all-zero system UUID means \"not set\" too", + "table": "011B0100010200000000000000000000000000000000000006000041434D4500536572766572203930303000007F04FEFF0000", + "params": [ + [ + "systemManufacturer", + "ACME" + ], + [ + "systemProductName", + "Server 9000" + ], + [ + "systemUuid", + "" + ] + ] + }, + { + "description": "a structure with no strings is just the double-NUL", + "table": "011B0100000000000123456789ABCDEF0123456789ABCDEF0600000000020F02000100000200090000000A0041434D450042534E2D343200007F04FEFF0000", + "params": [ + [ + "systemManufacturer", + "" + ], + [ + "systemProductName", + "" + ], + [ + "systemUuid", + "0123456789ABCDEF0123456789ABCDEF" + ], + [ + "baseboardManufacturer", + "ACME" + ], + [ + "baseboardProduct", + "" + ], + [ + "baseboardSerialNumber", + "BSN-42" + ] + ] + }, + { + "description": "an OEM filler serial is reported verbatim \u2014 the parser describes the firmware, canonicalization decides what counts", + "table": "020F02000102030400090000000A0041434D45004D422D3100312E3000546F2062652066696C6C6564206279204F2E452E4D2E00007F04FEFF0000", + "params": [ + [ + "baseboardManufacturer", + "ACME" + ], + [ + "baseboardProduct", + "MB-1" + ], + [ + "baseboardSerialNumber", + "To be filled by O.E.M." + ] + ] + }, + { + "description": "an empty table yields no parameters", + "table": "", + "params": [] + } + ], + "stamps": [ + { + "description": "the current version, hardware identity", + "deviceId": "mbd2_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": { + "version": 2, + "sourceTag": "", + "source": "identity", + "digest": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32" + } + }, + { + "description": "the current version, host-name fallback", + "deviceId": "mbd2n_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": { + "version": 2, + "sourceTag": "n", + "source": "deviceName", + "digest": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32" + } + }, + { + "description": "another version parses, so a validator can report \"re-activate\" rather than \"wrong device\"", + "deviceId": "mbd7_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": { + "version": 7, + "sourceTag": "", + "source": "identity", + "digest": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32" + } + }, + { + "description": "a bare digest is not a stamp", + "deviceId": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": null + }, + { + "description": "a legacy base64 id is not a stamp", + "deviceId": "kR8kWLPFR0j0LNYlPTQRhF6JQnPqmA0GjXf3TrGDvXA", + "parsed": null + }, + { + "description": "uppercase hex is not a stamp", + "deviceId": "mbd2_B465194056FF7721BF549799B4532BFCA0BC72FFFC0F6969C77C46D6B8E28E32", + "parsed": null + }, + { + "description": "a truncated digest is not a stamp", + "deviceId": "mbd2_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e3", + "parsed": null + }, + { + "description": "the current version, app-scoped identity", + "deviceId": "mbd2s_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": { + "version": 2, + "sourceTag": "s", + "source": "scoped", + "digest": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32" + } + }, + { + "description": "an unrecognised source tag still parses, so the id is compared literally rather than rejected as \"not a Moonbase id\"", + "deviceId": "mbd2x_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": { + "version": 2, + "sourceTag": "x", + "source": null, + "digest": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32" + } + }, + { + "description": "a multi-character tag parses too, so a future tag needs no version bump", + "deviceId": "mbd2zz_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": { + "version": 2, + "sourceTag": "zz", + "source": null, + "digest": "b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32" + } + }, + { + "description": "an uppercase source tag is not a stamp", + "deviceId": "mbd2S_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": null + }, + { + "description": "a tag with a digit in it is not a stamp", + "deviceId": "mbd2n2_b465194056ff7721bf549799b4532bfca0bc72fffc0f6969c77c46d6b8e28e32", + "parsed": null + } + ] +} diff --git a/tests/visual/snapshot_main.cpp b/tests/visual/snapshot_main.cpp index 47337ba..7036cdd 100644 --- a/tests/visual/snapshot_main.cpp +++ b/tests/visual/snapshot_main.cpp @@ -179,7 +179,10 @@ int main(int argc, char* argv[]) moonbase::licensing licensing( config.toLicensingOptions(), std::make_shared(), - std::make_shared(), + // A fixed identity rather than the runner's: this check exercises the + // HTTP transport only, and reading real hardware would make it fail on + // any machine with no readable device identity. + std::make_shared("snapshot-host", "snapshot-device-id"), std::make_shared()); try {