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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions keymint/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import requests

from ._version import __version__
from .identity import InstallationIdError, InstallationIdWarning
from .offline import (
OfflineLicense,
OfflineLicenseError,
Expand All @@ -10,6 +11,8 @@
from .types import *

__all__ = [
'InstallationIdError',
'InstallationIdWarning',
'KeyMint',
'KeyMintApiError',
'OfflineLicense',
Expand Down
82 changes: 74 additions & 8 deletions keymint/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +14,7 @@
import subprocess
import time
import uuid
import warnings
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -161,31 +162,88 @@ 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.

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()

Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions tests/test_identity.py
Original file line number Diff line number Diff line change
@@ -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)
Loading