Skip to content

feat: send device fingerprint so machine binding works on Android - #33

Merged
CallMeTechie merged 2 commits into
mainfrom
callmetechie/determined-knuth-awmcxl
Oct 6, 2026
Merged

CallMeTechie merged 2 commits into
mainfrom
callmetechie/determined-knuth-awmcxl

Conversation

@CallMeTechie

@CallMeTechie CallMeTechie commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

Machine binding (Gerätebindung) on the GateControl server now works for the Android client end to end: the fingerprint is stable and unique per device, it reaches only the configured server, it is sent when a setup code is redeemed, and binding rejections show a clear message.

Fingerprint (core/data/.../MachineFingerprint.kt)

  • SHA-256(ANDROID_ID), 64 lowercase hex (^[a-f0-9]{64}$). Since Android 8, ANDROID_ID is already scoped per signing key, user and device. It survives reinstalls and changes on a factory reset.
  • This is the same derivation the app has sent since X-Machine-Fingerprint was introduced (old MachineId). Adding a package name or salt would change the value, and every Android token that is already bound would then fail with "bound to a different machine". So the derivation is kept on purpose.
  • Fixed: if ANDROID_ID was missing, the old code hashed the string "unknown", so all such devices shared one fingerprint. Now a null, empty, all-zero or 9774d56d682e549c ANDROID_ID falls back to a random 32-byte ID. It is generated once and stored in the app's Keystore-backed EncryptedStorage, and it is kept when the setup is reset (EncryptedStorage.clear(keep = …)).
  • The value is cached in memory. Logs show at most the first 8 characters.

Requests

  • ServerScopedHeadersInterceptor (originally MachineFingerprintInterceptor) is a network interceptor on each server's OkHttp client. It runs once per hop, so the header goes only to the configured GateControl server (same scheme, host and port). It is removed if a redirect goes to another host. This covers every ApiClient call: ping, register, enroll, config/check, heartbeat, peer-info, traffic, policy, RDP, support bundle, update check, services, split-tunnel, Pi-hole.
  • AuthInterceptor no longer adds the fingerprint. The header stays redacted in the HTTP log.
  • EnrollRequest now also carries fingerprint in the body. The current server reads the header on /client/enroll, and the body field is there for the contract.

API token stays on the server (separate commit)

  • OkHttp strips only Authorization on cross-host redirects, not custom headers. A redirect from the server, or a man-in-the-middle on plain HTTP, would therefore pass X-API-Token to a foreign host.
  • The same per-hop network interceptor now removes X-API-Token whenever the scheme, host or port differ from the configured server. This includes the explicit token of a connection test.

Error handling

  • MachineBindingError (MISMATCH, REQUIRED, INVALID) recognises the server's messages in English and German and the enroll code fingerprint_required.
  • MachineBindingMonitor (an application interceptor) records binding rejections from any client API call, including background ones. A banner on the VPN screen shows them. The next successful call to an endpoint that checks the binding clears the banner, for example after an admin resets the binding.
  • Setup (register/enroll) and the support bundle result show the specific message instead of "HTTP 403".
  • New strings (values/ and values-de/):
    • binding_mismatch: "Dieser Zugang ist an ein anderes Gerät gebunden. Bitte den Administrator, die Gerätebindung zurückzusetzen." / "This access is bound to a different device. Ask your administrator to reset the device binding."
    • binding_required, binding_invalid, settings_device_id

Device ID display

  • The Settings footer shows Geräte-ID ab12cd34… / Device ID ab12cd34…. This matches the server's "Gebunden an Gerät ab12cd34…".
  • The support bundle settings snapshot includes deviceId (short form only).

Tests

  • MachineFingerprintTest: format, sha256 compatibility, stability and caching, the fallback for each invalid ANDROID_ID, fallback reuse, no shared fallback, a corrupt stored fallback, and an exception while reading ANDROID_ID.
  • MachineFingerprintRequestTest (MockWebServer, real ApiClientProvider wiring):
    • The header is present on client API calls.
    • The header is absent after a redirect to a foreign host and on requests to another host.
    • The enroll body and header both carry the fingerprint.
    • The binding monitor sets and clears its state.
    • X-API-Token is present on same-host calls and absent after a redirect to a foreign host, including an explicit test token and a host that differs only in port.
    • The classifier recognises the English and German messages.
  • EncryptedStorageTest: clear(keep). SetupViewModelTest: the enroll body has the fingerprint, and enroll fingerprint_required and register 403 mismatch map to the new messages. SettingsViewModelTest: the device ID is shown, the support bundle 403 mismatch maps to the binding message, and the bundle contains deviceId.
  • ./gradlew test and ./gradlew lintRelease pass locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD


Generated by Claude Code

Claude added 2 commits October 6, 2026 08:58
- MachineFingerprint (core:data): SHA-256 of ANDROID_ID as 64 lowercase hex,
  unchanged from the value the app already sent so tokens that are bound
  keep working. Missing, empty or broken ANDROID_ID falls back to a random
  32-byte ID kept in EncryptedStorage (survives a setup reset) instead of
  the shared hash of "unknown". Cached in memory, only the first 8 chars
  are ever logged.
- MachineFingerprintInterceptor: network interceptor per server client, so
  X-Machine-Fingerprint goes only to the configured GateControl server and
  is stripped on redirects to other hosts.
- Enroll (setup code redeem) sends `fingerprint` in the body as well.
- MachineBindingError / MachineBindingMonitor: binding mismatch,
  fingerprint_required and invalid fingerprint are recognised (en/de server
  messages and enroll codes) and shown as clear messages in setup, the
  support bundle result and a banner on the VPN screen.
- Settings shows the device ID short form (first 8 hex chars) to match the
  server's "Gebunden an Gerät ab12cd34…"; the support bundle includes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD
OkHttp strips only Authorization on cross-host redirects, not custom
headers, so a redirect from the server (or a MITM on plain HTTP) would
hand X-API-Token to a foreign host. The per-server network interceptor
(MachineFingerprintInterceptor, now ServerScopedHeadersInterceptor) runs
on every hop and removes the token, including an explicit connection-test
token, whenever scheme, host or port differ from the configured server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD
@CallMeTechie
CallMeTechie merged commit 3e09724 into main Oct 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants