diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d005f..60849cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- `identity.get_or_create_installation_id()` no longer fails silently when it can't save the ID. By default it still returns an unsaved ID, and now emits one `InstallationIdWarning` (a `RuntimeWarning`) per process, because the next launch will get a different ID and use another activation slot. +- Add the keyword `require_persisted=False`. With `True` it raises `InstallationIdError` (a subclass of `OSError`, with `path`) when the ID can't be saved and read back, or an existing file can't be read. Recommended for desktop apps that activate devices. + ## 2.8.0 - 2026-11-11 - Add `error_code` to `KeyMintApiError`: the stable string code the API sends with every error (for example `api_key_invalid`, `activation_limit_reached`, `rate_limited`). Branch on it instead of the message. `None` when the response has none. The constructor, `message`, `code`, `status` and `str(err)` are unchanged. diff --git a/README.md b/README.md index 2994d8a..7df5639 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,23 @@ Keymint provides utilities to uniquely identify machines for node-locking: - `identity.get_or_create_installation_id()`: **Recommended.** Generates a stable UUID anchored to hardware and persists it to `~/.keymint/installation-id`. - `identity.get_machine_id()`: Generates a SHA-256 fingerprint based on BIOS UUID, OS machine ID, and MAC address. +### When the installation ID can't be saved + +If `get_or_create_installation_id()` can't save the ID (read-only disk, sandbox, permissions), by default it still returns an ID and emits one `InstallationIdWarning` (a `RuntimeWarning`) per process. That ID isn't saved, so the next launch gets a different one and the same machine uses another activation slot. + +Desktop apps that activate devices should pass `require_persisted=True`. It raises `InstallationIdError` (a subclass of `OSError`, with the storage path in `err.path`) when the ID can't be saved and read back, or an existing ID file can't be read: + +```python +from keymint import InstallationIdError, identity + +try: + host_id = identity.get_or_create_installation_id(require_persisted=True) +except InstallationIdError as err: + ... # tell the user the app can't write to err.path, or pass a writable storage_path +``` + +Pass `storage_path=` to keep the ID somewhere else, such as your app's data directory. + ## API Methods ### License Key Management diff --git a/keymint/__init__.py b/keymint/__init__.py index d9d220d..345cc25 100644 --- a/keymint/__init__.py +++ b/keymint/__init__.py @@ -1,6 +1,7 @@ import requests from ._version import __version__ +from .identity import InstallationIdError, InstallationIdWarning from .offline import ( OfflineLicense, OfflineLicenseError, @@ -10,6 +11,8 @@ from .types import * __all__ = [ + 'InstallationIdError', + 'InstallationIdWarning', 'KeyMint', 'KeyMintApiError', 'OfflineLicense', diff --git a/keymint/identity.py b/keymint/identity.py index 090aea8..66d84a2 100644 --- a/keymint/identity.py +++ b/keymint/identity.py @@ -3,7 +3,7 @@ Provides two methods for identifying machines: - get_machine_id(): Best-effort hardware fingerprint (may fail or collide). -- get_or_create_installation_id(): Guaranteed-stable persistent identity. +- get_or_create_installation_id(): Persistent identity, stable once saved. """ import hashlib @@ -14,6 +14,7 @@ import subprocess import time import uuid +import warnings from pathlib import Path from typing import Optional @@ -161,9 +162,46 @@ def get_machine_id() -> str | None: return None -def get_or_create_installation_id(storage_path: str | None = None) -> str: +class InstallationIdError(OSError): + """The installation ID could not be saved to, or read back from, disk. + + Raised by :func:`get_or_create_installation_id` only when + ``require_persisted=True``. Subclasses ``OSError``, so existing + ``except OSError`` handlers still catch it. + + :ivar path: The storage path that could not be used. + """ + + def __init__(self, message: str, path: str): + super().__init__(message) + self.path = path + + +class InstallationIdWarning(RuntimeWarning): + """Emitted once per process when an unsaved installation ID is returned.""" + + +_unsaved_warning_emitted = False + + +def _warn_unsaved(file_path: Path) -> None: + global _unsaved_warning_emitted + if _unsaved_warning_emitted: + return + _unsaved_warning_emitted = True + warnings.warn( + f'Keymint could not save the installation ID to {file_path}. ' + 'An unsaved ID was returned, so the next launch will get a different ID ' + 'and use another activation slot. Pass a writable storage_path, or ' + 'require_persisted=True to raise instead.', + InstallationIdWarning, + stacklevel=3, + ) + + +def get_or_create_installation_id(storage_path: str | None = None, *, require_persisted: bool = False) -> str: """ - Returns a guaranteed-unique, guaranteed-stable installation identifier. + Returns a unique installation identifier that stays stable once saved. On first call, generates a UUIDv4 seeded with whatever hardware info is available and persists it to disk. Every subsequent call returns the same value. @@ -171,21 +209,41 @@ def get_or_create_installation_id(storage_path: str | None = None) -> str: This is the **recommended** value to pass as `host_id` when activating a license key. + If the ID cannot be saved (read-only disk, sandbox, permissions), the + default is to return the generated ID anyway and emit one + ``InstallationIdWarning`` (a ``RuntimeWarning``) per process. That ID is + not saved, so the next launch gets a different one and uses another + activation slot. Desktop apps that activate devices should pass + ``require_persisted=True``, which raises ``InstallationIdError`` instead. + Args: storage_path: Optional custom path for the persistence file. Defaults to ~/.keymint/installation-id. + require_persisted: If True, raise ``InstallationIdError`` when the ID + cannot be saved and read back, or an existing file cannot be read. + Defaults to False. Returns: A SHA-256 hashed 64-character hex string. Raises: - OSError: If the file cannot be read or written. + InstallationIdError: Only with ``require_persisted=True``, when the + ID cannot be saved and read back or the existing file cannot be read. + OSError: If an existing file cannot be read (default mode). """ file_path = Path(storage_path) if storage_path else Path.home() / '.keymint' / 'installation-id' # 1. If the file exists, trust it if file_path.exists(): - stored = file_path.read_text(encoding='utf-8').strip() + try: + stored = file_path.read_text(encoding='utf-8').strip() + except OSError as err: + if require_persisted: + raise InstallationIdError( + f'Could not read the Keymint installation ID from {file_path}: {err}', + str(file_path), + ) from err + raise if stored: return hashlib.sha256(stored.encode('utf-8')).hexdigest() @@ -194,15 +252,23 @@ def get_or_create_installation_id(storage_path: str | None = None) -> str: new_uuid = str(uuid.uuid4()) composite_id = f'{new_uuid}:{hardware_anchor}:{int(time.time() * 1000)}' - # 3. Persist it + # 3. Persist it and read it back try: file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(composite_id, encoding='utf-8') - except Exception: - pass # Silently fallback to in-memory ID if filesystem is read-only + if file_path.read_text(encoding='utf-8').strip() != composite_id: + raise OSError('the saved value did not match when read back') + except Exception as err: + if require_persisted: + raise InstallationIdError( + f'Could not save the Keymint installation ID to {file_path}: {err}', + str(file_path), + ) from err + _warn_unsaved(file_path) return hashlib.sha256(composite_id.encode('utf-8')).hexdigest() + def generate_session_signature(session_id: str, nonce: str, session_secret: str) -> str: """ Generates a cryptographic signature for a heartbeat or checkin request diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..339736f --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,101 @@ +import hashlib +import os +import sys +import warnings + +import pytest + +from keymint import InstallationIdError, InstallationIdWarning, identity + + +@pytest.fixture(autouse=True) +def reset_warning_flag(monkeypatch): + monkeypatch.setattr(identity, "_unsaved_warning_emitted", False) + monkeypatch.setattr(identity, "get_machine_id", lambda: "anchor") + + +def unwritable_path(tmp_path): + # The parent "directory" is a regular file, so mkdir and write fail + # on every platform, even when the tests run as root. + blocker = tmp_path / "not-a-directory" + blocker.write_text("x") + return blocker / "installation-id" + + +def test_saves_the_id_and_returns_the_same_value_next_time(tmp_path): + path = tmp_path / "nested" / "installation-id" + with warnings.catch_warnings(): + warnings.simplefilter("error") + first = identity.get_or_create_installation_id(str(path)) + second = identity.get_or_create_installation_id(str(path)) + assert path.exists() + assert first == second + assert first == hashlib.sha256(path.read_text().strip().encode()).hexdigest() + + +def test_strict_mode_saves_normally(tmp_path): + path = tmp_path / "installation-id" + value = identity.get_or_create_installation_id(str(path), require_persisted=True) + assert value == identity.get_or_create_installation_id(str(path), require_persisted=True) + + +def test_existing_file_is_reused(tmp_path): + path = tmp_path / "installation-id" + path.write_text("existing-id\n") + expected = hashlib.sha256(b"existing-id").hexdigest() + assert identity.get_or_create_installation_id(str(path)) == expected + assert identity.get_or_create_installation_id(str(path), require_persisted=True) == expected + assert path.read_text() == "existing-id\n" + + +def test_unwritable_default_returns_an_id_and_warns_once(tmp_path): + path = unwritable_path(tmp_path) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + first = identity.get_or_create_installation_id(str(path)) + second = identity.get_or_create_installation_id(str(path)) + assert len(first) == 64 and len(second) == 64 + assert first != second # nothing was saved + relevant = [w for w in caught if issubclass(w.category, InstallationIdWarning)] + assert len(relevant) == 1 + assert issubclass(relevant[0].category, RuntimeWarning) + assert str(path) in str(relevant[0].message) + assert relevant[0].filename == __file__ + + +def test_unwritable_strict_raises(tmp_path): + path = unwritable_path(tmp_path) + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(InstallationIdError) as excinfo: + identity.get_or_create_installation_id(str(path), require_persisted=True) + assert excinfo.value.path == str(path) + assert str(path) in str(excinfo.value) + assert isinstance(excinfo.value, OSError) + + +@pytest.mark.skipif(sys.platform == "win32" or os.geteuid() == 0, reason="needs POSIX permissions as non-root") +def test_read_only_directory(tmp_path): + directory = tmp_path / "ro" + directory.mkdir() + directory.chmod(0o500) + path = directory / "installation-id" + try: + with pytest.warns(InstallationIdWarning): + assert len(identity.get_or_create_installation_id(str(path))) == 64 + with pytest.raises(InstallationIdError): + identity.get_or_create_installation_id(str(path), require_persisted=True) + finally: + directory.chmod(0o700) + + +def test_read_back_mismatch_raises_in_strict_mode(tmp_path, monkeypatch): + path = tmp_path / "installation-id" + original_write = type(path).write_text + + def lossy_write(self, data, *args, **kwargs): + return original_write(self, data[:-1], *args, **kwargs) + + monkeypatch.setattr(type(path), "write_text", lossy_write) + with pytest.raises(InstallationIdError): + identity.get_or_create_installation_id(str(path), require_persisted=True)