Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/scripts/check-fingerprint-parity.mjs
Original file line number Diff line number Diff line change
@@ -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 <path-to-native-output.json>

import { appendFileSync, readFileSync } from 'node:fs'

const nativePath = process.argv[2]
if (!nativePath) {
console.error('usage: check-fingerprint-parity.mjs <path-to-native-output.json>')
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)
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
160 changes: 160 additions & 0 deletions .github/workflows/fingerprint-parity.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
],
Expand Down
Loading
Loading