altifyhwid 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: altifyhwid
3
+ Version: 1.1.0
4
+ Summary: Windows hardware identity matching across three or more hardware categories
5
+ Author: Altify
6
+ Keywords: hardware,hwid,windows,device,identity
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Topic :: System :: Hardware
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Altify HWID
16
+
17
+ Recognize a Windows device using three or more matching hardware categories.
18
+ Python 3.10 or newer. No third-party runtime dependencies.
19
+
20
+ ## Install
21
+
22
+ After the maintainer publishes this project to PyPI:
23
+
24
+ ```shell
25
+ python -m pip install altifyhwid
26
+ ```
27
+
28
+ The PyPI distribution name is `altifyhwid`; the Python import is `altifyhwid`.
29
+
30
+ ## Use
31
+
32
+ ```python
33
+ import altifyhwid as hwid
34
+
35
+ device_id = hwid.get_id()
36
+ print(device_id)
37
+
38
+ result = hwid.identify()
39
+ print(result.to_dict())
40
+ ```
41
+
42
+ Hardware collection is cached for 60 seconds per process. Database matching
43
+ still runs on each call. When both the ID and details are needed, call
44
+ `identify()` once and read `result.device_id`. Use `get_id(cache_ttl=0)` for a
45
+ fresh scan or `clear_cache()` to discard cached snapshots.
46
+
47
+ ```python
48
+ from altifyhwid import hwid
49
+
50
+ print(hwid())
51
+ ```
52
+
53
+ Default database on Windows:
54
+ `%LOCALAPPDATA%\Altify\HWID\devices.sqlite3`.
55
+ Preserve this database to preserve device IDs. Override it with `db_path` or
56
+ the `ALTIFY_HWID_DB` environment variable. Explicit `db_path` takes priority.
57
+
58
+ ```python
59
+ import altifyhwid
60
+
61
+ # Reuse the registry created by the earlier standalone script:
62
+ device_id = altifyhwid.get_id(
63
+ db_path=r"C:\MyApp\settings\AltifyHWID\devices.sqlite3"
64
+ )
65
+
66
+ # Match only, never write (no enrollment, no refresh, no last_seen update):
67
+ device_id = altifyhwid.get_id(read_only=True, create=False)
68
+ ```
69
+
70
+ `identify()` accepts `create=False` (match only), `refresh=False` (do not
71
+ store newly seen hardware on a match), and `read_only=True` (open the
72
+ database read-only; implies both). `IdentityResult.stored_kinds` lists the
73
+ identifier kinds written to the registry by that call.
74
+
75
+ ## Command line
76
+
77
+ ```shell
78
+ python -m altifyhwid --json
79
+ python -m altifyhwid scan
80
+ python -m altifyhwid scan --raw
81
+ python -m altifyhwid --no-tpm --no-monitors
82
+ python -m altifyhwid list
83
+ python -m altifyhwid merge KEEP_ID ABSORB_ID
84
+ python -m altifyhwid delete DEVICE_ID
85
+ python -m altifyhwid -v
86
+ altifyhwid --json
87
+ ```
88
+
89
+ `scan` reports categories, counts, and collection warnings without saving an
90
+ identity. `--raw` also prints hardware identifiers and diagnostic context.
91
+ Programmatic diagnostic context is available with
92
+ `collect(include_diagnostics=True)`. TPM is queried by default, but a missing
93
+ or inaccessible endorsement public key contributes no match evidence.
94
+ `--no-refresh`, `--no-create`, and `--read-only` mirror the API flags.
95
+ `-v` logs collector timings, PowerShell exit codes with a stderr tail, and
96
+ registry activity to stderr. Exit code 0 is success, 2 is a reported error
97
+ (JSON on stderr), 1 is an unexpected error. Console entry point
98
+ `altifyhwid` requires the Scripts directory of the environment on PATH; the
99
+ `python -m altifyhwid` form works without that extra PATH requirement.
100
+
101
+ ## Matching
102
+
103
+ The categories are firmware, storage, RAM, network, display, TPM public key,
104
+ and CPU serial where available. Related SMBIOS fields together count as one
105
+ firmware vote. Multiple disks or RAM modules do not multiply category votes.
106
+ CPU/GPU models and ordinary CPU ProcessorId values do not count as unique IDs.
107
+
108
+ A new enrollment gets a random UUID. Later scans reuse it when at least three
109
+ categories match that enrollment. Use `min_matches=4` or higher to require
110
+ more evidence.
111
+
112
+ On a match the registry also stores identifiers it has not seen before for
113
+ that device (a replaced disk, new RAM, a new network adapter), so gradual
114
+ hardware changes keep the same ID as long as each scan still matches three
115
+ categories. Old identifiers are kept, so a reverted swap still matches. Each
116
+ device keeps at most 128 identifiers per kind; beyond that the oldest ones not
117
+ present in the current scan are dropped. Pass `refresh=False` to disable this.
118
+
119
+ Insufficient usable categories raise `InsufficientHardwareError`. `create=False`
120
+ requires an existing match and otherwise raises `NoMatchError`.
121
+
122
+ Multiple qualifying records raise `AmbiguousMatchError`; `.candidates` lists
123
+ the two strongest, best first, each with `match_count`. Resolve it once with
124
+ the registry tools below (usually `merge` the weaker into the stronger).
125
+
126
+ ## Registry maintenance
127
+
128
+ ```python
129
+ from altifyhwid import Registry
130
+
131
+ registry = Registry() # same defaults as identify()
132
+ registry.list_devices() # dicts with device_id, created_at,
133
+ # last_seen, token_count, groups, kinds
134
+ registry.list_devices(all_namespaces=True)
135
+ registry.merge("ALTIFY-KEEP...", "ALTIFY-DUPLICATE...")
136
+ registry.delete("ALTIFY-OLD...")
137
+ registry.backup(r"D:\backups\devices.sqlite3")
138
+ ```
139
+
140
+ `merge` moves the identifiers of the absorbed enrollments onto the kept one and
141
+ removes the absorbed records. Both must be in the namespace of the registry.
142
+ `delete` and `merge` refuse a `read_only=True` registry.
143
+
144
+ ## Integrity, backups, and schema
145
+
146
+ Each registry path is checked with `PRAGMA quick_check` once per process. A
147
+ corrupt or non-SQLite file raises `RegistryError` naming the newest backup.
148
+
149
+ After any change to enrollments (a new device, stored identifiers, merge,
150
+ delete) the registry writes `devices.sqlite3.bak1` next to the database, at
151
+ most once per `backup_interval` seconds (default one day), and rotates older
152
+ copies to `.bak2` and so on up to `backup_copies` (default 2; 0 disables).
153
+ Backup failures are logged and never fail the identity call. To restore, stop
154
+ the application and copy `.bak1` over `devices.sqlite3`.
155
+
156
+ The database schema is version 2. A schema 1 registry (created by 1.0.x) is
157
+ migrated automatically on the first writable open; a `read_only=True` open
158
+ refuses it until then. A registry written by a newer module version is refused
159
+ rather than modified.
160
+
161
+ Only HMAC tokens are persisted, but the key is in the same database: this is
162
+ pseudonymization, not encryption. No data is uploaded by this module. Collection
163
+ uses read-only Windows queries, bounded worker threads, and timeouts. TPM and
164
+ some provider queries can be unavailable under ordinary user permissions.
165
+
166
+ ## Logging
167
+
168
+ The module logs to `logging.getLogger("altifyhwid")` and stays silent unless
169
+ the application configures logging. `WARNING` covers failed collector batches
170
+ (exit code, duration, last 2 KiB of PowerShell stderr), ambiguous matches,
171
+ rollback problems, and failed automatic backups. `INFO` covers enrollments,
172
+ migrations, backups, and scan summaries. `DEBUG` covers per-batch timings and
173
+ match details. Hardware identifiers are never logged.
174
+
175
+ ## Scope
176
+
177
+ Hardware collection requires Windows. Registry matching can run elsewhere with
178
+ an explicitly supplied `HardwareSnapshot`. Separate registry databases produce
179
+ separate IDs; installing this package does not create a shared identity service.
180
+ For multiple clients, use a trusted central registry through your own
181
+ authenticated transport. Client-supplied hardware values are not attestation.
182
+
183
+ This is heuristic recognition, not a guarantee of uniqueness or resistance to
184
+ spoofing. Shared components, OEM duplicates, cloned VMs, and extensive hardware
185
+ changes can produce incorrect matches or new IDs. Storing newly seen hardware
186
+ on a match also means a wrong match teaches the wrong record; raise
187
+ `min_matches` or set `refresh=False` where that matters. Proprietary kernel
188
+ anti-cheat internals are not implemented.
189
+
190
+ ## Resource limits
191
+
192
+ Collection uses at most four concurrent workers and an overall deadline. One
193
+ scan runs at a time per process; a caller waits up to `timeout` seconds for an
194
+ active scan, then gets the cached result when the same options were scanned,
195
+ or runs its own scan with a fresh deadline. Cache reads and `clear_cache()`
196
+ never wait behind a scan. The scan cache holds at most eight immutable
197
+ snapshots. No workers run between calls. Provider results are limited to 128
198
+ rows and Python reads at most 2 MiB of output per collector batch. SQLite
199
+ connections and temporary output files close when each operation completes.
200
+
201
+ The registry uses SQLite WAL mode, so readers never block the single writer.
202
+ Opening an existing registry takes no write lock; only enrollment, refresh,
203
+ merge, delete, and migration do. Backup has a deadline and removes incomplete
204
+ output on failure. Matching uses an index and retrieves at most two qualifying
205
+ records to detect ambiguity.
206
+
207
+ ## Tests
208
+
209
+ ```shell
210
+ py -m unittest discover -s tests -v
211
+ ```
212
+
213
+ The suite covers normalization, snapshot building from recorded provider
214
+ output, enrollment and matching, refresh and its cap, ambiguity and merge,
215
+ read-only mode, schema migration, integrity failures, backups and rotation,
216
+ collector caching and locking, PowerShell failure handling, and the command
217
+ line. It runs without hardware access. `ALTIFY_LIVE=1` adds one live
218
+ collection on the current Windows PC.
@@ -0,0 +1,6 @@
1
+ altifyhwid.py,sha256=qIae8omdkuD84AjPqoimexltL7Ot26Ud60d609tWqrs,44952
2
+ altifyhwid-1.1.0.dist-info/METADATA,sha256=zGcW_dzj-2ApAl-uwB6LpktVXyjmcIRSpgNGpO15fjQ,9408
3
+ altifyhwid-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ altifyhwid-1.1.0.dist-info/entry_points.txt,sha256=sPSjY8P4GoX_fmbcavqxLS_SiR-OEP4F7w5ScVCGTPk,48
5
+ altifyhwid-1.1.0.dist-info/top_level.txt,sha256=oWe5IZM2QrUrqlmQIMoxigdpC4RjzZneaWWP8x24bDs,11
6
+ altifyhwid-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ altifyhwid = altifyhwid:_main
@@ -0,0 +1 @@
1
+ altifyhwid
altifyhwid.py ADDED
@@ -0,0 +1,714 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import base64
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from collections import OrderedDict
6
+ from contextlib import closing, contextmanager
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ import hmac
10
+ import json
11
+ import logging
12
+ import math
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import secrets
17
+ import sqlite3
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from threading import Lock
22
+ from time import monotonic, time
23
+ from types import MappingProxyType
24
+ from typing import Any, Mapping
25
+ import unicodedata
26
+ import uuid
27
+ __version__ = '1.1.0'
28
+ __all__ = ['get_id', 'hwid', 'identify', 'collect', 'clear_cache', 'Registry', 'HardwareSnapshot', 'IdentityResult', 'AltifyError', 'CollectionError', 'RegistryError', 'InsufficientHardwareError', 'AmbiguousMatchError', 'NoMatchError']
29
+ _LOG = logging.getLogger('altifyhwid')
30
+ _LOG.addHandler(logging.NullHandler())
31
+ _KINDS = {'system_uuid': 'firmware', 'system_serial': 'firmware', 'baseboard_serial': 'firmware', 'bios_serial': 'firmware', 'chassis_serial': 'firmware', 'disk_serial': 'storage', 'disk_unique_id': 'storage', 'ram_serial': 'memory', 'nic_permanent_mac': 'network', 'nic_mac': 'network', 'tpm_ek_sha256': 'tpm', 'monitor_serial': 'display', 'cpu_serial': 'cpu'}
32
+ _PLACEHOLDERS = {'UNKNOWN', 'NONE', 'NULL', 'NA', 'NOTAPPLICABLE', 'NOTAVAILABLE', 'NOTSPECIFIED', 'UNSPECIFIED', 'INVALID', 'DEFAULT', 'DEFAULTSTRING', 'SYSTEMSERIALNUMBER', 'SERIALNUMBER', 'CHASSISSERIALNUMBER', 'BASEBOARDSERIALNUMBER', 'OEM', '0123456789', '123456789', '1234567890', '0123456789ABCDEF', '12345678', 'SYSTEMUUID', 'NOASSETTAG'}
33
+ _SCHEMA = 2
34
+ _MAX_OUTPUT = 2 * 1024 * 1024
35
+ _STDERR_TAIL = 2048
36
+ _TOKEN_CAP = 128
37
+ _CACHE = OrderedDict()
38
+ _CACHE_LOCK = Lock()
39
+ _SCAN_LOCK = Lock()
40
+ _CHECKED = OrderedDict()
41
+ _CHECKED_LOCK = Lock()
42
+ _GROUP_SQL = 'CASE t.kind ' + ' '.join((f"WHEN '{kind}' THEN '{group}'" for kind, group in _KINDS.items())) + ' END'
43
+ _MATCH_SQL = 'SELECT t.device_id,GROUP_CONCAT(DISTINCT t.kind),COUNT(DISTINCT ' + _GROUP_SQL + ') AS n FROM incoming i CROSS JOIN tokens t INDEXED BY tokens_lookup JOIN devices d ON d.device_id=t.device_id WHERE t.kind=i.kind AND t.digest=i.digest AND d.namespace=? GROUP BY t.device_id HAVING n>=? ORDER BY n DESC,t.device_id LIMIT 2'
44
+ _LIST_SQL = 'SELECT d.device_id,d.namespace,d.created_at,d.last_seen,COUNT(t.digest),GROUP_CONCAT(DISTINCT t.kind) FROM devices d LEFT JOIN tokens t ON t.device_id=d.device_id {where} GROUP BY d.device_id ORDER BY d.created_at,d.rowid'
45
+ class AltifyError(RuntimeError):
46
+ pass
47
+ class CollectionError(AltifyError):
48
+ pass
49
+ class RegistryError(AltifyError):
50
+ pass
51
+ class NoMatchError(AltifyError):
52
+ pass
53
+ class InsufficientHardwareError(AltifyError):
54
+ def __init__(self, groups: tuple[str, ...], minimum: int, warnings=()):
55
+ self.groups, self.minimum, self.warnings = (groups, minimum, tuple(warnings))
56
+ super().__init__(f"Need {minimum} hardware groups; found {len(groups)} ({', '.join(groups) or 'none'}). Run: py -m altifyhwid scan")
57
+ class AmbiguousMatchError(AltifyError):
58
+ def __init__(self, candidates: tuple[dict[str, Any], ...]):
59
+ self.candidates = candidates
60
+ super().__init__('Multiple records meet the threshold. No record selected; .candidates lists the two strongest. Run: py -m altifyhwid list, then merge or delete the duplicates')
61
+ def _default_registry_path() -> Path:
62
+ override = os.environ.get('ALTIFY_HWID_DB')
63
+ if override:
64
+ return Path(override).expanduser()
65
+ if sys.platform == 'win32':
66
+ base = Path(os.environ.get('LOCALAPPDATA') or Path.home() / 'AppData' / 'Local')
67
+ elif sys.platform == 'darwin':
68
+ base = Path.home() / 'Library' / 'Application Support'
69
+ else:
70
+ base = Path(os.environ.get('XDG_DATA_HOME') or Path.home() / '.local' / 'share')
71
+ return base / 'Altify' / 'HWID' / 'devices.sqlite3'
72
+ def _now() -> str:
73
+ return datetime.now(timezone.utc).isoformat(timespec='seconds')
74
+ def _normalize(kind: str, value: Any) -> str | None:
75
+ if not isinstance(value, str) or len(value) > 1024:
76
+ return None
77
+ value = re.sub('\\s+', '', unicodedata.normalize('NFKC', value).upper().strip('\x00 '))
78
+ compact = re.sub('[^A-Z0-9]', '', value)
79
+ if len(compact) < 4 or compact in _PLACEHOLDERS or len(set(compact)) == 1 or any((s in compact for s in ('TOBEFILLEDBY', 'NOTSPECIFIED', 'DEFAULTSTRING'))):
80
+ return None
81
+ if kind == 'system_uuid':
82
+ try:
83
+ parsed = uuid.UUID(value)
84
+ except ValueError:
85
+ return None
86
+ if parsed.int in (0, (1 << 128) - 1):
87
+ return None
88
+ return str(parsed).upper()
89
+ if kind in ('nic_mac', 'nic_permanent_mac'):
90
+ mac = re.sub('[:-]', '', value)
91
+ if not re.fullmatch('[0-9A-F]{12}', mac) or int(mac[:2], 16) & 3:
92
+ return None
93
+ return mac
94
+ if kind == 'tpm_ek_sha256':
95
+ value = re.sub('[:-]', '', value)
96
+ if not re.fullmatch('[0-9A-F]{64}', value):
97
+ return None
98
+ return value
99
+ def _freeze(value):
100
+ if isinstance(value, Mapping):
101
+ return MappingProxyType({k: _freeze(v) for k, v in value.items()})
102
+ if isinstance(value, (list, tuple)):
103
+ return tuple(map(_freeze, value))
104
+ return value
105
+ def _plain(value):
106
+ if isinstance(value, Mapping):
107
+ return {k: _plain(v) for k, v in value.items()}
108
+ if isinstance(value, tuple):
109
+ return list(map(_plain, value))
110
+ return value
111
+ @dataclass(frozen=True, slots=True)
112
+ class HardwareSnapshot:
113
+ signals: Mapping[str, tuple[str, ...]]
114
+ diagnostics: Mapping[str, Any] = field(default_factory=dict)
115
+ warnings: tuple[str, ...] = ()
116
+ collected_at: str = field(default_factory=_now)
117
+ _groups: tuple[str, ...] = field(init=False, repr=False)
118
+ def __post_init__(self):
119
+ if not isinstance(self.signals, Mapping) or set(self.signals) - _KINDS.keys():
120
+ raise ValueError('signals must contain supported identifier kinds')
121
+ cleaned, total = ({}, 0)
122
+ for kind, values in self.signals.items():
123
+ if isinstance(values, str):
124
+ values = (values,)
125
+ if not isinstance(values, (tuple, list, set)) or len(values) > 128:
126
+ raise ValueError(f'{kind}: supply at most 128 strings')
127
+ total += len(values)
128
+ if total > 512 or any((not isinstance(v, str) for v in values)):
129
+ raise ValueError('Snapshot permits at most 512 string identifiers')
130
+ values = tuple(sorted({v for raw in values if (v := _normalize(kind, raw))}))
131
+ if values:
132
+ cleaned[kind] = values
133
+ object.__setattr__(self, 'signals', MappingProxyType(cleaned))
134
+ object.__setattr__(self, 'diagnostics', _freeze(self.diagnostics))
135
+ object.__setattr__(self, 'warnings', tuple(self.warnings))
136
+ object.__setattr__(self, '_groups', tuple(sorted({_KINDS[k] for k in cleaned})))
137
+ @classmethod
138
+ def from_signals(cls, signals: Mapping[str, Any], **kwargs) -> HardwareSnapshot:
139
+ return cls(signals, **kwargs)
140
+ @classmethod
141
+ def from_dict(cls, payload: Mapping[str, Any]) -> HardwareSnapshot:
142
+ if not isinstance(payload, Mapping) or payload.get('format') != 'altify-hwid-v1':
143
+ raise ValueError('Expected altify-hwid-v1 snapshot')
144
+ if payload.get('redacted') is not False or 'signals' not in payload:
145
+ raise ValueError('Snapshot is redacted; export with to_dict(raw=True)')
146
+ return cls(payload['signals'])
147
+ @property
148
+ def groups(self) -> tuple[str, ...]:
149
+ return self._groups
150
+ def to_dict(self, *, raw: bool=False) -> dict[str, Any]:
151
+ result = {'format': 'altify-hwid-v1', 'redacted': not raw, 'collected_at': self.collected_at, 'groups': list(self.groups), 'counts': {k: len(v) for k, v in self.signals.items()}, 'warnings': list(self.warnings)}
152
+ if raw:
153
+ result['signals'] = {k: list(v) for k, v in self.signals.items()}
154
+ result['diagnostics'] = _plain(self.diagnostics)
155
+ return result
156
+ @dataclass(frozen=True, slots=True)
157
+ class IdentityResult:
158
+ device_id: str
159
+ created: bool
160
+ matched_groups: tuple[str, ...]
161
+ matched_kinds: tuple[str, ...]
162
+ available_groups: tuple[str, ...]
163
+ warnings: tuple[str, ...]
164
+ stored_kinds: tuple[str, ...] = ()
165
+ @property
166
+ def match_count(self) -> int:
167
+ return len(self.matched_groups)
168
+ def to_dict(self) -> dict[str, Any]:
169
+ return {'device_id': self.device_id, 'created': self.created, 'match_count': self.match_count, 'matched_groups': list(self.matched_groups), 'matched_kinds': list(self.matched_kinds), 'available_groups': list(self.available_groups), 'stored_kinds': list(self.stored_kinds), 'warnings': list(self.warnings)}
170
+ _PS_HEADER = '\n$ErrorActionPreference = \'Stop\'\n$ProgressPreference = \'SilentlyContinue\'\n$WarningPreference = \'SilentlyContinue\'\n[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)\n$OutputEncoding = [Console]::OutputEncoding\n$out = [ordered]@{data=@{}; errors=@()}\nfunction Read-Cim($key, $class, $properties, $namespace=\'root/cimv2\') {\n try {\n $rows = @(Get-CimInstance -ClassName $class -Namespace $namespace `\n -Property $properties -OperationTimeoutSec 5 -ErrorAction Stop |\n Select-Object -First 129 -Property $properties)\n if ($rows.Count -gt 128) { throw \'Provider row limit exceeded\' }\n $out.data[$key] = $rows\n } catch {\n $rows = @()\n $out.errors += "$key unavailable ($($_.Exception.GetType().Name))"\n }\n}\n'
171
+ _BATCHES = {'firmware': "\nRead-Cim 'system' 'Win32_ComputerSystemProduct' @('UUID','IdentifyingNumber','Vendor','Name')\nRead-Cim 'board' 'Win32_BaseBoard' @('SerialNumber','Manufacturer','Product')\nRead-Cim 'bios' 'Win32_BIOS' @('SerialNumber','Manufacturer','SMBIOSBIOSVersion')\nRead-Cim 'chassis' 'Win32_SystemEnclosure' @('SerialNumber')\nRead-Cim 'cpu' 'Win32_Processor' @('SerialNumber','ProcessorId','Name','Manufacturer')\n", 'storage': "\nRead-Cim 'physical_disks' 'MSFT_PhysicalDisk' @('SerialNumber','UniqueId','UniqueIdFormat','BusType','FriendlyName') 'root/Microsoft/Windows/Storage'\nRead-Cim 'disks' 'Win32_DiskDrive' @('SerialNumber','InterfaceType','Model','PNPDeviceID')\n", 'memory_network': "\nRead-Cim 'memory' 'Win32_PhysicalMemory' @('SerialNumber','Manufacturer','PartNumber','Capacity')\nRead-Cim 'adapters' 'MSFT_NetAdapter' @('PermanentAddress','HardwareInterface','Virtual','InterfaceDescription','PNPDeviceID') 'root/StandardCimv2'\nRead-Cim 'legacy_adapters' 'Win32_NetworkAdapter' @('MACAddress','PhysicalAdapter','Name','PNPDeviceID')\n", 'context': "\nRead-Cim 'gpu' 'Win32_VideoController' @('Name','PNPDeviceID','DriverVersion')\ntry {\n $out.data['windows'] = @{MachineGuid=(Get-ItemProperty -LiteralPath 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid}\n} catch { $out.errors += 'Windows installation context unavailable' }\n", 'monitors': "\nRead-Cim 'monitors' 'WmiMonitorID' @('ManufacturerName','ProductCodeID','SerialNumberID','Active') 'root/wmi'\n", 'tpm': '\ntry {\n $ek = Get-TpmEndorsementKeyInfo -HashAlgorithm Sha256 -ErrorAction Stop\n if ($ek.IsPresent -and $ek.PublicKeyHash) {\n $out.data[\'tpm\'] = @{PublicKeyHash=[string]$ek.PublicKeyHash}\n } else { $out.errors += \'TPM endorsement public key unavailable\' }\n} catch { $out.errors += "TPM public key unavailable ($($_.Exception.GetType().Name)); try an elevated host if needed" }\n'}
172
+ def _powershell_exe() -> str:
173
+ root = Path(os.environ.get('SystemRoot', 'C:\\Windows'))
174
+ for system in ('Sysnative', 'System32'):
175
+ exe = root / system / 'WindowsPowerShell' / 'v1.0' / 'powershell.exe'
176
+ if exe.is_file():
177
+ return str(exe)
178
+ raise CollectionError('Windows PowerShell was not found in the Windows directory')
179
+ def _tail(handle) -> str:
180
+ handle.seek(0, os.SEEK_END)
181
+ handle.seek(max(0, handle.tell() - _STDERR_TAIL))
182
+ return handle.read().decode('utf-8', 'replace').strip()
183
+ def _run_batch(exe: str, name: str, timeout: float) -> dict[str, Any]:
184
+ if timeout <= 0:
185
+ _LOG.warning('%s: collection deadline reached before the batch started', name)
186
+ return {'errors': [f'{name}: collection deadline reached']}
187
+ script = _PS_HEADER + _BATCHES[name] + '\n$out | ConvertTo-Json -Depth 8 -Compress\n'
188
+ encoded = base64.b64encode(script.encode('utf-16-le')).decode('ascii')
189
+ started = monotonic()
190
+ try:
191
+ with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors:
192
+ proc = subprocess.run([exe, '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], stdin=subprocess.DEVNULL, stdout=output, stderr=errors, timeout=timeout, check=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
193
+ elapsed = monotonic() - started
194
+ if proc.returncode:
195
+ _LOG.warning('%s: PowerShell exited with code %d after %.1fs: %s', name, proc.returncode, elapsed, _tail(errors) or 'no stderr')
196
+ return {'errors': [f'{name}: PowerShell exited with code {proc.returncode}']}
197
+ output.seek(0, os.SEEK_END)
198
+ if output.tell() > _MAX_OUTPUT:
199
+ raise ValueError('Collector output limit exceeded')
200
+ output.seek(0)
201
+ raw = output.read(_MAX_OUTPUT + 1)
202
+ if len(raw) > _MAX_OUTPUT:
203
+ raise ValueError('Collector output limit exceeded')
204
+ result = json.loads(raw.decode('utf-8-sig'))
205
+ if not isinstance(result, dict) or not isinstance(result.get('data'), dict):
206
+ raise ValueError('Invalid collector response')
207
+ _LOG.debug('%s: collected %d bytes in %.1fs with %d provider errors', name, len(raw), elapsed, len(result.get('errors') or []))
208
+ return result
209
+ except subprocess.TimeoutExpired:
210
+ _LOG.warning('%s: collection timed out after %.1fs', name, timeout)
211
+ return {'errors': [f'{name}: collection timed out']}
212
+ except (OSError, UnicodeError, ValueError) as exc:
213
+ _LOG.warning('%s: collection failed after %.1fs (%s: %s)', name, monotonic() - started, type(exc).__name__, exc)
214
+ return {'errors': [f'{name}: collection failed ({type(exc).__name__})']}
215
+ def _rows(data: Mapping[str, Any], key: str) -> list[dict[str, Any]]:
216
+ value = data.get(key, [])
217
+ return [value] if isinstance(value, dict) else [r for r in value or [] if isinstance(r, dict)]
218
+ def _edid_text(value: Any) -> str:
219
+ if isinstance(value, str):
220
+ return value.strip('\x00 ')
221
+ if not isinstance(value, list):
222
+ return ''
223
+ return ''.join((chr(n) for n in value if isinstance(n, int) and 32 <= n < 127)).strip()
224
+ def _looks_virtual(value: Any) -> bool:
225
+ text = str(value or '').upper()
226
+ return any((word in text for word in ('VIRTUAL', 'VMWARE', 'VBOX', 'VIRTIO', 'QEMU', 'XEN ', 'HYPER-V', 'TAP-WINDOWS', 'TUNNEL')))
227
+ def _snapshot_from_data(data: Mapping[str, Any], warnings=(), include_diagnostics=False) -> HardwareSnapshot:
228
+ signals: dict[str, list[str]] = {}
229
+ def add(kind, value):
230
+ normalized = _normalize(kind, value)
231
+ if normalized:
232
+ signals.setdefault(kind, []).append(normalized)
233
+ for section, prop, kind in (('system', 'UUID', 'system_uuid'), ('system', 'IdentifyingNumber', 'system_serial'), ('board', 'SerialNumber', 'baseboard_serial'), ('bios', 'SerialNumber', 'bios_serial'), ('chassis', 'SerialNumber', 'chassis_serial'), ('cpu', 'SerialNumber', 'cpu_serial'), ('memory', 'SerialNumber', 'ram_serial'), ('tpm', 'PublicKeyHash', 'tpm_ek_sha256')):
234
+ for row in _rows(data, section):
235
+ add(kind, row.get(prop))
236
+ for row in _rows(data, 'physical_disks'):
237
+ if row.get('BusType') not in (1, 2, 3, 4, 6, 8, 10, 11, 17, 18):
238
+ continue
239
+ if _looks_virtual(row.get('FriendlyName')):
240
+ continue
241
+ add('disk_serial', row.get('SerialNumber'))
242
+ if row.get('UniqueIdFormat') in (2, 3, 8):
243
+ add('disk_unique_id', row.get('UniqueId'))
244
+ for row in _rows(data, 'disks'):
245
+ pnp = str(row.get('PNPDeviceID') or '').upper()
246
+ if str(row.get('InterfaceType') or '').upper() == 'USB' or pnp.startswith('USB'):
247
+ continue
248
+ if not _looks_virtual(row.get('Model')):
249
+ add('disk_serial', row.get('SerialNumber'))
250
+ permanent_pnp = set()
251
+ for row in _rows(data, 'adapters'):
252
+ if row.get('HardwareInterface') is not True or row.get('Virtual') is True:
253
+ continue
254
+ if _looks_virtual(row.get('InterfaceDescription')):
255
+ continue
256
+ value = _normalize('nic_permanent_mac', row.get('PermanentAddress'))
257
+ if value:
258
+ add('nic_permanent_mac', value)
259
+ permanent_pnp.add(str(row.get('PNPDeviceID') or '').upper())
260
+ for row in _rows(data, 'legacy_adapters'):
261
+ pnp = str(row.get('PNPDeviceID') or '').upper()
262
+ if row.get('PhysicalAdapter') is not True or _looks_virtual(row.get('Name')) or (not pnp.startswith(('PCI\\', 'USB\\'))):
263
+ continue
264
+ if pnp not in permanent_pnp:
265
+ add('nic_permanent_mac', row.get('MACAddress'))
266
+ for row in _rows(data, 'monitors'):
267
+ if row.get('Active') is not True:
268
+ continue
269
+ serial = _edid_text(row.get('SerialNumberID'))
270
+ maker = _edid_text(row.get('ManufacturerName'))
271
+ model = _edid_text(row.get('ProductCodeID'))
272
+ if _normalize('monitor_serial', serial) and maker and model:
273
+ add('monitor_serial', f'{maker}|{model}|{serial}')
274
+ return HardwareSnapshot.from_signals(signals, diagnostics=data if include_diagnostics else {}, warnings=tuple(warnings))
275
+ def clear_cache() -> None:
276
+ with _CACHE_LOCK:
277
+ _CACHE.clear()
278
+ def _cache_get(key, cache_ttl: float) -> HardwareSnapshot | None:
279
+ with _CACHE_LOCK:
280
+ cached = _CACHE.get(key)
281
+ if cached and cache_ttl and monotonic() - cached[0] < cache_ttl:
282
+ _CACHE.move_to_end(key)
283
+ return cached[1]
284
+ _CACHE.pop(key, None)
285
+ return None
286
+ def collect(*, include_tpm: bool=True, include_monitors: bool=True, include_diagnostics: bool=False, timeout: float=35.0, cache_ttl: float=60.0) -> HardwareSnapshot:
287
+ if sys.platform != 'win32':
288
+ raise CollectionError('Live hardware collection requires Windows 10/11. Registry works cross-platform.')
289
+ if type(timeout) not in (float, int) or not math.isfinite(timeout) or (not 1 <= timeout <= 300):
290
+ raise ValueError('timeout must be between 1 and 300 seconds')
291
+ if type(cache_ttl) not in (float, int) or not math.isfinite(cache_ttl) or (not 0 <= cache_ttl <= 3600):
292
+ raise ValueError('cache_ttl must be between 0 and 3600 seconds')
293
+ key = (bool(include_tpm), bool(include_monitors), bool(include_diagnostics))
294
+ cached = _cache_get(key, cache_ttl)
295
+ if cached is not None:
296
+ return cached
297
+ if not _SCAN_LOCK.acquire(timeout=timeout):
298
+ raise CollectionError('Timed out waiting for an active hardware scan')
299
+ try:
300
+ cached = _cache_get(key, cache_ttl)
301
+ if cached is not None:
302
+ return cached
303
+ exe = _powershell_exe()
304
+ names = ['firmware', 'storage', 'memory_network']
305
+ if include_diagnostics:
306
+ names.append('context')
307
+ if include_monitors:
308
+ names.append('monitors')
309
+ if include_tpm:
310
+ names.append('tpm')
311
+ deadline = monotonic() + timeout
312
+ _LOG.debug('Scanning batches %s with a %.0fs deadline', ','.join(names), timeout)
313
+ data, warnings = ({}, [])
314
+ def run(name):
315
+ return _run_batch(exe, name, deadline - monotonic())
316
+ with ThreadPoolExecutor(max_workers=4, thread_name_prefix='AltifyHWID') as pool:
317
+ jobs = {pool.submit(run, name) for name in names}
318
+ for job in as_completed(jobs):
319
+ jobs.remove(job)
320
+ result = job.result()
321
+ data.update(result.get('data', {}))
322
+ warnings.extend(result.get('errors', []))
323
+ snapshot = _snapshot_from_data(data, warnings, include_diagnostics)
324
+ _LOG.info('Collected %d hardware groups (%s) with %d warnings in %.1fs', len(snapshot.groups), ','.join(snapshot.groups) or 'none', len(warnings), timeout - (deadline - monotonic()))
325
+ if len(snapshot.groups) >= 3:
326
+ with _CACHE_LOCK:
327
+ _CACHE[key] = (monotonic(), snapshot)
328
+ while len(_CACHE) > 8:
329
+ _CACHE.popitem(last=False)
330
+ return snapshot
331
+ finally:
332
+ _SCAN_LOCK.release()
333
+ def _migrate_1(con: sqlite3.Connection) -> None:
334
+ con.execute("ALTER TABLE tokens ADD COLUMN added_at TEXT NOT NULL DEFAULT ''")
335
+ con.execute("UPDATE tokens SET added_at=(SELECT created_at FROM devices WHERE devices.device_id=tokens.device_id) WHERE added_at=''")
336
+ _MIGRATIONS = {1: _migrate_1}
337
+ def _rollback(con: sqlite3.Connection | None) -> None:
338
+ if con is not None and con.in_transaction:
339
+ try:
340
+ con.rollback()
341
+ except sqlite3.Error as exc:
342
+ _LOG.warning('Rollback failed: %s', exc)
343
+ class Registry:
344
+ __slots__ = ('namespace', 'min_matches', 'path', 'read_only', 'backup_copies', 'backup_interval', '_key')
345
+ def __init__(self, db_path: str | os.PathLike | None=None, *, namespace: str='default', min_matches: int=3, read_only: bool=False, backup_copies: int=2, backup_interval: float=86400.0):
346
+ if type(min_matches) is not int or not 3 <= min_matches <= len(set(_KINDS.values())):
347
+ raise ValueError('min_matches must be an integer between 3 and 7')
348
+ if not isinstance(namespace, str) or not namespace.strip() or len(namespace) > 128:
349
+ raise ValueError('namespace must contain 1 to 128 characters')
350
+ if type(backup_copies) is not int or not 0 <= backup_copies <= 16:
351
+ raise ValueError('backup_copies must be an integer between 0 and 16')
352
+ if type(backup_interval) not in (float, int) or not math.isfinite(backup_interval) or backup_interval < 0:
353
+ raise ValueError('backup_interval must be zero or more seconds')
354
+ self.namespace, self.min_matches, self.read_only = (namespace, min_matches, bool(read_only))
355
+ self.backup_copies, self.backup_interval = (backup_copies, backup_interval)
356
+ self.path = (Path(db_path).expanduser() if db_path is not None else _default_registry_path()).resolve()
357
+ self._key = None
358
+ try:
359
+ if self.read_only:
360
+ if not self.path.is_file():
361
+ raise RegistryError(f'Registry {self.path} does not exist; a read-only registry cannot be created')
362
+ else:
363
+ self.path.parent.mkdir(parents=True, exist_ok=True)
364
+ self._check_integrity()
365
+ with self._transaction(write=False) as con:
366
+ key = self._load(con)
367
+ if key is None:
368
+ if self.read_only:
369
+ raise RegistryError(f'Registry {self.path} needs initialization or a schema migration; open it without read_only first')
370
+ with self._transaction() as con:
371
+ key = self._load(con) or self._setup(con)
372
+ self._key = key
373
+ except OSError as exc:
374
+ raise RegistryError(f'Cannot access registry {self.path}: {exc}') from exc
375
+ def _connect(self) -> sqlite3.Connection:
376
+ if self.read_only:
377
+ con = sqlite3.connect(self.path.as_uri() + '?mode=ro', uri=True, timeout=30, isolation_level=None)
378
+ else:
379
+ con = sqlite3.connect(str(self.path), timeout=30, isolation_level=None)
380
+ try:
381
+ if not self.read_only:
382
+ try:
383
+ mode = con.execute('PRAGMA journal_mode=WAL').fetchone()[0]
384
+ if str(mode).lower() != 'wal':
385
+ _LOG.debug('Registry %s stays in journal mode %s', self.path, mode)
386
+ except sqlite3.OperationalError as exc:
387
+ _LOG.debug('Could not enable WAL on %s: %s', self.path, exc)
388
+ con.execute('PRAGMA foreign_keys=ON')
389
+ con.execute('PRAGMA synchronous=FULL')
390
+ except BaseException:
391
+ con.close()
392
+ raise
393
+ return con
394
+ @contextmanager
395
+ def _transaction(self, write: bool=True):
396
+ con = None
397
+ try:
398
+ con = self._connect()
399
+ con.execute('BEGIN IMMEDIATE' if write and not self.read_only else 'BEGIN')
400
+ yield con
401
+ con.execute('COMMIT')
402
+ except sqlite3.Error as exc:
403
+ _rollback(con)
404
+ raise RegistryError(f'Registry database error: {exc}') from exc
405
+ except BaseException:
406
+ _rollback(con)
407
+ raise
408
+ finally:
409
+ if con is not None:
410
+ con.close()
411
+ def _check_integrity(self) -> None:
412
+ with _CHECKED_LOCK:
413
+ if self.path in _CHECKED:
414
+ _CHECKED.move_to_end(self.path)
415
+ return
416
+ if not self.path.is_file() or self.path.stat().st_size == 0:
417
+ return
418
+ try:
419
+ with closing(self._connect()) as con:
420
+ row = con.execute('PRAGMA quick_check(1)').fetchone()
421
+ except sqlite3.Error as exc:
422
+ raise RegistryError(f'Registry {self.path} is unreadable ({exc}); restore it from {self._backup_path(1)}') from exc
423
+ if not row or row[0] != 'ok':
424
+ raise RegistryError(f"Registry {self.path} failed its integrity check ({row[0] if row else 'no result'}); restore it from {self._backup_path(1)}")
425
+ with _CHECKED_LOCK:
426
+ _CHECKED[self.path] = True
427
+ while len(_CHECKED) > 64:
428
+ _CHECKED.popitem(last=False)
429
+ @staticmethod
430
+ def _tables(con: sqlite3.Connection) -> set[str]:
431
+ return {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
432
+ @staticmethod
433
+ def _version(text: Any) -> int:
434
+ if not isinstance(text, str) or not text.isdigit():
435
+ raise RegistryError('Unsupported registry schema; preserve the database and use its original API version')
436
+ version = int(text)
437
+ if version > _SCHEMA:
438
+ raise RegistryError(f'Registry schema {version} is newer than this module supports ({_SCHEMA}); upgrade altifyhwid')
439
+ return version
440
+ @staticmethod
441
+ def _read_key(con: sqlite3.Connection) -> bytes:
442
+ key = con.execute("SELECT value FROM meta WHERE key='hmac_key'").fetchone()
443
+ if not key or not isinstance(key[0], str) or not re.fullmatch('[0-9a-f]{64}', key[0]):
444
+ raise RegistryError('Registry HMAC key is missing/corrupt; restore a complete backup')
445
+ return bytes.fromhex(key[0])
446
+ def _load(self, con: sqlite3.Connection) -> bytes | None:
447
+ tables = self._tables(con)
448
+ if 'meta' not in tables:
449
+ return None
450
+ row = con.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
451
+ if not row or self._version(row[0]) < _SCHEMA:
452
+ return None
453
+ if not {'devices', 'tokens'} <= tables:
454
+ raise RegistryError('Registry tables are missing; restore a complete backup')
455
+ return self._read_key(con)
456
+ def _setup(self, con: sqlite3.Connection) -> bytes:
457
+ tables = self._tables(con)
458
+ row = con.execute("SELECT value FROM meta WHERE key='schema'").fetchone() if 'meta' in tables else None
459
+ if row is None:
460
+ if tables - {'meta'} or ('meta' in tables and con.execute('SELECT COUNT(*) FROM meta').fetchone()[0]):
461
+ raise RegistryError('Database is not a new or recognized Altify registry')
462
+ con.execute('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
463
+ con.executemany('INSERT INTO meta VALUES (?,?)', [('schema', str(_SCHEMA)), ('hmac_key', secrets.token_hex(32))])
464
+ con.execute('CREATE TABLE devices (device_id TEXT PRIMARY KEY, namespace TEXT NOT NULL, created_at TEXT NOT NULL, last_seen TEXT NOT NULL)')
465
+ con.execute("CREATE TABLE tokens (device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE, kind TEXT NOT NULL, digest TEXT NOT NULL, added_at TEXT NOT NULL DEFAULT '', PRIMARY KEY(device_id, kind, digest))")
466
+ con.execute('CREATE INDEX tokens_lookup ON tokens(kind,digest)')
467
+ _LOG.info('Created registry %s (schema %d)', self.path, _SCHEMA)
468
+ else:
469
+ version = self._version(row[0])
470
+ if not {'devices', 'tokens'} <= tables:
471
+ raise RegistryError('Registry tables are missing; restore a complete backup')
472
+ while version < _SCHEMA:
473
+ _MIGRATIONS[version](con)
474
+ version += 1
475
+ con.execute("UPDATE meta SET value=? WHERE key='schema'", (str(version),))
476
+ _LOG.info('Migrated registry %s to schema %d', self.path, version)
477
+ return self._read_key(con)
478
+ def _digest(self, kind: str, value: str) -> str:
479
+ payload = json.dumps(['altify-hwid-v1', self.namespace, kind, value], ensure_ascii=True, separators=(',', ':')).encode('ascii')
480
+ return hmac.digest(self._key, payload, 'sha256').hex()
481
+ def _check_key(self, con: sqlite3.Connection) -> None:
482
+ current_key = con.execute("SELECT value FROM meta WHERE key='hmac_key'").fetchone()
483
+ if not current_key or current_key[0] != self._key.hex():
484
+ raise RegistryError('Registry key changed while this instance was open; reopen the registry')
485
+ def _require_writable(self) -> None:
486
+ if self.read_only:
487
+ raise RegistryError('Registry was opened with read_only=True')
488
+ def _owned(self, con: sqlite3.Connection, device_id: Any) -> None:
489
+ if not isinstance(device_id, str) or not device_id:
490
+ raise ValueError('device_id must be a non-empty string')
491
+ if not con.execute('SELECT 1 FROM devices WHERE device_id=? AND namespace=?', (device_id, self.namespace)).fetchone():
492
+ raise NoMatchError(f'{device_id} is not enrolled in namespace {self.namespace!r}')
493
+ def _refresh(self, con: sqlite3.Connection, device_id: str, digests: dict[str, list[str]], stamp: str) -> tuple[str, ...]:
494
+ stored = []
495
+ for kind, values in digests.items():
496
+ cursor = con.executemany('INSERT OR IGNORE INTO tokens(device_id,kind,digest,added_at) VALUES (?,?,?,?)', [(device_id, kind, digest, stamp) for digest in values])
497
+ if cursor.rowcount <= 0:
498
+ continue
499
+ stored.append(kind)
500
+ excess = con.execute('SELECT COUNT(*) FROM tokens WHERE device_id=? AND kind=?', (device_id, kind)).fetchone()[0] - _TOKEN_CAP
501
+ if excess > 0:
502
+ con.execute('DELETE FROM tokens WHERE rowid IN (SELECT t.rowid FROM tokens t WHERE t.device_id=? AND t.kind=? AND NOT EXISTS (SELECT 1 FROM incoming i WHERE i.kind=t.kind AND i.digest=t.digest) ORDER BY t.added_at,t.rowid LIMIT ?)', (device_id, kind, excess))
503
+ _LOG.debug('Pruned %d stale %s tokens from %s', excess, kind, device_id)
504
+ return tuple(stored)
505
+ def resolve(self, snapshot: HardwareSnapshot, *, create: bool=True, refresh: bool=True) -> IdentityResult:
506
+ if not isinstance(snapshot, HardwareSnapshot):
507
+ raise TypeError('snapshot must be HardwareSnapshot')
508
+ if len(snapshot.groups) < self.min_matches:
509
+ raise InsufficientHardwareError(snapshot.groups, self.min_matches, snapshot.warnings)
510
+ write = not self.read_only
511
+ create, refresh = (bool(create) and write, bool(refresh) and write)
512
+ digests = {kind: [self._digest(kind, value) for value in values] for kind, values in snapshot.signals.items()}
513
+ tokens = [(kind, digest) for kind, values in digests.items() for digest in values]
514
+ changed = False
515
+ with self._transaction(write=write) as con:
516
+ self._check_key(con)
517
+ con.execute('CREATE TEMP TABLE incoming (kind TEXT, digest TEXT, PRIMARY KEY(kind,digest))')
518
+ con.executemany('INSERT INTO incoming VALUES (?,?)', tokens)
519
+ candidates = []
520
+ for device_id, kinds, count in con.execute(_MATCH_SQL, (self.namespace, self.min_matches)):
521
+ kinds = tuple(sorted(kinds.split(',')))
522
+ groups = tuple(sorted({_KINDS[k] for k in kinds}))
523
+ candidates.append({'device_id': device_id, 'match_count': count, 'matched_groups': groups, 'matched_kinds': kinds})
524
+ if len(candidates) > 1:
525
+ _LOG.warning('Ambiguous match in namespace %r: %s', self.namespace, ', '.join((f"{c['device_id']} ({c['match_count']} groups)" for c in candidates)))
526
+ raise AmbiguousMatchError(tuple(candidates))
527
+ stamp = _now()
528
+ if candidates:
529
+ candidate = candidates[0]
530
+ device_id = candidate['device_id']
531
+ stored = ()
532
+ if write:
533
+ con.execute('UPDATE devices SET last_seen=? WHERE device_id=? AND last_seen<>?', (stamp, device_id, stamp))
534
+ if refresh:
535
+ stored = self._refresh(con, device_id, digests, stamp)
536
+ changed = bool(stored)
537
+ _LOG.debug('Matched %s on %d groups (%s); stored new tokens for %s', device_id, candidate['match_count'], ','.join(candidate['matched_groups']), ','.join(stored) or 'nothing')
538
+ result = IdentityResult(device_id, False, candidate['matched_groups'], candidate['matched_kinds'], snapshot.groups, snapshot.warnings, stored)
539
+ else:
540
+ if not create:
541
+ raise NoMatchError(f'No enrollment matches at least {self.min_matches} hardware groups')
542
+ device_id = 'ALTIFY-' + str(uuid.uuid4()).upper()
543
+ con.execute('INSERT INTO devices VALUES (?,?,?,?)', (device_id, self.namespace, stamp, stamp))
544
+ con.executemany('INSERT INTO tokens(device_id,kind,digest,added_at) VALUES (?,?,?,?)', [(device_id, k, v, stamp) for k, v in tokens])
545
+ changed = True
546
+ _LOG.info('Enrolled %s with %d groups (%s)', device_id, len(snapshot.groups), ','.join(snapshot.groups))
547
+ result = IdentityResult(device_id, True, (), (), snapshot.groups, snapshot.warnings, tuple(sorted(digests)))
548
+ if changed:
549
+ self._auto_backup()
550
+ return result
551
+ def list_devices(self, *, all_namespaces: bool=False) -> list[dict[str, Any]]:
552
+ sql = _LIST_SQL.format(where='' if all_namespaces else 'WHERE d.namespace=?')
553
+ with self._transaction(write=False) as con:
554
+ rows = con.execute(sql, () if all_namespaces else (self.namespace,)).fetchall()
555
+ devices = []
556
+ for device_id, namespace, created_at, last_seen, count, kinds in rows:
557
+ kinds = tuple(sorted(kinds.split(','))) if kinds else ()
558
+ devices.append({'device_id': device_id, 'namespace': namespace, 'created_at': created_at, 'last_seen': last_seen, 'token_count': count, 'groups': sorted({_KINDS[k] for k in kinds if k in _KINDS}), 'kinds': list(kinds)})
559
+ return devices
560
+ def delete(self, *device_ids: str) -> int:
561
+ self._require_writable()
562
+ if not device_ids:
563
+ raise ValueError('Supply at least one device_id')
564
+ with self._transaction() as con:
565
+ self._check_key(con)
566
+ for device_id in device_ids:
567
+ self._owned(con, device_id)
568
+ deleted = sum((con.execute('DELETE FROM devices WHERE device_id=? AND namespace=?', (device_id, self.namespace)).rowcount for device_id in set(device_ids)))
569
+ _LOG.info('Deleted %d enrollment(s) from namespace %r', deleted, self.namespace)
570
+ self._auto_backup()
571
+ return deleted
572
+ def merge(self, keep_id: str, *absorb_ids: str) -> dict[str, Any]:
573
+ self._require_writable()
574
+ if not absorb_ids:
575
+ raise ValueError('Supply the device_id to keep followed by at least one device_id to absorb')
576
+ if keep_id in absorb_ids or len(set(absorb_ids)) != len(absorb_ids):
577
+ raise ValueError('device_ids must be distinct and must not include the kept device_id')
578
+ stamp = _now()
579
+ with self._transaction() as con:
580
+ self._check_key(con)
581
+ for device_id in (keep_id, *absorb_ids):
582
+ self._owned(con, device_id)
583
+ for device_id in absorb_ids:
584
+ con.execute('INSERT OR IGNORE INTO tokens(device_id,kind,digest,added_at) SELECT ?,kind,digest,added_at FROM tokens WHERE device_id=?', (keep_id, device_id))
585
+ con.execute('DELETE FROM devices WHERE device_id=?', (device_id,))
586
+ con.execute('UPDATE devices SET last_seen=? WHERE device_id=?', (stamp, keep_id))
587
+ count = con.execute('SELECT COUNT(*) FROM tokens WHERE device_id=?', (keep_id,)).fetchone()[0]
588
+ _LOG.info('Merged %s into %s (%d tokens)', ','.join(absorb_ids), keep_id, count)
589
+ self._auto_backup()
590
+ return {'device_id': keep_id, 'merged': list(absorb_ids), 'token_count': count}
591
+ def _backup_path(self, index: int) -> Path:
592
+ return self.path.with_name(f'{self.path.name}.bak{index}')
593
+ def _auto_backup(self) -> None:
594
+ if self.read_only or not self.backup_copies:
595
+ return
596
+ newest = self._backup_path(1)
597
+ staging = self.path.with_name(f'{self.path.name}.bak-{secrets.token_hex(4)}.tmp')
598
+ try:
599
+ if newest.is_file() and time() - newest.stat().st_mtime < self.backup_interval:
600
+ return
601
+ self.backup(staging)
602
+ for index in range(self.backup_copies, 1, -1):
603
+ older = self._backup_path(index - 1)
604
+ if older.is_file():
605
+ os.replace(older, self._backup_path(index))
606
+ os.replace(staging, newest)
607
+ _LOG.info('Backed up registry to %s', newest)
608
+ except Exception as exc:
609
+ staging.unlink(missing_ok=True)
610
+ _LOG.warning('Automatic registry backup failed: %s', exc)
611
+ def backup(self, destination: str | os.PathLike, *, timeout: float=30.0) -> Path:
612
+ if type(timeout) not in (float, int) or not math.isfinite(timeout) or (not 0 < timeout <= 300):
613
+ raise ValueError('backup timeout must be between 0 and 300 seconds')
614
+ target = Path(destination).expanduser().resolve()
615
+ if target == self.path:
616
+ raise ValueError('Backup destination must differ from the live database')
617
+ target.parent.mkdir(parents=True, exist_ok=True)
618
+ with target.open('xb'):
619
+ pass
620
+ deadline = monotonic() + timeout
621
+ def progress(status, remaining, total):
622
+ if monotonic() >= deadline:
623
+ raise RegistryError('Backup timed out')
624
+ try:
625
+ with closing(sqlite3.connect(self.path.as_uri() + '?mode=ro', uri=True, timeout=timeout)) as source:
626
+ with closing(sqlite3.connect(str(target), timeout=timeout)) as dest:
627
+ source.backup(dest, pages=256, progress=progress, sleep=0.05)
628
+ except sqlite3.Error as exc:
629
+ target.unlink(missing_ok=True)
630
+ raise RegistryError(f'Backup failed: {exc}') from exc
631
+ except BaseException:
632
+ target.unlink(missing_ok=True)
633
+ raise
634
+ return target
635
+ def identify(db_path: str | os.PathLike | None=None, *, namespace: str='default', min_matches: int=3, create: bool=True, refresh: bool=True, read_only: bool=False, include_tpm: bool=True, include_monitors: bool=True, timeout: float=35.0, cache_ttl: float=60.0, snapshot: HardwareSnapshot | None=None) -> IdentityResult:
636
+ if type(min_matches) is not int or not 3 <= min_matches <= 7:
637
+ raise ValueError('min_matches must be an integer between 3 and 7')
638
+ if not isinstance(namespace, str) or not namespace.strip() or len(namespace) > 128:
639
+ raise ValueError('namespace must contain 1 to 128 characters')
640
+ if snapshot is None:
641
+ snapshot = collect(include_tpm=include_tpm, include_monitors=include_monitors, timeout=timeout, cache_ttl=cache_ttl)
642
+ if not isinstance(snapshot, HardwareSnapshot):
643
+ raise TypeError('snapshot must be HardwareSnapshot')
644
+ if len(snapshot.groups) < min_matches:
645
+ raise InsufficientHardwareError(snapshot.groups, min_matches, snapshot.warnings)
646
+ return Registry(db_path, namespace=namespace, min_matches=min_matches, read_only=read_only).resolve(snapshot, create=create, refresh=refresh)
647
+ def get_id(*args, **kwargs) -> str:
648
+ return identify(*args, **kwargs).device_id
649
+ hwid = get_id
650
+ def _main() -> int:
651
+ parser = argparse.ArgumentParser(description='Altify: persistent identity from 3+ hardware groups')
652
+ parser.add_argument('action', nargs='?', choices=('identify', 'scan', 'list', 'merge', 'delete'), default='identify')
653
+ parser.add_argument('ids', nargs='*', metavar='DEVICE_ID', help='merge: KEEP_ID ABSORB_ID...; delete: DEVICE_ID...')
654
+ parser.add_argument('--db', help='Registry path (default: per-user Altify/HWID data folder)')
655
+ parser.add_argument('--namespace', default='default')
656
+ parser.add_argument('--all-namespaces', action='store_true', help='list: include every namespace')
657
+ parser.add_argument('--min-matches', type=int, default=3, choices=range(3, 8))
658
+ parser.add_argument('--no-create', action='store_true', help='Only match an existing enrollment')
659
+ parser.add_argument('--no-refresh', action='store_true', help='Do not store newly seen hardware on a match')
660
+ parser.add_argument('--read-only', action='store_true', help='Never write to the registry')
661
+ parser.add_argument('--no-tpm', action='store_true')
662
+ parser.add_argument('--no-monitors', action='store_true')
663
+ parser.add_argument('--timeout', type=float, default=35, help='Overall collection deadline in seconds')
664
+ parser.add_argument('--json', action='store_true', help='Print detailed identity result as JSON')
665
+ parser.add_argument('--raw', action='store_true', help='Include RAW identifiers in scan output')
666
+ parser.add_argument('-v', '--verbose', action='store_true', help='Log collector and registry activity to stderr')
667
+ args = parser.parse_args()
668
+ if args.raw and args.action != 'scan':
669
+ parser.error('--raw requires the scan action')
670
+ if args.all_namespaces and args.action != 'list':
671
+ parser.error('--all-namespaces requires the list action')
672
+ if args.ids and args.action not in ('merge', 'delete'):
673
+ parser.error('device ids are only accepted by merge and delete')
674
+ if args.action == 'merge' and len(args.ids) < 2:
675
+ parser.error('merge needs KEEP_ID and at least one ABSORB_ID')
676
+ if args.action == 'delete' and not args.ids:
677
+ parser.error('delete needs at least one DEVICE_ID')
678
+ if args.verbose:
679
+ logging.basicConfig(level=logging.DEBUG, stream=sys.stderr, format='%(levelname)s %(name)s: %(message)s')
680
+ try:
681
+ if args.action in ('list', 'merge', 'delete'):
682
+ registry = Registry(args.db, namespace=args.namespace, min_matches=args.min_matches, read_only=args.read_only)
683
+ if args.action == 'list':
684
+ output = registry.list_devices(all_namespaces=args.all_namespaces)
685
+ elif args.action == 'merge':
686
+ output = registry.merge(args.ids[0], *args.ids[1:])
687
+ else:
688
+ output = {'deleted': registry.delete(*args.ids)}
689
+ print(json.dumps(output, indent=2))
690
+ return 0
691
+ snapshot = collect(include_tpm=not args.no_tpm, include_monitors=not args.no_monitors, include_diagnostics=args.raw, timeout=args.timeout, cache_ttl=0)
692
+ if args.action == 'scan':
693
+ print(json.dumps(snapshot.to_dict(raw=args.raw), indent=2, ensure_ascii=True))
694
+ return 0
695
+ result = identify(args.db, namespace=args.namespace, min_matches=args.min_matches, create=not args.no_create, refresh=not args.no_refresh, read_only=args.read_only, snapshot=snapshot)
696
+ print(json.dumps(result.to_dict(), indent=2) if args.json else result.device_id)
697
+ if not args.json:
698
+ for warning in result.warnings:
699
+ print('Warning: ' + warning, file=sys.stderr)
700
+ return 0
701
+ except (AltifyError, ValueError, OSError) as exc:
702
+ error = {'error': type(exc).__name__, 'message': str(exc)}
703
+ if isinstance(exc, AmbiguousMatchError):
704
+ error['candidates'] = exc.candidates
705
+ if isinstance(exc, InsufficientHardwareError):
706
+ error['warnings'] = exc.warnings
707
+ print(json.dumps(error, indent=2), file=sys.stderr)
708
+ return 2
709
+ except Exception as exc:
710
+ _LOG.exception('Unexpected error')
711
+ print(json.dumps({'error': type(exc).__name__, 'message': f'Unexpected error: {exc}'}, indent=2), file=sys.stderr)
712
+ return 1
713
+ if __name__ == '__main__':
714
+ raise SystemExit(_main())