From a38bf25d31c75c01c69294c6ce9de8adf5c24496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Sun, 2 Aug 2026 21:56:01 +0200 Subject: [PATCH 1/3] feat!: adopt the Moonbase device fingerprint spec v2 for device ids Replaces both device id algorithms this SDK shipped, the unstamped `moonbase-cpp:fingerprint:v1` hash and the JUCE module's juce::SystemStats::getUniqueDeviceID(), with the cross-SDK Moonbase device fingerprint spec. A license activated by @moonbase.sh/licensing now validates here and vice versa, verified byte-for-byte on real hardware rather than only against vectors. Ships migrating_device_id_resolver plus frozen historical resolvers so existing fleets are not locked out, a scoped identity (mbd2s_) for iOS and Android where the platforms provide no identifier unrelated apps can read, and a cross-SDK parity workflow that runs both SDKs on the same runner across ubuntu/macos/windows. Also fixes a latent defect found on the way: the old Windows SMBIOS read built the GetSystemFirmwareTable provider signature byte-reversed, applying MSDN's byte-reversal note for FirmwareTableID to the provider signature. The call returned 0 on every machine, so every Windows device id silently degraded to a hash of the computer name. The legacy resolver preserves that behaviour, because existing Windows bindings depend on it. BREAKING CHANGE: The default device id now follows the cross-SDK Moonbase device fingerprint spec (`moonbase:fingerprint:v2`, vendored as FINGERPRINT_SPEC.md and pinned by tests/vectors/fingerprint-vectors.json). Existing device-bound licenses must be re-activated, which consumes an activation seat and resets any device-scoped trial. To avoid that, wrap the default in moonbase::migrating_device_id_resolver together with moonbase::legacy_cpp_device_id_resolver and/or moonbase::juce_integration::legacy_juce_device_id_resolver; see "Migrating from 3.x" in README.md. moonbase::fingerprint_provider is renamed to moonbase::device_id_resolver and static_fingerprint_provider to static_device_id_resolver, with deprecated aliases kept until 5.0.0 (define MOONBASE_DISABLE_DEPRECATED_ALIASES to compile them out). default_fingerprint_provider now resolves to the spec resolver; the previous algorithm is preserved verbatim as moonbase::legacy_cpp_device_id_resolver. licensing::fingerprint() is deprecated in favour of licensing::device_resolver(). A device mismatch now throws moonbase::license_device_mismatch_error (error_type::license_device_mismatch), which derives from license_invalid_error so existing catch sites and the offline grace period still behave, but code switching on error_type::license_invalid must add the case. A machine with no readable hardware identity now throws moonbase::insufficient_device_identity_error (error_type::device_identity_unavailable) instead of silently hashing the host name; opt back in with moonbase_device_id_resolver_options::fallback, or ActivationConfig::allowDeviceNameFallback, neither of which applies on iOS or Android where the host name is the same on every device. --- .github/scripts/check-fingerprint-parity.mjs | 133 +++ .github/workflows/ci.yml | 30 + .github/workflows/fingerprint-parity.yml | 160 +++ .gitignore | 8 + .releaserc.json | 7 +- CMakeLists.txt | 41 + FINGERPRINT_SPEC.md | 655 ++++++++++++ README.md | 212 +++- docs/juce-module.md | 98 +- docs/juce.md | 80 +- examples/device_id.cpp | 52 + examples/juce/MoonbaseJuceBridge.h | 191 +++- include/moonbase/client.hpp | 14 +- include/moonbase/default_fingerprint.hpp | 383 +------ include/moonbase/detail/unicode/nfc_ascii.hpp | 294 ++++++ .../moonbase/detail/unicode/nfc_tables.hpp | 141 +++ include/moonbase/device_id_resolver.hpp | 212 ++++ include/moonbase/errors.hpp | 86 ++ include/moonbase/fingerprint.hpp | 46 +- include/moonbase/fingerprint_spec.hpp | 856 ++++++++++++++++ include/moonbase/legacy_fingerprint.hpp | 395 ++++++++ include/moonbase/licensing.hpp | 55 +- include/moonbase/moonbase.hpp | 8 + .../moonbase/moonbase_device_id_resolver.hpp | 353 +++++++ include/moonbase/validator.hpp | 145 ++- modules/moonbase_licensing/README.md | 52 +- .../juce/ActivationConfig.h | 127 ++- .../juce/ActivationController.cpp | 75 +- .../juce/ActivationController.h | 10 + .../juce/android_device_id_resolver.h | 140 +++ .../juce/ios_device_id_resolver.h | 79 ++ .../juce/juce_fingerprint_provider.h | 31 - .../juce/legacy_juce_device_id_resolver.h | 58 ++ .../moonbase_licensing/moonbase/client.hpp | 14 +- .../moonbase/default_fingerprint.hpp | 383 +------ .../moonbase/detail/unicode/nfc_ascii.hpp | 294 ++++++ .../moonbase/detail/unicode/nfc_tables.hpp | 141 +++ .../moonbase/device_id_resolver.hpp | 212 ++++ .../moonbase_licensing/moonbase/errors.hpp | 86 ++ .../moonbase/fingerprint.hpp | 46 +- .../moonbase/fingerprint_spec.hpp | 856 ++++++++++++++++ .../moonbase/legacy_fingerprint.hpp | 395 ++++++++ .../moonbase_licensing/moonbase/licensing.hpp | 55 +- .../moonbase_licensing/moonbase/moonbase.hpp | 8 + .../moonbase/moonbase_device_id_resolver.hpp | 353 +++++++ .../moonbase_licensing/moonbase/validator.hpp | 145 ++- .../moonbase_licensing/moonbase_licensing.h | 15 +- scripts/bump-version.sh | 31 + scripts/gen-nfc-tables.py | 540 ++++++++++ scripts/sync-fingerprint-vectors.sh | 163 +++ tests/client_tests.cpp | 6 +- tests/device_id_resolver_tests.cpp | 291 ++++++ tests/fingerprint_reader_tests.cpp | 443 +++++++++ tests/fingerprint_spec_tests.cpp | 482 +++++++++ tests/fingerprint_tests.cpp | 45 +- tests/juce/controller_tests.cpp | 168 +++- tests/licensing_tests.cpp | 14 +- tests/live_tests.cpp | 2 +- tests/process_dedup_tests.cpp | 6 +- tests/validator_tests.cpp | 4 +- tests/vectors/fingerprint-vectors.json | 938 ++++++++++++++++++ tests/visual/snapshot_main.cpp | 5 +- 62 files changed, 10421 insertions(+), 947 deletions(-) create mode 100644 .github/scripts/check-fingerprint-parity.mjs create mode 100644 .github/workflows/fingerprint-parity.yml create mode 100644 FINGERPRINT_SPEC.md create mode 100644 examples/device_id.cpp create mode 100644 include/moonbase/detail/unicode/nfc_ascii.hpp create mode 100644 include/moonbase/detail/unicode/nfc_tables.hpp create mode 100644 include/moonbase/device_id_resolver.hpp create mode 100644 include/moonbase/fingerprint_spec.hpp create mode 100644 include/moonbase/legacy_fingerprint.hpp create mode 100644 include/moonbase/moonbase_device_id_resolver.hpp create mode 100644 modules/moonbase_licensing/juce/android_device_id_resolver.h create mode 100644 modules/moonbase_licensing/juce/ios_device_id_resolver.h delete mode 100644 modules/moonbase_licensing/juce/juce_fingerprint_provider.h create mode 100644 modules/moonbase_licensing/juce/legacy_juce_device_id_resolver.h create mode 100644 modules/moonbase_licensing/moonbase/detail/unicode/nfc_ascii.hpp create mode 100644 modules/moonbase_licensing/moonbase/detail/unicode/nfc_tables.hpp create mode 100644 modules/moonbase_licensing/moonbase/device_id_resolver.hpp create mode 100644 modules/moonbase_licensing/moonbase/fingerprint_spec.hpp create mode 100644 modules/moonbase_licensing/moonbase/legacy_fingerprint.hpp create mode 100644 modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp create mode 100755 scripts/gen-nfc-tables.py create mode 100755 scripts/sync-fingerprint-vectors.sh create mode 100644 tests/device_id_resolver_tests.cpp create mode 100644 tests/fingerprint_reader_tests.cpp create mode 100644 tests/fingerprint_spec_tests.cpp create mode 100644 tests/vectors/fingerprint-vectors.json 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..5d38eb0 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -163,12 +163,100 @@ 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. + +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..0826bf4 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -14,11 +14,19 @@ 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 five pieces under the `moonbase::juce_bridge` namespace: -- **`MoonbaseJuceFingerprintProvider`** — implements `moonbase::fingerprint_provider` - on top of `juce::SystemStats::getUniqueDeviceID()` (JUCE 7+). +- **`makeDefaultDeviceIdResolver()`** — the resolver you get when you pass none. + Cross-SDK hardware identity on desktop (including Mac Catalyst), and a scoped + identity on iOS and Android, where the plain spec resolver has nothing to read + and would throw. Wrap *this* when migrating, not + `moonbase_device_id_resolver` directly. +- **`MoonbaseJuceScopedDeviceIdResolver`** — the mobile half of that, building a + `mbd2s_` scoped id from `identifierForVendor` or `ANDROID_ID`. +- **`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 +286,61 @@ 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( + // 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 entirely. + makeDefaultDeviceIdResolver(), // 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 +380,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..bd2e22a 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -55,12 +55,29 @@ 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( +// makeDefaultDeviceIdResolver(), // NOT moonbase_device_id_resolver: +// // that throws on iOS and Android +// 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 @@ -74,6 +91,128 @@ class MoonbaseJuceFingerprintProvider : public moonbase::fingerprint_provider } }; +// Scoped device identity for iOS and Android, per the spec's "Scoped identity" +// section. Neither platform exposes a device identifier that unrelated +// applications can read, so the id is stable only within the platform's own scope +// and is stamped `mbd2s_` to say so. +// +// This duplicates modules/moonbase_licensing/juce/{ios,android}_device_id_resolver.h +// on purpose: the bridge is copy-paste reference code and must not depend on the +// JUCE module. Keep the two in step if you change either. +class MoonbaseJuceScopedDeviceIdResolver : public moonbase::device_id_resolver +{ +public: + [[nodiscard]] std::string device_name() const override + { + // Decoration only. It never enters the material, because + // build_fingerprint_material refuses the host-name fallback on these + // platforms, where the name is "localhost" or a model name. + return juce::SystemStats::getDeviceDescription().toStdString(); + } + + [[nodiscard]] std::string device_id() const override { return describe().device_id; } + + [[nodiscard]] std::optional describe_device() const override + { + return describe(); + } + +private: + /// The platform's scoped identifier, or empty when it is momentarily absent. + [[nodiscard]] static moonbase::fingerprint_spec::parameter readScopedIdentity() + { + #if JUCE_IOS + // juce::SystemStats wraps [[UIDevice currentDevice] identifierForVendor] + // on iOS. Normalized like ioPlatformUuid: hyphens stripped, uppercased. + return {"identifierForVendor", + moonbase::fingerprint_spec::normalize_platform_uuid( + juce::SystemStats::getUniqueDeviceID().toStdString())}; + #elif JUCE_ANDROID + // Deliberately NOT juce::SystemStats::getUniqueDeviceID(): it 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 would reject that + // anyway, but reading it correctly is the point. + return {"androidId", readAndroidId()}; + #else + return {"androidId", {}}; + #endif + } + + #if JUCE_ANDROID + [[nodiscard]] static std::string readAndroidId() + { + auto* env = juce::getEnv(); + if (env == nullptr) + return {}; + + juce::LocalRef secure((jclass) env->FindClass("android/provider/Settings$Secure")); + if (secure.get() == nullptr) { env->ExceptionClear(); return {}; } + + const auto getString = env->GetStaticMethodID( + secure.get(), "getString", + "(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;"); + if (getString == nullptr) { env->ExceptionClear(); return {}; } + + auto context = juce::getAppContext(); + if (context.get() == nullptr) + return {}; + + juce::LocalRef resolver(env->CallObjectMethod( + context.get(), + env->GetMethodID(env->GetObjectClass(context.get()), + "getContentResolver", "()Landroid/content/ContentResolver;"))); + if (resolver.get() == nullptr) { env->ExceptionClear(); return {}; } + + juce::LocalRef key(env->NewStringUTF("android_id")); + juce::LocalRef value((jstring) env->CallStaticObjectMethod( + secure.get(), getString, resolver.get(), key.get())); + + if (env->ExceptionCheck()) { env->ExceptionClear(); return {}; } + if (value.get() == nullptr) + return {}; + + return juce::juceString(env, value.get()).toLowerCase().toStdString(); + } + #endif + + [[nodiscard]] moonbase::device_id_description describe() const + { + namespace fp = moonbase::fingerprint_spec; + + const fp::parameter_list params{readScopedIdentity()}; + const auto platform = std::string(fp::platform_tag()); + + moonbase::device_id_description described; + described.device_id = fp::fingerprint_device_id( + fp::build_fingerprint_material(platform, params), fp::device_id_source::scoped); + described.version = fp::version; + described.platform = platform; + described.source = fp::device_id_source::scoped; + for (const auto& param : fp::canonicalize_params(params)) + described.param_names.push_back(param.first); + return described; + } +}; + +// The resolver this platform gets when you do not pass one. +// +// Desktop, including Mac Catalyst, uses the cross-SDK hardware fingerprint. iOS +// and Android have no identifier unrelated apps can read, so they get a scoped id +// instead; the plain spec resolver would simply throw there. +// +// Wrap *this* rather than a hard-coded moonbase_device_id_resolver when migrating, +// or mobile users are locked out: a migrating resolver asks its current resolver +// for an id before consulting any historical one. +[[nodiscard]] inline std::shared_ptr makeDefaultDeviceIdResolver() +{ + #if JUCE_IOS || JUCE_ANDROID + return std::make_shared(); + #else + return std::make_shared(); + #endif +} + // --------------------------------------------------------------------------- // Metadata helper // --------------------------------------------------------------------------- @@ -185,13 +324,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 + = makeDefaultDeviceIdResolver(), 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 +971,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 +1008,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 +1092,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 +1103,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 +1112,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 +1151,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..0a39643 --- /dev/null +++ b/include/moonbase/device_id_resolver.hpp @@ -0,0 +1,212 @@ +#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; + } + + std::call_once(previous_ids_once_, [this] { + 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. + } + } + }); + + 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::once_flag previous_ids_once_; + 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..6432793 --- /dev/null +++ b/include/moonbase/moonbase_device_id_resolver.hpp @@ -0,0 +1,353 @@ +#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__) && !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 { + +/// 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__) + // No identity parameters are defined for Android. +#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: + [[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. + std::call_once(identity_once_, [this] { + 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{}; + } + }); + return identity_; + } + + [[nodiscard]] const device_id_description& description() const + { + // call_once treats an exceptional return as "not done", so a machine that + // is momentarily unreadable retries on the next call instead of caching + // the failure. That is deliberate: a sticky failure would outlive the + // condition that caused it. + std::call_once(description_once_, [this] { + 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); + } + }); + 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(_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::once_flag identity_once_; + mutable device_identity identity_; + mutable std::once_flag description_once_; + 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..494bab7 100644 --- a/modules/moonbase_licensing/README.md +++ b/modules/moonbase_licensing/README.md @@ -17,6 +17,17 @@ 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; 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 +47,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..a3822e8 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -11,12 +11,21 @@ #include #include +#if defined(__APPLE__) + #include // TARGET_OS_IPHONE, for the iOS resolver default +#endif + #include #include #include #include "JuceMetadata.h" +// Both are named by resolvedDeviceIdResolver(). Included here rather than relied +// on from the module umbrella so this header stays usable on its own. +#include "android_device_id_resolver.h" +#include "ios_device_id_resolver.h" +#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 +112,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 +295,96 @@ 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. + /// + /// Static and public because a migrating configuration must wrap *this*, not a + /// hard-coded `moonbase_device_id_resolver`. On iOS and Android the spec + /// resolver has no identity parameters to read and throws, and a migrating + /// wrapper asks its current resolver for an id before consulting any historical + /// one, so hard-coding it there locks mobile users out of both validation and + /// new activation: + /// + /// \code + /// config.deviceIdResolver = std::make_shared( + /// ActivationConfig::defaultDeviceIdResolver(), + /// std::make_shared()); + /// \endcode + [[nodiscard]] static std::shared_ptr defaultDeviceIdResolver( + bool allowHostNameFallback = false) + { + // A constexpr flag with an ordinary `if`, rather than an #if around the + // returns: in a non-template function both branches are still compiled, so + // the mobile paths cannot rot unnoticed on a desktop-only CI matrix. + if (isAndroid) + { + // Android's only device identifier is scoped to the app signing key, so + // the id is scoped and stamped mbd2s_. Deliberately not + // juce::SystemStats::getUniqueDeviceID(): it hashes the string constant + // "android_id" rather than the device's value, so every JUCE Android app + // reports the same id. See android_device_id_resolver.h. + // + // allowHostNameFallback is ignored, as it is on iOS: the label is + // Build.MODEL on most devices, so hashing it would give a whole product + // line one id. build_fingerprint_material refuses it outright. + return std::make_shared(); + } + + if (isMobileApple) + { + // iOS gets a *scoped* spec identity, stamped mbd2s_. + // + // iOS exposes no device identifier that unrelated applications can + // read: no hardware serial since iOS 7, and identifierForVendor is + // scoped to the App Store vendor. Cross-SDK parity is therefore + // impossible by construction, and the stamp says so rather than + // pretending otherwise. + // + // Note this excludes Mac Catalyst, which runs on macOS, can read IOKit, + // and so takes the cross-SDK hardware identity below. + return std::make_shared(); + } + + moonbase::moonbase_device_id_resolver_options options; + 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..6c2016c 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,7 @@ ActivationController::ActivationController(ActivationConfig config) auto store = std::make_shared( std::filesystem::path(file.getFullPathName().toStdString())); - auto fingerprint = std::make_shared(); + 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 +62,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 +80,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 +111,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 +202,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/android_device_id_resolver.h b/modules/moonbase_licensing/juce/android_device_id_resolver.h new file mode 100644 index 0000000..15090e6 --- /dev/null +++ b/modules/moonbase_licensing/juce/android_device_id_resolver.h @@ -0,0 +1,140 @@ +#pragma once + +// Scoped device identity for Android, per the fingerprint spec's "Scoped +// identity" section. +// +// Android exposes no device identifier that unrelated applications can read: +// Build.SERIAL returns "unknown" without a privileged permission from Android 10, +// IMEI needs READ_PRIVILEGED_PHONE_STATE, MAC addresses are randomised, and +// Settings.Secure.ANDROID_ID has been scoped to the app signing key since +// Android 8. So the id is scoped and stamped `mbd2s_`. +// +// DO NOT reach for juce::SystemStats::getUniqueDeviceID() here. It reads the +// *static field* Settings.Secure.ANDROID_ID via GetStaticObjectField, which is the +// key name "android_id" rather than the device's value, and hashes that. Every +// JUCE Android app therefore reports the same id, so one activation would unlock +// the entire install base. Its jassert that the result is non-empty never fires, +// because the hash of a constant is not empty, so the defect is silent. It is +// still present in JUCE 9.0.0. +// +// This resolver calls Settings.Secure.getString(contentResolver, "android_id") +// through JNI instead, and the spec's ^[0-9a-f]{1,16}$ rule then makes the mistake +// mechanically impossible: "android_id" is not hex, so it can never reach the +// material even if this code regressed. + +#include +#include +#include + +#include +#include + +#include + +namespace moonbase::juce_integration { + +class android_device_id_resolver : public moonbase::device_id_resolver +{ +public: + /// Settings.Secure.getString(contentResolver, ANDROID_ID), lowercased. + /// + /// Empty when Android declines to provide one: the value is generated lazily + /// and getString can return null. Absence is transient and must surface as + /// insufficient identity rather than as a constant. + [[nodiscard]] static std::string readAndroidId() + { +#if JUCE_ANDROID + auto* env = juce::getEnv(); + if (env == nullptr) + return {}; + + // Settings.Secure.getString(ContentResolver, String). The second argument + // is the literal key, NOT the static field: reading the field yields the + // key name itself, which is what the JUCE defect above does. + juce::LocalRef secure( + (jclass) env->FindClass("android/provider/Settings$Secure")); + if (secure.get() == nullptr) + { + env->ExceptionClear(); + return {}; + } + + const auto getString = env->GetStaticMethodID( + secure.get(), + "getString", + "(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;"); + if (getString == nullptr) + { + env->ExceptionClear(); + return {}; + } + + auto context = juce::getAppContext(); + if (context.get() == nullptr) + return {}; + + juce::LocalRef resolver(env->CallObjectMethod( + context.get(), + env->GetMethodID( + env->GetObjectClass(context.get()), + "getContentResolver", + "()Landroid/content/ContentResolver;"))); + if (resolver.get() == nullptr) + { + env->ExceptionClear(); + return {}; + } + + juce::LocalRef key(env->NewStringUTF("android_id")); + juce::LocalRef value((jstring) env->CallStaticObjectMethod( + secure.get(), getString, resolver.get(), key.get())); + + if (env->ExceptionCheck()) + { + env->ExceptionClear(); + return {}; + } + if (value.get() == nullptr) + return {}; + + return juce::juceString(env, value.get()).toLowerCase().toStdString(); +#else + return {}; +#endif + } + + [[nodiscard]] std::string device_name() const override + { + // Decoration only: it never enters the material, because the host-name + // fallback is forbidden on Android. Build.MODEL on most devices. + return juce::SystemStats::getDeviceDescription().toStdString(); + } + + [[nodiscard]] std::string device_id() const override { return describe().device_id; } + + [[nodiscard]] std::optional describe_device() const override + { + return describe(); + } + +private: + [[nodiscard]] moonbase::device_id_description describe() const + { + namespace fp = moonbase::fingerprint_spec; + + const fp::parameter_list params{{"androidId", readAndroidId()}}; + const auto platform = std::string(fp::platform_tag()); + + moonbase::device_id_description described; + described.device_id = fp::fingerprint_device_id( + fp::build_fingerprint_material(platform, params), fp::device_id_source::scoped); + described.version = fp::version; + described.platform = platform; + described.source = fp::device_id_source::scoped; + for (const auto& param : fp::canonicalize_params(params)) + described.param_names.push_back(param.first); + return described; + } +}; + +} // namespace moonbase::juce_integration diff --git a/modules/moonbase_licensing/juce/ios_device_id_resolver.h b/modules/moonbase_licensing/juce/ios_device_id_resolver.h new file mode 100644 index 0000000..98e6148 --- /dev/null +++ b/modules/moonbase_licensing/juce/ios_device_id_resolver.h @@ -0,0 +1,79 @@ +#pragma once + +// Scoped device identity for iOS, per the fingerprint spec's "Scoped identity" +// section. +// +// iOS provides no device identifier that unrelated applications can read: the +// hardware serial has been inaccessible since iOS 7, and identifierForVendor is +// scoped to the app's vendor prefix, so two publishers see different values on +// the same device. The spec's answer is not to pretend otherwise but to stamp the +// limitation into the id, as `mbd2s_`, so the server, support and any validator +// know the value is stable within this publisher and meaningless outside it. +// +// This is deliberately NOT the spec's host-name fallback. iOS device names are +// routinely the literal "iPhone", so hashing one would hand an enormous +// population a single device id and let them validate each other's licenses, +// which is exactly the failure the insufficient-identity rule exists to prevent. +// +// identifierForVendor is nil until the device is first unlocked after boot, and +// resets if the user removes every app from this vendor. Both cases surface as +// insufficient_device_identity_error rather than as a constant. + +#include +#include +#include + +#include +#include + +#include + +namespace moonbase::juce_integration { + +class ios_device_id_resolver : public moonbase::device_id_resolver +{ +public: + /// The vendor identifier, uppercased with hyphens removed, matching how the + /// spec treats ioPlatformUuid. Empty when iOS declines to provide one. + /// + /// Sourced through juce::SystemStats, which wraps + /// [[UIDevice currentDevice] identifierForVendor] on iOS. + [[nodiscard]] static std::string readIdentifierForVendor() + { + return moonbase::fingerprint_spec::normalize_platform_uuid( + juce::SystemStats::getUniqueDeviceID().toStdString()); + } + + [[nodiscard]] std::string device_name() const override + { + return juce::SystemStats::getComputerName().toStdString(); + } + + [[nodiscard]] std::string device_id() const override { return describe().device_id; } + + [[nodiscard]] std::optional describe_device() const override + { + return describe(); + } + +private: + [[nodiscard]] moonbase::device_id_description describe() const + { + namespace fp = moonbase::fingerprint_spec; + + const fp::parameter_list params{{"identifierForVendor", readIdentifierForVendor()}}; + const auto platform = std::string(fp::platform_tag()); + + moonbase::device_id_description described; + described.device_id = fp::fingerprint_device_id( + fp::build_fingerprint_material(platform, params), fp::device_id_source::scoped); + described.version = fp::version; + described.platform = platform; + described.source = fp::device_id_source::scoped; + for (const auto& param : fp::canonicalize_params(params)) + described.param_names.push_back(param.first); + return described; + } +}; + +} // namespace moonbase::juce_integration 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..767f0af --- /dev/null +++ b/modules/moonbase_licensing/juce/legacy_juce_device_id_resolver.h @@ -0,0 +1,58 @@ +#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(), // NOT moonbase_device_id_resolver: +// // that throws on iOS and Android +// 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..0a39643 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/device_id_resolver.hpp @@ -0,0 +1,212 @@ +#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; + } + + std::call_once(previous_ids_once_, [this] { + 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. + } + } + }); + + 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::once_flag previous_ids_once_; + 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..6432793 --- /dev/null +++ b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp @@ -0,0 +1,353 @@ +#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__) && !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 { + +/// 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__) + // No identity parameters are defined for Android. +#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: + [[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. + std::call_once(identity_once_, [this] { + 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{}; + } + }); + return identity_; + } + + [[nodiscard]] const device_id_description& description() const + { + // call_once treats an exceptional return as "not done", so a machine that + // is momentarily unreadable retries on the next call instead of caching + // the failure. That is deliberate: a sticky failure would outlive the + // condition that caused it. + std::call_once(description_once_, [this] { + 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); + } + }); + 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(_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::once_flag identity_once_; + mutable device_identity identity_; + mutable std::once_flag description_once_; + 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..a8792f4 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,9 @@ //============================================================================== // 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/android_device_id_resolver.h" +#include "juce/ios_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..2fd31fc 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,170 @@ 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"}); + 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 { From d4a556cb07a5dbd7683389cbc122ab991bfc7552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Sun, 2 Aug 2026 22:10:55 +0200 Subject: [PATCH 2/3] refactor: read iOS and Android identity in the core, not through JUCE The device fingerprint is meant to be framework-independent, and it was for macOS, Linux and Windows: the core SDK reads IOKit, sysfs and the firmware table itself. iOS and Android were the odd ones out, implemented in the JUCE module and then duplicated into the OnlineUnlockStatus bridge so it had something to use. Two copies of the same readers, and neither available to a non-JUCE consumer. Both now live in moonbase_device_id_resolver.hpp alongside the others. identifierForVendor is reached through the Objective-C runtime's C API, so the header stays plain C++ with no framework and no .mm file. ANDROID_ID goes through plain JNI from the NDK. Android needs one thing a native library genuinely cannot obtain by itself, an application Context, so the host supplies it once via moonbase::android::set_jni_environment(vm, context). The JUCE module calls that from the controller; a non-JUCE app calls it from JNI_OnLoad. 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. Deletes modules/moonbase_licensing/juce/{ios,android}_device_id_resolver.h and the bridge's copy of them, and collapses ActivationConfig::defaultDeviceIdResolver() back to a single resolver, since there is no longer a platform for which the core one is wrong. Net 169 lines removed from the JUCE surface. No device id changes: the iOS material is the same identifierForVendor, normalized the same way, and Android was previously unreachable from the core at all. --- docs/juce-module.md | 4 +- docs/juce.md | 17 +- examples/juce/MoonbaseJuceBridge.h | 127 +---------- .../moonbase/moonbase_device_id_resolver.hpp | 204 +++++++++++++++++- modules/moonbase_licensing/README.md | 3 +- .../juce/ActivationConfig.h | 54 ++--- .../juce/ActivationController.cpp | 12 ++ .../juce/android_device_id_resolver.h | 140 ------------ .../juce/ios_device_id_resolver.h | 79 ------- .../juce/legacy_juce_device_id_resolver.h | 3 +- .../moonbase/moonbase_device_id_resolver.hpp | 204 +++++++++++++++++- .../moonbase_licensing/moonbase_licensing.h | 2 - tests/juce/controller_tests.cpp | 5 + 13 files changed, 446 insertions(+), 408 deletions(-) delete mode 100644 modules/moonbase_licensing/juce/android_device_id_resolver.h delete mode 100644 modules/moonbase_licensing/juce/ios_device_id_resolver.h diff --git a/docs/juce-module.md b/docs/juce-module.md index 5d38eb0..c831aa4 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -212,7 +212,9 @@ JUCE major bump. Full options in ### 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. +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 diff --git a/docs/juce.md b/docs/juce.md index 0826bf4..5188d79 100644 --- a/docs/juce.md +++ b/docs/juce.md @@ -14,16 +14,9 @@ build dependency of the SDK itself. ## What you get [`examples/juce/MoonbaseJuceBridge.h`](../examples/juce/MoonbaseJuceBridge.h) is a -single header containing five pieces under the `moonbase::juce_bridge` +single header containing three pieces under the `moonbase::juce_bridge` namespace: -- **`makeDefaultDeviceIdResolver()`** — the resolver you get when you pass none. - Cross-SDK hardware identity on desktop (including Mac Catalyst), and a scoped - identity on iOS and Android, where the plain spec resolver has nothing to read - and would throw. Wrap *this* when migrating, not - `moonbase_device_id_resolver` directly. -- **`MoonbaseJuceScopedDeviceIdResolver`** — the mobile half of that, building a - `mbd2s_` scoped id from `identifierForVendor` or `ANDROID_ID`. - **`MoonbaseJuceDeviceIdResolver`** — implements `moonbase::device_id_resolver` on top of `juce::SystemStats::getUniqueDeviceID()`. Retained only as a *historical* resolver for migrating an already-shipped bridge. @@ -311,12 +304,8 @@ MoonbaseUnlockStatus status( options, store, 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 entirely. - makeDefaultDeviceIdResolver(), // binds - std::make_shared())); // still accepted + std::make_shared(), // binds + std::make_shared())); // still accepted ``` `MoonbaseJuceDeviceIdResolver` (renamed from `MoonbaseJuceFingerprintProvider`) is diff --git a/examples/juce/MoonbaseJuceBridge.h b/examples/juce/MoonbaseJuceBridge.h index bd2e22a..31ec72d 100644 --- a/examples/juce/MoonbaseJuceBridge.h +++ b/examples/juce/MoonbaseJuceBridge.h @@ -71,8 +71,7 @@ namespace moonbase::juce_bridge { // // MoonbaseUnlockStatus status(options, store, // std::make_shared( -// makeDefaultDeviceIdResolver(), // NOT moonbase_device_id_resolver: -// // that throws on iOS and Android +// std::make_shared(), // std::make_shared())); // // Deliberately not wired up by default: widening what your validator accepts is @@ -91,128 +90,6 @@ class MoonbaseJuceDeviceIdResolver : public moonbase::device_id_resolver } }; -// Scoped device identity for iOS and Android, per the spec's "Scoped identity" -// section. Neither platform exposes a device identifier that unrelated -// applications can read, so the id is stable only within the platform's own scope -// and is stamped `mbd2s_` to say so. -// -// This duplicates modules/moonbase_licensing/juce/{ios,android}_device_id_resolver.h -// on purpose: the bridge is copy-paste reference code and must not depend on the -// JUCE module. Keep the two in step if you change either. -class MoonbaseJuceScopedDeviceIdResolver : public moonbase::device_id_resolver -{ -public: - [[nodiscard]] std::string device_name() const override - { - // Decoration only. It never enters the material, because - // build_fingerprint_material refuses the host-name fallback on these - // platforms, where the name is "localhost" or a model name. - return juce::SystemStats::getDeviceDescription().toStdString(); - } - - [[nodiscard]] std::string device_id() const override { return describe().device_id; } - - [[nodiscard]] std::optional describe_device() const override - { - return describe(); - } - -private: - /// The platform's scoped identifier, or empty when it is momentarily absent. - [[nodiscard]] static moonbase::fingerprint_spec::parameter readScopedIdentity() - { - #if JUCE_IOS - // juce::SystemStats wraps [[UIDevice currentDevice] identifierForVendor] - // on iOS. Normalized like ioPlatformUuid: hyphens stripped, uppercased. - return {"identifierForVendor", - moonbase::fingerprint_spec::normalize_platform_uuid( - juce::SystemStats::getUniqueDeviceID().toStdString())}; - #elif JUCE_ANDROID - // Deliberately NOT juce::SystemStats::getUniqueDeviceID(): it 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 would reject that - // anyway, but reading it correctly is the point. - return {"androidId", readAndroidId()}; - #else - return {"androidId", {}}; - #endif - } - - #if JUCE_ANDROID - [[nodiscard]] static std::string readAndroidId() - { - auto* env = juce::getEnv(); - if (env == nullptr) - return {}; - - juce::LocalRef secure((jclass) env->FindClass("android/provider/Settings$Secure")); - if (secure.get() == nullptr) { env->ExceptionClear(); return {}; } - - const auto getString = env->GetStaticMethodID( - secure.get(), "getString", - "(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;"); - if (getString == nullptr) { env->ExceptionClear(); return {}; } - - auto context = juce::getAppContext(); - if (context.get() == nullptr) - return {}; - - juce::LocalRef resolver(env->CallObjectMethod( - context.get(), - env->GetMethodID(env->GetObjectClass(context.get()), - "getContentResolver", "()Landroid/content/ContentResolver;"))); - if (resolver.get() == nullptr) { env->ExceptionClear(); return {}; } - - juce::LocalRef key(env->NewStringUTF("android_id")); - juce::LocalRef value((jstring) env->CallStaticObjectMethod( - secure.get(), getString, resolver.get(), key.get())); - - if (env->ExceptionCheck()) { env->ExceptionClear(); return {}; } - if (value.get() == nullptr) - return {}; - - return juce::juceString(env, value.get()).toLowerCase().toStdString(); - } - #endif - - [[nodiscard]] moonbase::device_id_description describe() const - { - namespace fp = moonbase::fingerprint_spec; - - const fp::parameter_list params{readScopedIdentity()}; - const auto platform = std::string(fp::platform_tag()); - - moonbase::device_id_description described; - described.device_id = fp::fingerprint_device_id( - fp::build_fingerprint_material(platform, params), fp::device_id_source::scoped); - described.version = fp::version; - described.platform = platform; - described.source = fp::device_id_source::scoped; - for (const auto& param : fp::canonicalize_params(params)) - described.param_names.push_back(param.first); - return described; - } -}; - -// The resolver this platform gets when you do not pass one. -// -// Desktop, including Mac Catalyst, uses the cross-SDK hardware fingerprint. iOS -// and Android have no identifier unrelated apps can read, so they get a scoped id -// instead; the plain spec resolver would simply throw there. -// -// Wrap *this* rather than a hard-coded moonbase_device_id_resolver when migrating, -// or mobile users are locked out: a migrating resolver asks its current resolver -// for an id before consulting any historical one. -[[nodiscard]] inline std::shared_ptr makeDefaultDeviceIdResolver() -{ - #if JUCE_IOS || JUCE_ANDROID - return std::make_shared(); - #else - return std::make_shared(); - #endif -} - // --------------------------------------------------------------------------- // Metadata helper // --------------------------------------------------------------------------- @@ -325,7 +202,7 @@ class MoonbaseUnlockStatus : public juce::OnlineUnlockStatus explicit MoonbaseUnlockStatus(moonbase::licensing_options options, std::shared_ptr store = nullptr, std::shared_ptr deviceIds - = makeDefaultDeviceIdResolver(), + = std::make_shared(), juce::String websiteName = "moonbase.sh") : productId_(options.product_id), websiteName_(std::move(websiteName)), diff --git a/include/moonbase/moonbase_device_id_resolver.hpp b/include/moonbase/moonbase_device_id_resolver.hpp index 6432793..2b55a9f 100644 --- a/include/moonbase/moonbase_device_id_resolver.hpp +++ b/include/moonbase/moonbase_device_id_resolver.hpp @@ -39,6 +39,25 @@ #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` @@ -61,6 +80,58 @@ 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; @@ -137,7 +208,9 @@ class moonbase_device_id_resolver : public device_id_resolver { #elif defined(_WIN32) identity.params = fingerprint_spec::parse_smbios_params(read_windows_smbios_table()); #elif defined(__ANDROID__) - // No identity parameters are defined for 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. @@ -296,6 +369,135 @@ class moonbase_device_id_resolver : public device_id_resolver { } #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() { diff --git a/modules/moonbase_licensing/README.md b/modules/moonbase_licensing/README.md index 494bab7..3f78c97 100644 --- a/modules/moonbase_licensing/README.md +++ b/modules/moonbase_licensing/README.md @@ -24,7 +24,8 @@ Add the module, fill in three fields, show one component. 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; see + 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. diff --git a/modules/moonbase_licensing/juce/ActivationConfig.h b/modules/moonbase_licensing/juce/ActivationConfig.h index a3822e8..1921d36 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -21,10 +21,8 @@ #include #include "JuceMetadata.h" -// Both are named by resolvedDeviceIdResolver(). Included here rather than relied -// on from the module umbrella so this header stays usable on its own. -#include "android_device_id_resolver.h" -#include "ios_device_id_resolver.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 @@ -306,12 +304,14 @@ struct ActivationConfig /// The resolver this platform gets when `deviceIdResolver` is left unset. /// - /// Static and public because a migrating configuration must wrap *this*, not a - /// hard-coded `moonbase_device_id_resolver`. On iOS and Android the spec - /// resolver has no identity parameters to read and throws, and a migrating - /// wrapper asks its current resolver for an id before consulting any historical - /// one, so hard-coding it there locks mobile users out of both validation and - /// new activation: + /// 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( @@ -321,39 +321,9 @@ struct ActivationConfig [[nodiscard]] static std::shared_ptr defaultDeviceIdResolver( bool allowHostNameFallback = false) { - // A constexpr flag with an ordinary `if`, rather than an #if around the - // returns: in a non-template function both branches are still compiled, so - // the mobile paths cannot rot unnoticed on a desktop-only CI matrix. - if (isAndroid) - { - // Android's only device identifier is scoped to the app signing key, so - // the id is scoped and stamped mbd2s_. Deliberately not - // juce::SystemStats::getUniqueDeviceID(): it hashes the string constant - // "android_id" rather than the device's value, so every JUCE Android app - // reports the same id. See android_device_id_resolver.h. - // - // allowHostNameFallback is ignored, as it is on iOS: the label is - // Build.MODEL on most devices, so hashing it would give a whole product - // line one id. build_fingerprint_material refuses it outright. - return std::make_shared(); - } - - if (isMobileApple) - { - // iOS gets a *scoped* spec identity, stamped mbd2s_. - // - // iOS exposes no device identifier that unrelated applications can - // read: no hardware serial since iOS 7, and identifierForVendor is - // scoped to the App Store vendor. Cross-SDK parity is therefore - // impossible by construction, and the stamp says so rather than - // pretending otherwise. - // - // Note this excludes Mac Catalyst, which runs on macOS, can read IOKit, - // and so takes the cross-SDK hardware identity below. - return std::make_shared(); - } - 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; diff --git a/modules/moonbase_licensing/juce/ActivationController.cpp b/modules/moonbase_licensing/juce/ActivationController.cpp index 6c2016c..8409e8a 100644 --- a/modules/moonbase_licensing/juce/ActivationController.cpp +++ b/modules/moonbase_licensing/juce/ActivationController.cpp @@ -53,6 +53,18 @@ ActivationController::ActivationController(ActivationConfig config) auto store = std::make_shared( std::filesystem::path(file.getFullPathName().toStdString())); +#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 diff --git a/modules/moonbase_licensing/juce/android_device_id_resolver.h b/modules/moonbase_licensing/juce/android_device_id_resolver.h deleted file mode 100644 index 15090e6..0000000 --- a/modules/moonbase_licensing/juce/android_device_id_resolver.h +++ /dev/null @@ -1,140 +0,0 @@ -#pragma once - -// Scoped device identity for Android, per the fingerprint spec's "Scoped -// identity" section. -// -// Android exposes no device identifier that unrelated applications can read: -// Build.SERIAL returns "unknown" without a privileged permission from Android 10, -// IMEI needs READ_PRIVILEGED_PHONE_STATE, MAC addresses are randomised, and -// Settings.Secure.ANDROID_ID has been scoped to the app signing key since -// Android 8. So the id is scoped and stamped `mbd2s_`. -// -// DO NOT reach for juce::SystemStats::getUniqueDeviceID() here. It reads the -// *static field* Settings.Secure.ANDROID_ID via GetStaticObjectField, which is the -// key name "android_id" rather than the device's value, and hashes that. Every -// JUCE Android app therefore reports the same id, so one activation would unlock -// the entire install base. Its jassert that the result is non-empty never fires, -// because the hash of a constant is not empty, so the defect is silent. It is -// still present in JUCE 9.0.0. -// -// This resolver calls Settings.Secure.getString(contentResolver, "android_id") -// through JNI instead, and the spec's ^[0-9a-f]{1,16}$ rule then makes the mistake -// mechanically impossible: "android_id" is not hex, so it can never reach the -// material even if this code regressed. - -#include -#include -#include - -#include -#include - -#include - -namespace moonbase::juce_integration { - -class android_device_id_resolver : public moonbase::device_id_resolver -{ -public: - /// Settings.Secure.getString(contentResolver, ANDROID_ID), lowercased. - /// - /// Empty when Android declines to provide one: the value is generated lazily - /// and getString can return null. Absence is transient and must surface as - /// insufficient identity rather than as a constant. - [[nodiscard]] static std::string readAndroidId() - { -#if JUCE_ANDROID - auto* env = juce::getEnv(); - if (env == nullptr) - return {}; - - // Settings.Secure.getString(ContentResolver, String). The second argument - // is the literal key, NOT the static field: reading the field yields the - // key name itself, which is what the JUCE defect above does. - juce::LocalRef secure( - (jclass) env->FindClass("android/provider/Settings$Secure")); - if (secure.get() == nullptr) - { - env->ExceptionClear(); - return {}; - } - - const auto getString = env->GetStaticMethodID( - secure.get(), - "getString", - "(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;"); - if (getString == nullptr) - { - env->ExceptionClear(); - return {}; - } - - auto context = juce::getAppContext(); - if (context.get() == nullptr) - return {}; - - juce::LocalRef resolver(env->CallObjectMethod( - context.get(), - env->GetMethodID( - env->GetObjectClass(context.get()), - "getContentResolver", - "()Landroid/content/ContentResolver;"))); - if (resolver.get() == nullptr) - { - env->ExceptionClear(); - return {}; - } - - juce::LocalRef key(env->NewStringUTF("android_id")); - juce::LocalRef value((jstring) env->CallStaticObjectMethod( - secure.get(), getString, resolver.get(), key.get())); - - if (env->ExceptionCheck()) - { - env->ExceptionClear(); - return {}; - } - if (value.get() == nullptr) - return {}; - - return juce::juceString(env, value.get()).toLowerCase().toStdString(); -#else - return {}; -#endif - } - - [[nodiscard]] std::string device_name() const override - { - // Decoration only: it never enters the material, because the host-name - // fallback is forbidden on Android. Build.MODEL on most devices. - return juce::SystemStats::getDeviceDescription().toStdString(); - } - - [[nodiscard]] std::string device_id() const override { return describe().device_id; } - - [[nodiscard]] std::optional describe_device() const override - { - return describe(); - } - -private: - [[nodiscard]] moonbase::device_id_description describe() const - { - namespace fp = moonbase::fingerprint_spec; - - const fp::parameter_list params{{"androidId", readAndroidId()}}; - const auto platform = std::string(fp::platform_tag()); - - moonbase::device_id_description described; - described.device_id = fp::fingerprint_device_id( - fp::build_fingerprint_material(platform, params), fp::device_id_source::scoped); - described.version = fp::version; - described.platform = platform; - described.source = fp::device_id_source::scoped; - for (const auto& param : fp::canonicalize_params(params)) - described.param_names.push_back(param.first); - return described; - } -}; - -} // namespace moonbase::juce_integration diff --git a/modules/moonbase_licensing/juce/ios_device_id_resolver.h b/modules/moonbase_licensing/juce/ios_device_id_resolver.h deleted file mode 100644 index 98e6148..0000000 --- a/modules/moonbase_licensing/juce/ios_device_id_resolver.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once - -// Scoped device identity for iOS, per the fingerprint spec's "Scoped identity" -// section. -// -// iOS provides no device identifier that unrelated applications can read: the -// hardware serial has been inaccessible since iOS 7, and identifierForVendor is -// scoped to the app's vendor prefix, so two publishers see different values on -// the same device. The spec's answer is not to pretend otherwise but to stamp the -// limitation into the id, as `mbd2s_`, so the server, support and any validator -// know the value is stable within this publisher and meaningless outside it. -// -// This is deliberately NOT the spec's host-name fallback. iOS device names are -// routinely the literal "iPhone", so hashing one would hand an enormous -// population a single device id and let them validate each other's licenses, -// which is exactly the failure the insufficient-identity rule exists to prevent. -// -// identifierForVendor is nil until the device is first unlocked after boot, and -// resets if the user removes every app from this vendor. Both cases surface as -// insufficient_device_identity_error rather than as a constant. - -#include -#include -#include - -#include -#include - -#include - -namespace moonbase::juce_integration { - -class ios_device_id_resolver : public moonbase::device_id_resolver -{ -public: - /// The vendor identifier, uppercased with hyphens removed, matching how the - /// spec treats ioPlatformUuid. Empty when iOS declines to provide one. - /// - /// Sourced through juce::SystemStats, which wraps - /// [[UIDevice currentDevice] identifierForVendor] on iOS. - [[nodiscard]] static std::string readIdentifierForVendor() - { - return moonbase::fingerprint_spec::normalize_platform_uuid( - juce::SystemStats::getUniqueDeviceID().toStdString()); - } - - [[nodiscard]] std::string device_name() const override - { - return juce::SystemStats::getComputerName().toStdString(); - } - - [[nodiscard]] std::string device_id() const override { return describe().device_id; } - - [[nodiscard]] std::optional describe_device() const override - { - return describe(); - } - -private: - [[nodiscard]] moonbase::device_id_description describe() const - { - namespace fp = moonbase::fingerprint_spec; - - const fp::parameter_list params{{"identifierForVendor", readIdentifierForVendor()}}; - const auto platform = std::string(fp::platform_tag()); - - moonbase::device_id_description described; - described.device_id = fp::fingerprint_device_id( - fp::build_fingerprint_material(platform, params), fp::device_id_source::scoped); - described.version = fp::version; - described.platform = platform; - described.source = fp::device_id_source::scoped; - for (const auto& param : fp::canonicalize_params(params)) - described.param_names.push_back(param.first); - return described; - } -}; - -} // 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 index 767f0af..5ef7416 100644 --- a/modules/moonbase_licensing/juce/legacy_juce_device_id_resolver.h +++ b/modules/moonbase_licensing/juce/legacy_juce_device_id_resolver.h @@ -8,8 +8,7 @@ // historical resolver: // // config.deviceIdResolver = std::make_shared( -// ActivationConfig::defaultDeviceIdResolver(), // NOT moonbase_device_id_resolver: -// // that throws on iOS and Android +// ActivationConfig::defaultDeviceIdResolver(), // std::make_shared()); // // Two reasons it is not the default any more. It is not the spec, so a license diff --git a/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp index 6432793..2b55a9f 100644 --- a/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp +++ b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp @@ -39,6 +39,25 @@ #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` @@ -61,6 +80,58 @@ 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; @@ -137,7 +208,9 @@ class moonbase_device_id_resolver : public device_id_resolver { #elif defined(_WIN32) identity.params = fingerprint_spec::parse_smbios_params(read_windows_smbios_table()); #elif defined(__ANDROID__) - // No identity parameters are defined for 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. @@ -296,6 +369,135 @@ class moonbase_device_id_resolver : public device_id_resolver { } #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() { diff --git a/modules/moonbase_licensing/moonbase_licensing.h b/modules/moonbase_licensing/moonbase_licensing.h index a8792f4..edcefc6 100644 --- a/modules/moonbase_licensing/moonbase_licensing.h +++ b/modules/moonbase_licensing/moonbase_licensing.h @@ -73,8 +73,6 @@ // Native integration + built-in UI. #include "juce/juce_http_transport.h" #include "juce/legacy_juce_device_id_resolver.h" -#include "juce/android_device_id_resolver.h" -#include "juce/ios_device_id_resolver.h" #include "juce/JuceMetadata.h" #include "juce/LicenseGate.h" #include "juce/ActivationConfig.h" diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 2fd31fc..7485034 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -1098,6 +1098,11 @@ TEST_CASE("the module default is the cross-SDK spec resolver, except on 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; } From 23938d1d87ec43c27fd85881a443c80346a2505c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Sun, 2 Aug 2026 22:37:12 +0200 Subject: [PATCH 3/3] fix: replace std::call_once memoization, which deadlocks under ThreadSanitizer The sanitizers workflow was not slow, it was hung. Its log stops dead at "Start 22: a failed identity read is retried, not cached" and sits there until the 20-minute timeout kills it. Everything before that is fast: tests 1-21 take about one second under TSan, including the 8-thread memoization test (0.02s) and the 20,000-iteration SMBIOS sweep (0.17s). TSan overhead was never the issue. moonbase_device_id_resolver::description() lets insufficient_device_identity_error escape the std::call_once callable on purpose, so a machine that is momentarily unreadable retries instead of caching the failure. libstdc++ implements call_once on pthread_once, and TSan's pthread_once interceptor does not model the reset that the exception path performs, so the *second* call blocks forever. That is exactly what test 22 does, and it is why ASan and UBSan pass on the same libstdc++ while TSan does not. Replaces both memos, and the one in migrating_device_id_resolver whose allocations can also throw, with a mutex and a flag set only after the value is stored. Same semantics, including the deliberate retry, without depending on the one corner of call_once that is fragile. Returning a reference stays safe because neither value is mutated once its flag is set, and the lock provides the happens-before edge. Worth fixing in the header rather than skipping the test: any consumer running TSan against a machine with no readable hardware identity would hit the same deadlock in shipped code. --- include/moonbase/device_id_resolver.hpp | 35 +++++++++++------ .../moonbase/moonbase_device_id_resolver.hpp | 39 ++++++++++++++----- .../moonbase/device_id_resolver.hpp | 35 +++++++++++------ .../moonbase/moonbase_device_id_resolver.hpp | 39 ++++++++++++++----- 4 files changed, 104 insertions(+), 44 deletions(-) diff --git a/include/moonbase/device_id_resolver.hpp b/include/moonbase/device_id_resolver.hpp index 0a39643..9f2f881 100644 --- a/include/moonbase/device_id_resolver.hpp +++ b/include/moonbase/device_id_resolver.hpp @@ -175,19 +175,29 @@ class migrating_device_id_resolver : public device_id_resolver { return false; } - std::call_once(previous_ids_once_, [this] { - 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. + // 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) { @@ -205,7 +215,8 @@ class migrating_device_id_resolver : public device_id_resolver { std::shared_ptr current_; std::vector> previous_; - mutable std::once_flag previous_ids_once_; + mutable std::mutex previous_ids_mutex_; + mutable bool previous_ids_computed_ = false; mutable std::vector previous_ids_; }; diff --git a/include/moonbase/moonbase_device_id_resolver.hpp b/include/moonbase/moonbase_device_id_resolver.hpp index 2b55a9f..3e97495 100644 --- a/include/moonbase/moonbase_device_id_resolver.hpp +++ b/include/moonbase/moonbase_device_id_resolver.hpp @@ -261,13 +261,27 @@ class moonbase_device_id_resolver : public device_id_resolver { } 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. - std::call_once(identity_once_, [this] { + const std::lock_guard lock(identity_mutex_); + if (!identity_read_) { try { identity_ = options_.reader ? options_.reader() : read_host_identity(); } catch (...) { @@ -276,17 +290,19 @@ class moonbase_device_id_resolver : public device_id_resolver { // out of device_name(). identity_ = device_identity{}; } - }); + identity_read_ = true; + } return identity_; } [[nodiscard]] const device_id_description& description() const { - // call_once treats an exceptional return as "not done", so a machine that - // is momentarily unreadable retries on the next call instead of caching - // the failure. That is deliberate: a sticky failure would outlive the - // condition that caused it. - std::call_once(description_once_, [this] { + // 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); @@ -297,7 +313,8 @@ class moonbase_device_id_resolver : public device_id_resolver { description_ = describe( {{"deviceName", read.device_name}}, fingerprint_spec::device_id_source::device_name); } - }); + described_ = true; + } return description_; } @@ -546,9 +563,11 @@ class moonbase_device_id_resolver : public device_id_resolver { moonbase_device_id_resolver_options options_; - mutable std::once_flag identity_once_; + mutable std::mutex identity_mutex_; + mutable bool identity_read_ = false; mutable device_identity identity_; - mutable std::once_flag description_once_; + mutable std::mutex description_mutex_; + mutable bool described_ = false; mutable device_id_description description_; }; diff --git a/modules/moonbase_licensing/moonbase/device_id_resolver.hpp b/modules/moonbase_licensing/moonbase/device_id_resolver.hpp index 0a39643..9f2f881 100644 --- a/modules/moonbase_licensing/moonbase/device_id_resolver.hpp +++ b/modules/moonbase_licensing/moonbase/device_id_resolver.hpp @@ -175,19 +175,29 @@ class migrating_device_id_resolver : public device_id_resolver { return false; } - std::call_once(previous_ids_once_, [this] { - 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. + // 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) { @@ -205,7 +215,8 @@ class migrating_device_id_resolver : public device_id_resolver { std::shared_ptr current_; std::vector> previous_; - mutable std::once_flag previous_ids_once_; + mutable std::mutex previous_ids_mutex_; + mutable bool previous_ids_computed_ = false; mutable std::vector previous_ids_; }; diff --git a/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp index 2b55a9f..3e97495 100644 --- a/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp +++ b/modules/moonbase_licensing/moonbase/moonbase_device_id_resolver.hpp @@ -261,13 +261,27 @@ class moonbase_device_id_resolver : public device_id_resolver { } 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. - std::call_once(identity_once_, [this] { + const std::lock_guard lock(identity_mutex_); + if (!identity_read_) { try { identity_ = options_.reader ? options_.reader() : read_host_identity(); } catch (...) { @@ -276,17 +290,19 @@ class moonbase_device_id_resolver : public device_id_resolver { // out of device_name(). identity_ = device_identity{}; } - }); + identity_read_ = true; + } return identity_; } [[nodiscard]] const device_id_description& description() const { - // call_once treats an exceptional return as "not done", so a machine that - // is momentarily unreadable retries on the next call instead of caching - // the failure. That is deliberate: a sticky failure would outlive the - // condition that caused it. - std::call_once(description_once_, [this] { + // 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); @@ -297,7 +313,8 @@ class moonbase_device_id_resolver : public device_id_resolver { description_ = describe( {{"deviceName", read.device_name}}, fingerprint_spec::device_id_source::device_name); } - }); + described_ = true; + } return description_; } @@ -546,9 +563,11 @@ class moonbase_device_id_resolver : public device_id_resolver { moonbase_device_id_resolver_options options_; - mutable std::once_flag identity_once_; + mutable std::mutex identity_mutex_; + mutable bool identity_read_ = false; mutable device_identity identity_; - mutable std::once_flag description_once_; + mutable std::mutex description_mutex_; + mutable bool described_ = false; mutable device_id_description description_; };