feat(telemetry): transmit the stable install_id as-is
Product-owner decision, 2026-08-27: the analytical need is stable cross-window identity (retention curves, longitudinal install behaviour), which the rotating pseudonym destroyed by design. The feature has not shipped - zero consented users, zero production transmissions - so identity semantics can change without breaking any promise made to a user; existing (dev-only) consent windows carry forward unchanged. Removed in full rather than weakened in place: - shared_metrics_identity.py (salt generation/rotation, HMAC-SHA256 derivation, payload substitution) and its 19-test file. - The sender's derivation step. _freeze_identity keeps its validation role (unreadable/non-object/id-less payloads still reject rather than block the queue) and now records the raw install_id in sent_install_id; _body rewrites the payload's install_id from that frozen column, keeping byte-identical resends anchored to one recorded value. Consent surface updated in the same change: the setup wizard now states plainly that packages carry the stable profile-scoped install ID (a random UUID, no personal information, reset by deleting the shared-metrics directory). No consent was ever collected under the old wording in any shipped build. Docs A.2/A.3 rewritten as decision records rather than silently edited: A.2 records what is transmitted now and states the consequences plainly (indefinite cross-package correlation is the designed behaviour); A.3 records why rotation existed and why its removal was accepted. The main-body "must not reuse the persistent local identifier by default" escape hatch is exercised, not deleted: that paragraph required exactly this product decision, which has now been made. A.6's deletion note updated: install_id is now itself the lookup key, so a future delete-on-request needs only a service-side API, not a mapping. Tests: the two privacy assertions invert deliberately (test_the_stable_install_id_is_transmitted_as_is and the e2e wire variant); freezing/byte-identical-retry coverage unchanged. Staging E2E script now asserts transmitted == install_id. 258 targeted tests pass; ruff + footguns clean; both staging E2E harnesses green with the raw id observed on the wire (202s).
This commit is contained in:
@@ -230,17 +230,18 @@ packages from that profile and can therefore link those local packages.
|
|||||||
Deleting `$HERMES_HOME/telemetry/shared_metrics` resets the identifier together
|
Deleting `$HERMES_HOME/telemetry/shared_metrics` resets the identifier together
|
||||||
with all aggregates and package files.
|
with all aggregates and package files.
|
||||||
|
|
||||||
Remote delivery is opt-in and off by default. A remote exporter must not reuse
|
Remote delivery is opt-in and off by default. Reusing the persistent local
|
||||||
the persistent local identifier by default. It requires a separate product and
|
identifier remotely required a separate product and privacy decision covering
|
||||||
privacy decision covering consent, identity scope, rotation or keyed
|
consent, identity scope, reset behavior, retention, and deletion — that
|
||||||
pseudonymization, reset behavior, retention, and deletion.
|
decision has been made.
|
||||||
|
|
||||||
> Those decisions are recorded in
|
> Those decisions are recorded in
|
||||||
> [Appendix A](#appendix-a-remote-exporter-decisions-phase-2), and the exporter
|
> [Appendix A](#appendix-a-remote-exporter-decisions-phase-2), and the exporter
|
||||||
> implementing them has shipped. Collection alone still transmits nothing: the
|
> implementing them has shipped. Collection alone still transmits nothing: the
|
||||||
> sender runs only when `telemetry.shared_metrics.send` is also true, and it
|
> sender runs only when `telemetry.shared_metrics.send` is also true. Each
|
||||||
> transmits a rotating HMAC of the install identity rather than the identifier
|
> transmitted package carries the stable `install_id` as-is (product decision,
|
||||||
> itself.
|
> 2026-08-27 — see A.2 for the record, including the superseded
|
||||||
|
> HMAC-pseudonym design).
|
||||||
|
|
||||||
The install identity is scoped to one `HERMES_HOME`. To reset it, stop Hermes
|
The install identity is scoped to one `HERMES_HOME`. To reset it, stop Hermes
|
||||||
processes and remove `$HERMES_HOME/telemetry/shared_metrics`. This deliberately
|
processes and remove `$HERMES_HOME/telemetry/shared_metrics`. This deliberately
|
||||||
@@ -327,60 +328,66 @@ Local history can be up to 30 days old, and that data was collected under a
|
|||||||
promise that nothing is uploaded. Honouring consent forward-only costs at most
|
promise that nothing is uploaded. Honouring consent forward-only costs at most
|
||||||
30 days of backlog we never had permission to send.
|
30 days of backlog we never had permission to send.
|
||||||
|
|
||||||
### A.2 Identity scope — the transmitted identifier is derived, not the local one
|
### A.2 Identity scope — the stable install_id is transmitted as-is
|
||||||
|
|
||||||
`install_id` is the persistent profile-scoped identifier described above. It is
|
**Decision record.** The original design of this exporter (and revisions 1–8
|
||||||
**not transmitted**. Each package sent carries a derived value instead:
|
of this appendix) transmitted a keyed pseudonym instead of the identifier:
|
||||||
|
`HMAC-SHA256(key = locally-held rotating salt, message = install_id)`, with
|
||||||
|
the salt rotating every 30 days. On **2026-08-27**, before the feature
|
||||||
|
shipped (zero consented users, zero production transmissions), the product
|
||||||
|
owner decided the analytical need is a **stable cross-window identity** —
|
||||||
|
retention curves, longitudinal install behaviour — which rotation by design
|
||||||
|
destroys. The pseudonymization layer was removed in full rather than
|
||||||
|
weakened in place.
|
||||||
|
|
||||||
```text
|
What is transmitted now:
|
||||||
transmitted_id = HMAC-SHA256(key = rotation_salt, message = install_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `rotation_salt` is random, generated locally, and never leaves the machine.
|
- Each package carries `install_id` verbatim: the persistent, profile-scoped
|
||||||
- The derivation is one-way: the service cannot recover `install_id`.
|
random UUID described above.
|
||||||
- Within a rotation window, packages from one profile correlate — so distinct
|
- It is generated locally (`uuid4`), contains no hardware, account, user, or
|
||||||
installs remain countable, which is the primary analytical question.
|
machine-derived information, and identifies a *profile*, not a person.
|
||||||
- Across windows, they do not.
|
- It is stable until the user deletes the shared-metrics directory, which
|
||||||
|
regenerates it (see A.4).
|
||||||
|
|
||||||
This satisfies "must not reuse the persistent local identifier by default"
|
Consequences stated plainly rather than papered over:
|
||||||
while keeping the data useful. Stripping the identifier entirely was rejected
|
|
||||||
because "how many installs are reporting" is the first question the data must
|
|
||||||
answer; sending `install_id` unchanged was rejected because it contradicts the
|
|
||||||
commitment made above.
|
|
||||||
|
|
||||||
**Byte-identical resends still hold.** The derived value is computed **once**,
|
- Packages from one profile correlate **indefinitely**, not per-window.
|
||||||
when the package is first prepared for sending, and stored alongside the
|
Long-term linkability of one install's daily envelope sequence is now the
|
||||||
package (the derived id only — not a second copy of the payload, which is
|
designed behaviour, not a residue.
|
||||||
recomputed deterministically from the stored package). A retry therefore
|
- The A.3 residue analysis of the old design (stable `resource` tuple +
|
||||||
rebuilds identical bytes even if the salt rotated in between. The contract
|
contiguous periods bridging rotation windows) is moot — there is no window
|
||||||
requires this: resending a `package_id` with different content is undefined
|
boundary left to bridge.
|
||||||
behaviour.
|
- The setup wizard's consent language states this identity model explicitly;
|
||||||
|
it was updated in the same change that removed the derivation, so no
|
||||||
|
consent was ever collected under the old wording in any shipped build.
|
||||||
|
|
||||||
### A.3 Rotation
|
**Byte-identical resends still hold.** The transmitted id is recorded on the
|
||||||
|
row (`sent_install_id`) when the package is first prepared, and the wire body
|
||||||
|
is always rebuilt from that recorded value, so a retry rebuilds identical
|
||||||
|
bytes. The contract requires this: resending a `package_id` with different
|
||||||
|
content is undefined behaviour. (With a stable id the recorded copy is no
|
||||||
|
longer load-bearing against rotation — it remains as the audit column and as
|
||||||
|
cheap insurance against any future change to identity semantics.)
|
||||||
|
|
||||||
`rotation_salt` rotates on a fixed schedule (default: every 30 days, aligned to
|
### A.3 Rotation — removed (decision record)
|
||||||
local history retention). Rotation only affects packages prepared after it;
|
|
||||||
already-prepared packages keep their derived value so retries stay
|
|
||||||
byte-identical.
|
|
||||||
|
|
||||||
Rotation bounds long-term linkability without destroying short-term cohort
|
Salt rotation was deleted together with the derivation (product decision,
|
||||||
analysis. A profile is one identity for the length of a window, and an
|
2026-08-27). This section is retained as a record of what the earlier design
|
||||||
unrelated identity after it.
|
did and why the removal was accepted:
|
||||||
|
|
||||||
**What rotation does not bound.** The identifier changes; the rest of the
|
- Rotation existed to bound long-term linkability: one identity per 30-day
|
||||||
envelope does not. `resource` (`os_family`, `architecture`, `install_method`,
|
window, unrelated identities across windows.
|
||||||
`hermes_version`) is stable and low-entropy, and `period_start` /
|
- The documented residue (see git history for the full analysis): the
|
||||||
`period_end` are contiguous across a rotation boundary. For a common
|
envelope's stable, low-entropy `resource` tuple plus contiguous daily
|
||||||
configuration this is no help to an observer — measured against the 11 real
|
periods could plausibly bridge windows for rare configurations anyway, so
|
||||||
packages in a development outbox, every one shares the same
|
the boundary was a cost-raiser, not a wall.
|
||||||
`arm64 / macos / git` tuple. For a **rare** configuration it is a plausible
|
- The product need that killed it: cross-window continuity is precisely what
|
||||||
re-identification aid: an unusual architecture or install method, combined
|
retention analysis requires. A boundary that mostly inconveniences honest
|
||||||
with an uninterrupted daily period sequence, can bridge two windows. The
|
analysis while only raising costs for a determined correlator was judged
|
||||||
claim this design makes is therefore "rotation raises the cost of long-term
|
the wrong trade once stable identity became a requirement.
|
||||||
correlation", not "rotation makes it impossible". Narrowing that residue
|
|
||||||
would mean coarsening `resource` or jittering period boundaries, and neither
|
There is no salt in the store, no rotation schedule, and no derived
|
||||||
is worth the analytical loss today — but it should be a conscious decision,
|
identifier anywhere in the pipeline.
|
||||||
not an unexamined one.
|
|
||||||
|
|
||||||
### A.4 Reset behavior
|
### A.4 Reset behavior
|
||||||
|
|
||||||
@@ -388,11 +395,11 @@ Removing `$HERMES_HOME/telemetry/shared_metrics` still resets local identity,
|
|||||||
aggregates, and package files, exactly as documented above. Two honest
|
aggregates, and package files, exactly as documented above. Two honest
|
||||||
qualifications now apply:
|
qualifications now apply:
|
||||||
|
|
||||||
- Reset also discards `rotation_salt`, so subsequent packages derive a **new**
|
- Reset regenerates `install_id`, so subsequent packages transmit a **new**
|
||||||
transmitted identity. Local reset does give a new remote identity.
|
identity. Local reset does give a new remote identity.
|
||||||
- Reset **cannot unsend**. Packages already transmitted remain in the ingest
|
- Reset **cannot unsend**. Packages already transmitted remain in the ingest
|
||||||
service's storage under their derived identifier. There is no read-back or
|
service's storage under the identifier they were sent with. There is no
|
||||||
delete API in the v1 contract.
|
read-back or delete API in the v1 contract.
|
||||||
|
|
||||||
Setting `send: false` stops transmission immediately: consent is re-read
|
Setting `send: false` stops transmission immediately: consent is re-read
|
||||||
before every package, so a pass already in flight stops after the package it
|
before every package, so a pass already in flight stops after the package it
|
||||||
@@ -439,13 +446,13 @@ invent one. What a user can do:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `send: false` | No further packages leave the machine |
|
| `send: false` | No further packages leave the machine |
|
||||||
| `enabled: false` | Collection stops; existing local state remains |
|
| `enabled: false` | Collection stops; existing local state remains |
|
||||||
| Remove `.../shared_metrics` | Local identity, aggregates, and files reset; future sends use a new derived identity |
|
| Remove `.../shared_metrics` | Local identity, aggregates, and files reset; future sends use a new install_id |
|
||||||
| Delete already-sent data | Not self-service — requires an operator acting on the S3 bucket |
|
| Delete already-sent data | Not self-service — requires an operator acting on the S3 bucket |
|
||||||
|
|
||||||
If a deletion-on-request obligation is ever taken on, it needs a lookup path
|
If a deletion-on-request obligation is ever taken on, the lookup path is now
|
||||||
from a user to their derived identifiers. That is deliberately **not** built:
|
direct: the user's `install_id` (readable from their local store) is the key
|
||||||
it would require retaining the mapping this design exists to avoid. Any such
|
their data is stored under. Building the service-side delete API remains a
|
||||||
change is a new product decision, not an implementation detail.
|
new product decision, not an implementation detail.
|
||||||
|
|
||||||
### A.7 What the outbox directory is
|
### A.7 What the outbox directory is
|
||||||
|
|
||||||
@@ -464,7 +471,8 @@ state they were promised. Send state lives in new columns on the
|
|||||||
|
|
||||||
### A.8 Scope note
|
### A.8 Scope note
|
||||||
|
|
||||||
The `install_id` field inside the package body is what gets replaced by the
|
The `install_id` field inside the package body is transmitted as the
|
||||||
derived value. No other payload field changes, nothing is added, and the
|
generator wrote it (rewritten from the row's frozen `sent_install_id`, which
|
||||||
service treats the whole body as opaque. Payload schema evolution therefore
|
records the same value). No other payload field changes, nothing is added,
|
||||||
stays a sender-side concern, as before.
|
and the service treats the whole body as opaque. Payload schema evolution
|
||||||
|
therefore stays a sender-side concern, as before.
|
||||||
|
|||||||
@@ -373,8 +373,9 @@ class SharedMetricsStore:
|
|||||||
# Earliest next attempt; enforces backoff across process restarts.
|
# Earliest next attempt; enforces backoff across process restarts.
|
||||||
("next_attempt_at", "TEXT"),
|
("next_attempt_at", "TEXT"),
|
||||||
("last_error", "TEXT"),
|
("last_error", "TEXT"),
|
||||||
# The derived identifier actually transmitted, frozen on the first
|
# The identifier actually transmitted, frozen on the first
|
||||||
# attempt so retries stay byte-identical across a salt rotation.
|
# attempt so retries stay byte-identical. Since the 2026-08-27
|
||||||
|
# product decision this is the stable install_id itself.
|
||||||
# Only the ~36-byte id is stored: the body is recomputed from
|
# Only the ~36-byte id is stored: the body is recomputed from
|
||||||
# payload_json, whose serialisation is deterministic.
|
# payload_json, whose serialisation is deterministic.
|
||||||
("sent_install_id", "TEXT"),
|
("sent_install_id", "TEXT"),
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Keyed pseudonymization of the shared-metrics install identity.
|
|
||||||
|
|
||||||
``install_id`` is a persistent, profile-scoped identifier. It is deliberately
|
|
||||||
NOT transmitted: ``docs/observability/relay-shared-metrics.md`` commits that a
|
|
||||||
remote exporter "must not reuse the persistent local identifier by default".
|
|
||||||
|
|
||||||
Each transmitted package instead carries::
|
|
||||||
|
|
||||||
HMAC-SHA256(key=rotation_salt, message=install_id)
|
|
||||||
|
|
||||||
where ``rotation_salt`` is generated locally, never leaves the machine, and
|
|
||||||
rotates on a fixed schedule. Within a rotation window the value is stable, so
|
|
||||||
distinct installs stay countable — the primary analytical question. Across
|
|
||||||
windows it changes, bounding long-term linkability.
|
|
||||||
|
|
||||||
The derivation is one-way: the service cannot recover ``install_id`` from what
|
|
||||||
it receives.
|
|
||||||
|
|
||||||
See Appendix A.2 and A.3 of the doc above for the decision record.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import secrets
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
#: Salt lifetime. Matches local history retention so the two ages line up.
|
|
||||||
ROTATION_INTERVAL = timedelta(days=30)
|
|
||||||
|
|
||||||
#: ``telemetry_state`` keys. The salt lives in the same store as install_id, so
|
|
||||||
#: deleting the shared-metrics directory resets both together — the documented
|
|
||||||
#: reset behaviour keeps working without a second cleanup path.
|
|
||||||
SALT_KEY = "send_rotation_salt"
|
|
||||||
SALT_ISSUED_AT_KEY = "send_rotation_salt_issued_at"
|
|
||||||
|
|
||||||
_SALT_BYTES = 32
|
|
||||||
|
|
||||||
|
|
||||||
def _isoformat(value: datetime) -> str:
|
|
||||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse(value: str | None) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _read(connection: sqlite3.Connection, key: str) -> str | None:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT value FROM telemetry_state WHERE key = ?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
# sqlite3.Row and plain tuples both index by position.
|
|
||||||
return str(row[0])
|
|
||||||
|
|
||||||
|
|
||||||
def _write(connection: sqlite3.Connection, key: str, value: str) -> None:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO telemetry_state(key, value) VALUES (?, ?)
|
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
||||||
""",
|
|
||||||
(key, value),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def current_salt(
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
*,
|
|
||||||
now: datetime | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Return the active salt, generating or rotating it when due.
|
|
||||||
|
|
||||||
Must be called inside a write transaction: it can write to
|
|
||||||
``telemetry_state``.
|
|
||||||
"""
|
|
||||||
moment = now or datetime.now(timezone.utc)
|
|
||||||
salt = _read(connection, SALT_KEY)
|
|
||||||
issued_at = _parse(_read(connection, SALT_ISSUED_AT_KEY))
|
|
||||||
|
|
||||||
fresh = (
|
|
||||||
salt is not None
|
|
||||||
and issued_at is not None
|
|
||||||
# Strictly within the window. A future issued_at means the clock moved
|
|
||||||
# backwards (or the value was tampered with), so the recorded age
|
|
||||||
# cannot be trusted and we reissue rather than keep using a salt of
|
|
||||||
# unknown vintage. Reissuing is the safe direction: it shortens
|
|
||||||
# linkability, and already-prepared packages keep their frozen
|
|
||||||
# identifier so retries stay byte-identical.
|
|
||||||
and issued_at <= moment < issued_at + ROTATION_INTERVAL
|
|
||||||
)
|
|
||||||
if fresh:
|
|
||||||
return str(salt)
|
|
||||||
|
|
||||||
salt = secrets.token_hex(_SALT_BYTES)
|
|
||||||
_write(connection, SALT_KEY, salt)
|
|
||||||
_write(connection, SALT_ISSUED_AT_KEY, _isoformat(moment))
|
|
||||||
return salt
|
|
||||||
|
|
||||||
|
|
||||||
def derive_install_id(install_id: str, salt: str) -> str:
|
|
||||||
"""Return the transmitted identifier for ``install_id`` under ``salt``."""
|
|
||||||
return hmac.new(
|
|
||||||
salt.encode("utf-8"),
|
|
||||||
install_id.encode("utf-8"),
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def substitute_install_id(payload: dict, derived: str) -> dict:
|
|
||||||
"""Return ``payload`` with its ``install_id`` replaced by ``derived``.
|
|
||||||
|
|
||||||
This is the ONLY field the exporter changes. Everything else is
|
|
||||||
transmitted exactly as the generator wrote it, so payload schema evolution
|
|
||||||
stays a sender-side concern. A shallow copy is enough — only a top-level
|
|
||||||
key is replaced — and the caller's dict is left untouched.
|
|
||||||
"""
|
|
||||||
updated = dict(payload)
|
|
||||||
updated["install_id"] = derived
|
|
||||||
return updated
|
|
||||||
@@ -39,12 +39,6 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from hermes_cli.sqlite_util import write_txn
|
from hermes_cli.sqlite_util import write_txn
|
||||||
|
|
||||||
from .shared_metrics_identity import (
|
|
||||||
current_salt,
|
|
||||||
derive_install_id,
|
|
||||||
substitute_install_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
#: Contract recommends timing out at 30s and treating a timeout as retryable.
|
#: Contract recommends timing out at 30s and treating a timeout as retryable.
|
||||||
@@ -432,13 +426,17 @@ class SharedMetricsSender:
|
|||||||
payload_json,
|
payload_json,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Derive and persist the transmitted id, or reject an unusable row.
|
"""Record the transmitted id on the row, or reject an unusable one.
|
||||||
|
|
||||||
Returns None when the package can never be sent. Rejecting rather than
|
The stable install_id is transmitted as-is (product decision,
|
||||||
raising matters: an exception here rolls back the claim transaction
|
2026-08-27 — see the doc's A.2). What remains of "freezing" is the
|
||||||
and blocks every healthy package behind this one.
|
validation and the audit column: ``sent_install_id`` records exactly
|
||||||
|
what the wire will carry, and rejecting unusable rows here rather
|
||||||
|
than raising matters because an exception rolls back the claim
|
||||||
|
transaction and blocks every healthy package behind this one.
|
||||||
"""
|
"""
|
||||||
reason = None
|
reason = None
|
||||||
|
install_id = None
|
||||||
try:
|
try:
|
||||||
payload = json.loads(payload_json)
|
payload = json.loads(payload_json)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -467,24 +465,26 @@ class SharedMetricsSender:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
salt = current_salt(connection, now=now)
|
|
||||||
derived = derive_install_id(payload["install_id"], salt)
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE package_outbox SET sent_install_id = ? WHERE package_id = ?",
|
"UPDATE package_outbox SET sent_install_id = ? WHERE package_id = ?",
|
||||||
(derived, package_id),
|
(install_id, package_id),
|
||||||
)
|
)
|
||||||
return derived
|
return str(install_id)
|
||||||
|
|
||||||
# -- transmission ------------------------------------------------------
|
# -- transmission ------------------------------------------------------
|
||||||
|
|
||||||
def _body(self, payload_json: str, derived: str) -> bytes:
|
def _body(self, payload_json: str, transmitted_id: str) -> bytes:
|
||||||
"""Rebuild the exact bytes to send.
|
"""Rebuild the exact bytes to send.
|
||||||
|
|
||||||
The payload is recomputed from the stored package rather than kept as
|
The payload is recomputed from the stored package rather than kept as
|
||||||
a second copy: json.dumps with these options is deterministic, and the
|
a second copy: json.dumps with these options is deterministic. The
|
||||||
only mutable input (the derived id) is frozen in the row.
|
install_id is written from the frozen ``sent_install_id`` column
|
||||||
|
rather than trusted implicitly, keeping "a resend is byte-identical"
|
||||||
|
anchored to one recorded value.
|
||||||
"""
|
"""
|
||||||
payload = substitute_install_id(json.loads(payload_json), derived)
|
payload = json.loads(payload_json)
|
||||||
|
payload = dict(payload)
|
||||||
|
payload["install_id"] = transmitted_id
|
||||||
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
||||||
|
|
||||||
def _mark(
|
def _mark(
|
||||||
|
|||||||
+6
-4
@@ -2464,10 +2464,12 @@ def setup_telemetry(config: dict):
|
|||||||
print_success("Local shared metrics enabled.")
|
print_success("Local shared metrics enabled.")
|
||||||
print_info("")
|
print_info("")
|
||||||
print_info("Sending uploads each daily package to the Nous telemetry")
|
print_info("Sending uploads each daily package to the Nous telemetry")
|
||||||
print_info("service. Your profile-scoped install ID is NOT sent: packages")
|
print_info("service. Packages carry your profile-scoped install ID, a")
|
||||||
print_info("carry a rotating HMAC of it instead. Only packages from the")
|
print_info("stable random UUID that identifies this profile across days")
|
||||||
print_info("day you opt in onwards are ever sent, and sending can be")
|
print_info("(it contains no personal information and is reset by deleting")
|
||||||
print_info("turned off again at any time.")
|
print_info("the shared-metrics directory). Only packages from the day you")
|
||||||
|
print_info("opt in onwards are ever sent, and sending can be turned off")
|
||||||
|
print_info("again at any time.")
|
||||||
shared_metrics["send"] = prompt_yes_no(
|
shared_metrics["send"] = prompt_yes_no(
|
||||||
"Send shared metrics to Nous?",
|
"Send shared metrics to Nous?",
|
||||||
default=shared_metrics.get("send") is True,
|
default=shared_metrics.get("send") is True,
|
||||||
|
|||||||
@@ -171,10 +171,12 @@ def main() -> int:
|
|||||||
print(f" last_error : {row[5]}")
|
print(f" last_error : {row[5]}")
|
||||||
if row[1] != "sent":
|
if row[1] != "sent":
|
||||||
failures.append(f"{row[0]} is {row[1]}: {row[5]}")
|
failures.append(f"{row[0]} is {row[1]}: {row[5]}")
|
||||||
if row[4] == real_install_id:
|
# Product decision 2026-08-27: the stable install_id is transmitted
|
||||||
failures.append(f"{row[0]} LEAKED the real install_id")
|
# as-is; the transmitted value must be exactly the local id.
|
||||||
if not row[4] or len(str(row[4])) != 64:
|
if row[4] != real_install_id:
|
||||||
failures.append(f"{row[0]} has a malformed derived id")
|
failures.append(
|
||||||
|
f"{row[0]} transmitted {row[4]!r}, expected the install_id"
|
||||||
|
)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
@@ -183,7 +185,7 @@ def main() -> int:
|
|||||||
print(f" ✗ {failure}")
|
print(f" ✗ {failure}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print("PASS: every package acknowledged 202 with a derived identifier.")
|
print("PASS: every package acknowledged 202 with the stable install_id.")
|
||||||
print()
|
print()
|
||||||
print("Verify the objects in S3 with the package ids above:")
|
print("Verify the objects in S3 with the package ids above:")
|
||||||
print(" aws s3 ls --recursive "
|
print(" aws s3 ls --recursive "
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
"""Tests for keyed pseudonymization of the shared-metrics install identity.
|
|
||||||
|
|
||||||
The load-bearing property: install_id must never be transmitted, and the
|
|
||||||
value that IS transmitted must stay stable for a package even across a salt
|
|
||||||
rotation, or a retry would change the body under an already-used package_id.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from hermes_cli.observability.shared_metrics_identity import (
|
|
||||||
ROTATION_INTERVAL,
|
|
||||||
SALT_ISSUED_AT_KEY,
|
|
||||||
SALT_KEY,
|
|
||||||
current_salt,
|
|
||||||
derive_install_id,
|
|
||||||
substitute_install_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
INSTALL_ID = "12a73e97-4de9-4766-830d-9ca1192c0420"
|
|
||||||
T0 = datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def connection():
|
|
||||||
conn = sqlite3.connect(":memory:")
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE telemetry_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
||||||
)
|
|
||||||
yield conn
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaltLifecycle:
|
|
||||||
def test_first_call_generates_a_salt(self, connection):
|
|
||||||
salt = current_salt(connection, now=T0)
|
|
||||||
assert len(salt) == 64 # 32 bytes hex
|
|
||||||
assert int(salt, 16) >= 0 # valid hex
|
|
||||||
|
|
||||||
def test_salt_is_stable_within_the_window(self, connection):
|
|
||||||
first = current_salt(connection, now=T0)
|
|
||||||
later = current_salt(connection, now=T0 + timedelta(days=29, hours=23))
|
|
||||||
assert first == later
|
|
||||||
|
|
||||||
def test_salt_rotates_after_the_interval(self, connection):
|
|
||||||
first = current_salt(connection, now=T0)
|
|
||||||
after = current_salt(connection, now=T0 + ROTATION_INTERVAL + timedelta(seconds=1))
|
|
||||||
assert first != after
|
|
||||||
|
|
||||||
def test_salt_is_persisted(self, connection):
|
|
||||||
salt = current_salt(connection, now=T0)
|
|
||||||
stored = connection.execute(
|
|
||||||
"SELECT value FROM telemetry_state WHERE key = ?", (SALT_KEY,)
|
|
||||||
).fetchone()[0]
|
|
||||||
assert stored == salt
|
|
||||||
|
|
||||||
def test_issued_at_is_recorded(self, connection):
|
|
||||||
current_salt(connection, now=T0)
|
|
||||||
stored = connection.execute(
|
|
||||||
"SELECT value FROM telemetry_state WHERE key = ?", (SALT_ISSUED_AT_KEY,)
|
|
||||||
).fetchone()[0]
|
|
||||||
assert stored.startswith("2026-08-26T12:00")
|
|
||||||
|
|
||||||
def test_two_installs_get_different_salts(self):
|
|
||||||
salts = set()
|
|
||||||
for _ in range(5):
|
|
||||||
conn = sqlite3.connect(":memory:")
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE telemetry_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
||||||
)
|
|
||||||
salts.add(current_salt(conn, now=T0))
|
|
||||||
conn.close()
|
|
||||||
assert len(salts) == 5, "salts must be random per install, not derived"
|
|
||||||
|
|
||||||
def test_clock_rollback_reissues_rather_than_trusting_the_stamp(self, connection):
|
|
||||||
"""A future issued_at means the clock moved; the age is unknowable.
|
|
||||||
|
|
||||||
Reissuing is the safe direction — it shortens linkability rather than
|
|
||||||
extending it, and packages already prepared keep their frozen id.
|
|
||||||
"""
|
|
||||||
first = current_salt(connection, now=T0)
|
|
||||||
rolled_back = current_salt(connection, now=T0 - timedelta(days=5))
|
|
||||||
assert rolled_back != first
|
|
||||||
|
|
||||||
def test_corrupt_issued_at_reissues_rather_than_crashing(self, connection):
|
|
||||||
current_salt(connection, now=T0)
|
|
||||||
connection.execute(
|
|
||||||
"UPDATE telemetry_state SET value = 'not-a-date' WHERE key = ?",
|
|
||||||
(SALT_ISSUED_AT_KEY,),
|
|
||||||
)
|
|
||||||
assert current_salt(connection, now=T0) is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestDerivation:
|
|
||||||
def test_derivation_is_deterministic(self):
|
|
||||||
salt = "a" * 64
|
|
||||||
assert derive_install_id(INSTALL_ID, salt) == derive_install_id(INSTALL_ID, salt)
|
|
||||||
|
|
||||||
def test_derivation_hides_the_install_id(self):
|
|
||||||
derived = derive_install_id(INSTALL_ID, "a" * 64)
|
|
||||||
assert INSTALL_ID not in derived
|
|
||||||
assert derived != INSTALL_ID
|
|
||||||
|
|
||||||
def test_different_salts_give_different_values(self):
|
|
||||||
assert derive_install_id(INSTALL_ID, "a" * 64) != derive_install_id(
|
|
||||||
INSTALL_ID, "b" * 64
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_different_installs_give_different_values(self):
|
|
||||||
salt = "a" * 64
|
|
||||||
assert derive_install_id(INSTALL_ID, salt) != derive_install_id("other", salt)
|
|
||||||
|
|
||||||
def test_output_shape_is_sha256_hex(self):
|
|
||||||
derived = derive_install_id(INSTALL_ID, "a" * 64)
|
|
||||||
assert len(derived) == 64
|
|
||||||
int(derived, 16)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSubstitution:
|
|
||||||
def _package(self):
|
|
||||||
return {
|
|
||||||
"schema_version": "hermes.shared_metrics.v2",
|
|
||||||
"package_id": "3a63d27e-f170-4d4c-8c4d-ebd80feac592",
|
|
||||||
"install_id": INSTALL_ID,
|
|
||||||
"generated_at": "2026-08-26T01:01:25.311956Z",
|
|
||||||
"period_start": "2026-08-26T00:00:00Z",
|
|
||||||
"period_end": "2026-08-27T00:00:00Z",
|
|
||||||
"resource": {"hermes_version": "0.20.5", "os_family": "macos"},
|
|
||||||
"metrics": [{"name": "hermes.client.active", "type": "counter", "value": 1}],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_install_id_is_replaced(self):
|
|
||||||
result = substitute_install_id(self._package(), "derived-value")
|
|
||||||
assert result["install_id"] == "derived-value"
|
|
||||||
|
|
||||||
def test_no_other_field_changes(self):
|
|
||||||
original = self._package()
|
|
||||||
result = substitute_install_id(original, "derived-value")
|
|
||||||
for key in original:
|
|
||||||
if key != "install_id":
|
|
||||||
assert result[key] == original[key]
|
|
||||||
|
|
||||||
def test_the_caller_dict_is_not_mutated(self):
|
|
||||||
original = self._package()
|
|
||||||
substitute_install_id(original, "derived-value")
|
|
||||||
assert original["install_id"] == INSTALL_ID
|
|
||||||
|
|
||||||
def test_no_fields_are_added_or_removed(self):
|
|
||||||
original = self._package()
|
|
||||||
assert set(substitute_install_id(original, "x")) == set(original)
|
|
||||||
|
|
||||||
def test_the_raw_install_id_never_survives_substitution(self):
|
|
||||||
import json
|
|
||||||
|
|
||||||
body = json.dumps(substitute_install_id(self._package(), "derived-value"))
|
|
||||||
assert INSTALL_ID not in body
|
|
||||||
|
|
||||||
|
|
||||||
class TestRetryStability:
|
|
||||||
"""The property that keeps retries contract-compliant."""
|
|
||||||
|
|
||||||
def test_a_frozen_derived_id_survives_a_rotation(self, connection):
|
|
||||||
salt_before = current_salt(connection, now=T0)
|
|
||||||
frozen = derive_install_id(INSTALL_ID, salt_before)
|
|
||||||
|
|
||||||
# Time passes, the salt rotates, and the package is retried.
|
|
||||||
salt_after = current_salt(connection, now=T0 + ROTATION_INTERVAL + timedelta(days=1))
|
|
||||||
assert salt_after != salt_before
|
|
||||||
|
|
||||||
# Rebuilding from the FROZEN value reproduces identical bytes; deriving
|
|
||||||
# afresh would not.
|
|
||||||
assert substitute_install_id({"install_id": INSTALL_ID}, frozen) == {
|
|
||||||
"install_id": frozen
|
|
||||||
}
|
|
||||||
assert derive_install_id(INSTALL_ID, salt_after) != frozen
|
|
||||||
@@ -374,15 +374,19 @@ class TestConsentGate:
|
|||||||
|
|
||||||
|
|
||||||
class TestIdentity:
|
class TestIdentity:
|
||||||
def test_install_id_is_never_transmitted(self, store):
|
def test_the_stable_install_id_is_transmitted_as_is(self, store):
|
||||||
|
"""Product decision 2026-08-27: no pseudonymization.
|
||||||
|
|
||||||
|
The wire body carries the profile-scoped install_id verbatim. This
|
||||||
|
test is the deliberate inversion of the pre-decision assertion that
|
||||||
|
the raw id never crossed the wire.
|
||||||
|
"""
|
||||||
_add_package(store, "pkg-1", "2026-08-26")
|
_add_package(store, "pkg-1", "2026-08-26")
|
||||||
transport = FakeTransport(FakeResponse(202))
|
transport = FakeTransport(FakeResponse(202))
|
||||||
_sender(store, transport).send_pending()
|
_sender(store, transport).send_pending()
|
||||||
raw = transport.calls[0]["payload"].decode("utf-8")
|
assert transport.bodies[0]["install_id"] == INSTALL_ID
|
||||||
assert INSTALL_ID not in raw
|
|
||||||
assert transport.bodies[0]["install_id"] != INSTALL_ID
|
|
||||||
|
|
||||||
def test_derived_id_is_frozen_on_the_row(self, store):
|
def test_transmitted_id_is_frozen_on_the_row(self, store):
|
||||||
_add_package(store, "pkg-1", "2026-08-26")
|
_add_package(store, "pkg-1", "2026-08-26")
|
||||||
transport = FakeTransport(FakeResponse(503), FakeResponse(202))
|
transport = FakeTransport(FakeResponse(503), FakeResponse(202))
|
||||||
_sender(store, transport).send_pending()
|
_sender(store, transport).send_pending()
|
||||||
|
|||||||
@@ -171,12 +171,11 @@ class TestRealTransport:
|
|||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
assert state == "sent"
|
assert state == "sent"
|
||||||
|
|
||||||
def test_the_install_id_never_crosses_the_wire(self, store, server):
|
def test_the_stable_install_id_crosses_the_wire_as_is(self, store, server):
|
||||||
|
"""Product decision 2026-08-27: the raw install_id is transmitted."""
|
||||||
_add(store, "pkg-1", metrics=40)
|
_add(store, "pkg-1", metrics=40)
|
||||||
_sender(store, server).send_pending()
|
_sender(store, server).send_pending()
|
||||||
body = json.dumps(Ingest.received[0]["body"])
|
assert Ingest.received[0]["body"]["install_id"] == INSTALL_ID
|
||||||
assert INSTALL_ID not in body
|
|
||||||
assert len(Ingest.received[0]["body"]["install_id"]) == 64
|
|
||||||
|
|
||||||
def test_content_type_is_json(self, store, server):
|
def test_content_type_is_json(self, store, server):
|
||||||
_add(store, "pkg-1")
|
_add(store, "pkg-1")
|
||||||
|
|||||||
Reference in New Issue
Block a user