diff --git a/AGENTS.md b/AGENTS.md index e5416437b..b46c8a006 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,7 +205,7 @@ Each domain has exactly **one** write-owning module (or one tightly-scoped famil | Speaker labels (`chronicle/**/talents/speaker_labels.json`) | `solstone/apps/speakers/attribution.py` | | Speaker corrections (`chronicle/**/talents/speaker_corrections.json`) | `solstone/apps/speakers/attribution.py` | | Stream identity (`chronicle/**//stream.json` marker + `streams/.json` state) | `solstone/think/streams.py` | -| Link service state (`link/ca/cert.pem`, `link/ca/private.pem`, `link/nonces.json`, `link/authorized_clients.json`, `link/state.json`, `link/tokens/account.json`, `link/totp.json`) | `solstone/think/link/ca.py` + `solstone/think/link/nonces.py` + `solstone/think/link/auth.py` + `solstone/think/link/paths.py` | +| Link service state (`link/ca/cert.pem`, `link/ca/private.pem`, `link/ca-staging/**`, `link/nonces.json`, `link/authorized_clients.json`, `link/state.json` including optional `locked_at`, `link/tokens/account.json`, `link/totp.json`) | `solstone/think/link/ca.py` + `solstone/think/link/establish.py` + `solstone/think/link/nonces.py` + `solstone/think/link/auth.py` + `solstone/think/link/paths.py` | | Chronicle day content (`chronicle/YYYYMMDD/**`) | The capturing module (observer, importer) per its declared outputs | | Index (SQLite, `indexer/*`) | `solstone/think/indexer/*` | | Observer registry and sync history (`apps/observer/observers/*.json`, `apps/observer/observers/*/hist/*.jsonl`) | `solstone/apps/observer/utils.py` | diff --git a/docs/openapi/convey-clients.json b/docs/openapi/convey-clients.json index e982b72be..504a38f90 100644 --- a/docs/openapi/convey-clients.json +++ b/docs/openapi/convey-clients.json @@ -63,6 +63,7 @@ "file_read_failed", "health_report_failed", "identity_busy", + "identity_not_locked", "import_client_id_conflict", "import_conflict", "import_metadata_failed", diff --git a/scripts/check_journal_io_access.py b/scripts/check_journal_io_access.py index 15863d465..d8e752e70 100644 --- a/scripts/check_journal_io_access.py +++ b/scripts/check_journal_io_access.py @@ -120,6 +120,7 @@ OWNER_FILES: frozenset[str] = frozenset( # Link domain — device-pairing service state. "solstone/think/link/auth.py", "solstone/think/link/ca.py", + "solstone/think/link/establish.py", "solstone/think/link/nonces.py", "solstone/think/link/paths.py", "solstone/think/sense_splitter.py", diff --git a/solstone/convey/reasons.py b/solstone/convey/reasons.py index 2e3c866b7..d85045f67 100644 --- a/solstone/convey/reasons.py +++ b/solstone/convey/reasons.py @@ -95,6 +95,11 @@ CORRUPT_CONFIG = Reason( "I couldn't read your settings.", 500, ) +IDENTITY_NOT_LOCKED = Reason( + "identity_not_locked", + "I couldn't finish setup because the journal id is not locked.", + 400, +) INVALID_CONFIG_VALUE = Reason( "invalid_config_value", "I couldn't save that setting because one value was invalid.", diff --git a/solstone/convey/root.py b/solstone/convey/root.py index 8884013ca..d38d414c5 100644 --- a/solstone/convey/root.py +++ b/solstone/convey/root.py @@ -42,7 +42,12 @@ from .config import ( locked_modify_convey_config, seed_default_app_navigation, ) -from .reasons import INVALID_CONFIG_VALUE, PL_REVOKED +from .reasons import ( + IDENTITY_NOT_LOCKED, + INVALID_CONFIG_VALUE, + INVALID_OPERATION_FOR_STATE, + PL_REVOKED, +) from .secure_listener import get_authorized_clients from .secure_listener.wsgi import CERTLESS_PAIR_ENDPOINTS from .utils import error_response, error_response_with_reason @@ -75,6 +80,9 @@ def require_access() -> Any: if request.endpoint in { "root.init", + "root.init_mark", + "root.init_mark_regenerate", + "root.init_mark_lock", "root.init_validate_provider", "root.init_observers", "root.init_finalize", @@ -243,8 +251,50 @@ def init_observers() -> Any: ) +@bp.route("/init/mark") +def init_mark() -> Any: + from solstone.think.link import establish + + if establish.is_committed(): + mark = establish.committed_mark() + locked = True + else: + mark = establish.current_candidate_mark() + locked = False + return jsonify({"mark": mark.to_render_spec(), "locked": locked}) + + +@bp.route("/init/mark/regenerate", methods=["POST"]) +def init_mark_regenerate() -> Any: + from solstone.think.link import establish + + if establish.is_committed(): + return error_response_with_reason( + INVALID_OPERATION_FOR_STATE, + detail="journal id already locked", + ) + mark = establish.regenerate_candidate() + return jsonify({"mark": mark.to_render_spec(), "locked": False}) + + +@bp.route("/init/mark/lock", methods=["POST"]) +def init_mark_lock() -> Any: + from solstone.think.link import establish + + mark = establish.lock_in() + return jsonify({"mark": mark.to_render_spec(), "locked": True}) + + @bp.route("/init/finalize", methods=["POST"]) def init_finalize() -> Any: + from solstone.think.link import establish + + if not establish.is_committed(): + return error_response_with_reason( + IDENTITY_NOT_LOCKED, + detail="journal id must be locked before setup can finish", + ) + data = request.get_json(silent=True) or {} from solstone.think.utils import now_ms diff --git a/solstone/convey/tests/test_init_mark_routes.py b/solstone/convey/tests/test_init_mark_routes.py new file mode 100644 index 000000000..fc0fb491a --- /dev/null +++ b/solstone/convey/tests/test_init_mark_routes.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import uuid +from pathlib import Path +from typing import Any + +from solstone.think.link.ca import load_or_generate_ca +from solstone.think.link.paths import LinkState, ca_dir, state_path + + +def _commit_journal_identity() -> None: + load_or_generate_ca(ca_dir()) + + +def _read_config(journal: Path) -> dict[str, Any]: + return json.loads((journal / "config" / "journal.json").read_text("utf-8")) + + +def _assert_mark_shape(mark: dict[str, Any]) -> None: + assert set(mark) == {"icon1", "icon2", "words"} + assert {"name", "svg", "color", "rot"} <= set(mark["icon1"]) + assert {"name", "svg", "color", "rot"} <= set(mark["icon2"]) + assert len(mark["words"]) == 2 + + +def test_init_mark_returns_unlocked_candidate(convey_env_setup_pending) -> None: + env = convey_env_setup_pending() + + response = env.client.get("/init/mark") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["locked"] is False + _assert_mark_shape(payload["mark"]) + + +def test_init_mark_regenerate_changes_unlocked_candidate( + convey_env_setup_pending, +) -> None: + env = convey_env_setup_pending() + first = env.client.get("/init/mark").get_json()["mark"] + + response = env.client.post("/init/mark/regenerate") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["locked"] is False + _assert_mark_shape(payload["mark"]) + assert payload["mark"] != first + + +def test_init_mark_lock_is_idempotent(convey_env_setup_pending) -> None: + env = convey_env_setup_pending() + env.client.get("/init/mark") + + first = env.client.post("/init/mark/lock") + second = env.client.post("/init/mark/lock") + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.get_json()["locked"] is True + assert second.get_json()["locked"] is True + assert second.get_json()["mark"] == first.get_json()["mark"] + + +def test_legacy_lazy_journal_stays_locked_and_preserves_ca( + convey_env_setup_pending, +) -> None: + env = convey_env_setup_pending() + load_or_generate_ca(ca_dir()) + ca_path = ca_dir() + cert_before = (ca_path / "cert.pem").read_bytes() + key_before = (ca_path / "private.pem").read_bytes() + legacy_id = str(uuid.uuid4()) + LinkState(instance_id=legacy_id, home_label="legacy").save() + state_before = state_path().read_bytes() + + mark_response = env.client.get("/init/mark") + regenerate_response = env.client.post("/init/mark/regenerate") + + assert mark_response.status_code == 200 + assert mark_response.get_json()["locked"] is True + assert regenerate_response.status_code == 400 + assert ( + regenerate_response.get_json()["reason_code"] == "invalid_operation_for_state" + ) + assert (ca_path / "cert.pem").read_bytes() == cert_before + assert (ca_path / "private.pem").read_bytes() == key_before + assert state_path().read_bytes() == state_before + + +def test_finalize_requires_locked_identity_before_config_mutation( + convey_env_setup_pending, +) -> None: + env = convey_env_setup_pending() + before_config = _read_config(env.journal) + convey_config = env.journal / "config" / "convey.json" + assert not convey_config.exists() + + blocked = env.client.post( + "/init/finalize", + json={"name": "Blocked", "retention_mode": "processed"}, + content_type="application/json", + ) + + assert blocked.status_code == 400 + assert blocked.get_json()["reason_code"] == "identity_not_locked" + assert _read_config(env.journal) == before_config + assert not convey_config.exists() + + _commit_journal_identity() + allowed = env.client.post( + "/init/finalize", + json={"name": "Allowed"}, + content_type="application/json", + ) + + assert allowed.status_code == 200 + assert allowed.get_json()["success"] is True diff --git a/solstone/convey/tests/test_init_template.py b/solstone/convey/tests/test_init_template.py index bf5e9fe61..7324b4f7b 100644 --- a/solstone/convey/tests/test_init_template.py +++ b/solstone/convey/tests/test_init_template.py @@ -35,6 +35,13 @@ def _write_config(journal: Path, config: dict[str, Any]) -> None: (journal / "config" / "journal.json").write_text(json.dumps(config, indent=2)) +def _commit_journal_identity() -> None: + from solstone.think.link.ca import load_or_generate_ca + from solstone.think.link.paths import ca_dir + + load_or_generate_ca(ca_dir()) + + def _finalize_body(gemini_key: str) -> dict[str, Any]: return { "name": "Setup Test", @@ -96,6 +103,7 @@ def test_finalize_empty_gemini_key_preserves_existing_scout_config( config.setdefault("env", {})["GOOGLE_API_KEY"] = "SCOUT_FIXTURE" config.setdefault("services", {})["scout"] = scout_block.copy() _write_config(env.journal, config) + _commit_journal_identity() response = env.client.post( "/init/finalize", @@ -112,6 +120,7 @@ def test_finalize_empty_gemini_key_preserves_existing_scout_config( def test_finalize_manual_paste_writes_gemini_key(convey_env_setup_pending) -> None: env = convey_env_setup_pending() + _commit_journal_identity() response = env.client.post( "/init/finalize", diff --git a/solstone/think/link/ca.py b/solstone/think/link/ca.py index 6508569c3..de6b11327 100644 --- a/solstone/think/link/ca.py +++ b/solstone/think/link/ca.py @@ -67,9 +67,9 @@ def generate_ca( ) -> LoadedCa: """Generate a fresh ECDSA-P256 CA and write it to disk. - Writes `/cert.pem` (world-readable) + `/private.pem` - (mode 0600). No passphrase — filesystem perms + disk encryption are - the protection surface per the spec. + Writes `/private.pem` (mode 0600), then `/cert.pem` + (world-readable). No passphrase — filesystem perms + disk encryption + are the protection surface per the spec. """ private_key = ec.generate_private_key(ec.SECP256R1()) now = dt.datetime.now(dt.UTC) @@ -104,8 +104,8 @@ def generate_ca( cert_path = _cert_path(ca_dir) key_path = _key_path(ca_dir) - atomic_replace(cert_path, cert.public_bytes(serialization.Encoding.PEM)) _write_key(key_path, private_key) + atomic_replace(cert_path, cert.public_bytes(serialization.Encoding.PEM)) return _materialize(cert, private_key) @@ -120,13 +120,29 @@ def load_ca(ca_dir: Path) -> LoadedCa: return _materialize(cert, key) +def ca_is_present(ca_dir: Path) -> bool: + """Return whether both permanent CA files are present.""" + return _cert_path(ca_dir).exists() and _key_path(ca_dir).exists() + + def load_or_generate_ca(ca_dir: Path) -> LoadedCa: """Return an existing CA if present; otherwise generate a fresh one.""" - if _cert_path(ca_dir).exists() and _key_path(ca_dir).exists(): + if ca_is_present(ca_dir): return load_ca(ca_dir) return generate_ca(ca_dir) +def promote_ca(staging_dir: Path, permanent_dir: Path) -> LoadedCa: + """Promote a valid staged CA into the permanent CA location.""" + staging = load_ca(staging_dir) + _write_key(_key_path(permanent_dir), staging.private_key) + atomic_replace( + _cert_path(permanent_dir), + staging.cert.public_bytes(serialization.Encoding.PEM), + ) + return load_ca(permanent_dir) + + def sign_csr( ca: LoadedCa, csr_pem: str | bytes, diff --git a/solstone/think/link/establish.py b/solstone/think/link/establish.py new file mode 100644 index 000000000..e2d8024f1 --- /dev/null +++ b/solstone/think/link/establish.py @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Onboarding journal identity establishment. + +This module owns the staged candidate CA lifecycle, regeneration, crash-safe +lock-in, and self-certifying instance_id derivation. It performs no raw journal +I/O: content writes delegate to ca.py and paths.LinkState.save. Its only +journal_io primitive is hold_lock; its only direct filesystem mutation is +best-effort staging cleanup. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from cryptography.hazmat.primitives import serialization + +from solstone.think.journal_io import hold_lock +from solstone.think.link.ca import ( + LoadedCa, + ca_is_present, + generate_ca, + load_ca, + load_or_generate_ca, + promote_ca, +) +from solstone.think.link.mark import Mark, jid_from_spki, mark_from_spki +from solstone.think.link.paths import LinkState, ca_dir, link_root, staging_dir +from solstone.think.utils import now_ms + + +def _identity_lock_path() -> Path: + return link_root() / "identity" + + +def _spki_der(ca: LoadedCa) -> bytes: + return ca.cert.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + +def _valid_staging_or_none() -> LoadedCa | None: + try: + return load_ca(staging_dir()) + except (OSError, ValueError): + return None + + +def _regenerate_staging() -> LoadedCa: + return generate_ca(staging_dir()) + + +def is_committed() -> bool: + """Return whether the permanent journal identity CA is committed.""" + return ca_is_present(ca_dir()) + + +def committed_mark() -> Mark: + """Return the mark derived from the committed permanent CA.""" + return mark_from_spki(_spki_der(load_ca(ca_dir()))) + + +def current_candidate_mark() -> Mark: + """Return the current staged candidate mark, regenerating invalid staging.""" + with hold_lock(_identity_lock_path()): + candidate = _valid_staging_or_none() or _regenerate_staging() + return mark_from_spki(_spki_der(candidate)) + + +def regenerate_candidate() -> Mark: + """Generate a fresh staged candidate and return its mark.""" + with hold_lock(_identity_lock_path()): + return mark_from_spki(_spki_der(_regenerate_staging())) + + +def lock_in() -> Mark: + """Commit the staged candidate CA and persist a locked state when needed.""" + with hold_lock(_identity_lock_path()): + if not is_committed(): + # Ensure promote_ca can reload a valid staged CA from disk. + _valid_staging_or_none() or _regenerate_staging() + promote_ca(staging_dir(), ca_dir()) + + ca = load_ca(ca_dir()) + spki = _spki_der(ca) + jid = str(jid_from_spki(spki)) + existing = LinkState.load() + if existing is None or not existing.instance_id: + LinkState( + instance_id=jid, + home_label="solstone", + locked_at=now_ms(), + ).save() + + shutil.rmtree(staging_dir(), ignore_errors=True) + return mark_from_spki(spki) + + +def create_link_state(default_label: str = "solstone") -> LinkState: + """Create the lazy LinkState under the identity lock.""" + with hold_lock(_identity_lock_path()): + existing = LinkState.load(default_label=default_label) + if existing is not None: + return existing + + ca = load_or_generate_ca(ca_dir()) + jid = str(jid_from_spki(_spki_der(ca))) + state = LinkState(instance_id=jid, home_label=default_label) + state.save() + return state diff --git a/solstone/think/link/paths.py b/solstone/think/link/paths.py index c6b4a7179..d9bc42d4c 100644 --- a/solstone/think/link/paths.py +++ b/solstone/think/link/paths.py @@ -27,7 +27,6 @@ import base64 import json import os import secrets -import uuid from dataclasses import dataclass from pathlib import Path @@ -52,6 +51,11 @@ def ca_dir() -> Path: return d +def staging_dir() -> Path: + """Pure path for staged candidate CA material.""" + return Path(get_journal()) / "link" / "ca-staging" + + def authorized_clients_path() -> Path: return link_root() / "authorized_clients.json" @@ -106,6 +110,11 @@ class LinkState: instance_id: str home_label: str + locked_at: int | None = None + + @property + def jid(self) -> str: + return self.instance_id @classmethod def load_or_create(cls, *, default_label: str = "solstone") -> LinkState: @@ -115,13 +124,15 @@ class LinkState: raw = json.loads(path.read_text("utf-8")) iid = raw.get("instance_id") label = raw.get("home_label") or default_label + value = raw.get("locked_at") + locked_at = value if isinstance(value, int) else None if isinstance(iid, str) and iid: - return cls(instance_id=iid, home_label=label) + return cls(instance_id=iid, home_label=label, locked_at=locked_at) except (json.JSONDecodeError, OSError): pass - state = cls(instance_id=str(uuid.uuid4()), home_label=default_label) - state.save() - return state + from solstone.think.link import establish + + return establish.create_link_state(default_label=default_label) @classmethod def load(cls, *, default_label: str = "solstone") -> LinkState | None: @@ -133,18 +144,22 @@ class LinkState: raw = json.loads(path.read_text("utf-8")) iid = raw.get("instance_id") label = raw.get("home_label") or default_label + value = raw.get("locked_at") + locked_at = value if isinstance(value, int) else None if isinstance(iid, str) and iid: - return cls(instance_id=iid, home_label=label) + return cls(instance_id=iid, home_label=label, locked_at=locked_at) except (json.JSONDecodeError, OSError): return None return None def save(self) -> None: - write_json( - state_path(), - {"instance_id": self.instance_id, "home_label": self.home_label}, - indent=2, - ) + payload: dict[str, object] = { + "instance_id": self.instance_id, + "home_label": self.home_label, + } + if self.locked_at is not None: + payload["locked_at"] = self.locked_at + write_json(state_path(), payload, indent=2) def load_service_token() -> str | None: diff --git a/tests/link/test_ca.py b/tests/link/test_ca.py index 8e09cdd0f..ec56525af 100644 --- a/tests/link/test_ca.py +++ b/tests/link/test_ca.py @@ -18,17 +18,27 @@ from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature from solstone.think.link.ca import ( + LoadedCa, _write_key, + ca_is_present, cert_fingerprint, generate_ca, load_ca, load_or_generate_ca, mint_attestation, mint_reach_assertion, + promote_ca, sign_csr, ) +def _spki_der(ca: LoadedCa) -> bytes: + return ca.cert.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def test_generate_and_reload(tmp_path: Path) -> None: ca_dir = tmp_path / "ca" generated = generate_ca(ca_dir) @@ -49,6 +59,34 @@ def test_load_or_generate_is_idempotent(tmp_path: Path) -> None: assert first.fingerprint_sha256() == second.fingerprint_sha256() +def test_ca_is_present_requires_cert_and_key(tmp_path: Path) -> None: + ca_dir = tmp_path / "ca" + + assert ca_is_present(ca_dir) is False + generate_ca(ca_dir) + assert ca_is_present(ca_dir) is True + + (ca_dir / "cert.pem").unlink() + assert ca_is_present(ca_dir) is False + + generate_ca(ca_dir) + (ca_dir / "private.pem").unlink() + assert ca_is_present(ca_dir) is False + + +def test_promote_ca_copies_staged_ca_to_permanent(tmp_path: Path) -> None: + staging = tmp_path / "staging" + permanent = tmp_path / "permanent" + staged = generate_ca(staging) + + promoted = promote_ca(staging, permanent) + + assert _spki_der(promoted) == _spki_der(staged) + assert (permanent / "cert.pem").exists() + assert (permanent / "private.pem").exists() + assert (permanent / "private.pem").stat().st_mode & 0o777 == 0o600 + + def test_generate_ca_write_is_atomic(tmp_path: Path, monkeypatch) -> None: ca_dir = tmp_path / "ca" generate_ca(ca_dir) diff --git a/tests/link/test_establish.py b/tests/link/test_establish.py new file mode 100644 index 000000000..cd111de8e --- /dev/null +++ b/tests/link/test_establish.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import uuid +from pathlib import Path + +from cryptography.hazmat.primitives import serialization + +from solstone.think.link import establish +from solstone.think.link.ca import LoadedCa, load_ca, load_or_generate_ca +from solstone.think.link.mark import Mark, jid_from_spki +from solstone.think.link.paths import LinkState, ca_dir, staging_dir, state_path + + +def _set_journal(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + import solstone.think.utils as think_utils + + think_utils._journal_path_cache = None + + +def _spki_der(ca: LoadedCa) -> bytes: + return ca.cert.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + +def _derived_jid(ca: LoadedCa) -> str: + return str(jid_from_spki(_spki_der(ca))) + + +def test_lock_in_persists_id_derived_from_permanent_ca( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + + establish.regenerate_candidate() + establish.lock_in() + + permanent = load_ca(ca_dir()) + state = LinkState.load() + assert state is not None + assert state.instance_id == _derived_jid(permanent) + + +def test_lock_in_preserves_legacy_random_state_id( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + load_or_generate_ca(ca_dir()) + legacy_id = str(uuid.uuid4()) + LinkState(instance_id=legacy_id, home_label="legacy").save() + before = state_path().read_bytes() + + establish.lock_in() + + assert state_path().read_bytes() == before + state = LinkState.load() + assert state is not None + assert state.instance_id == legacy_id + assert state.locked_at is None + + +def test_lock_in_self_heals_missing_state_from_committed_ca( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + permanent = load_or_generate_ca(ca_dir()) + assert not state_path().exists() + + establish.lock_in() + + state = LinkState.load() + assert state is not None + assert state.instance_id == _derived_jid(permanent) + assert state.locked_at is not None + + +def test_regenerate_candidate_changes_mark_without_committing_files( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + + first = establish.regenerate_candidate() + second = establish.regenerate_candidate() + + assert isinstance(first, Mark) + assert isinstance(second, Mark) + assert second != first + assert not (tmp_path / "link" / "ca" / "cert.pem").exists() + assert not (tmp_path / "link" / "ca" / "private.pem").exists() + assert not (tmp_path / "link" / "state.json").exists() + + +def test_corrupt_staging_candidate_is_repaired( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + staging = staging_dir() + staging.mkdir(parents=True) + (staging / "cert.pem").write_text("not a cert", encoding="utf-8") + (staging / "private.pem").write_text("not a key", encoding="utf-8") + + mark = establish.current_candidate_mark() + regenerated = establish.regenerate_candidate() + + assert isinstance(mark, Mark) + assert isinstance(regenerated, Mark) + load_ca(staging_dir()) + + +def test_lock_in_is_one_way_idempotent_and_private( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + + establish.current_candidate_mark() + first_mark = establish.lock_in() + state = LinkState.load() + assert state is not None + first_id = state.instance_id + first_locked_at = state.locked_at + first_ca = load_ca(ca_dir()) + + second_mark = establish.lock_in() + second_state = LinkState.load() + assert second_state is not None + + assert isinstance(first_mark, Mark) + assert second_mark == first_mark + assert first_id == _derived_jid(first_ca) + assert second_state.instance_id == first_id + assert second_state.locked_at == first_locked_at + assert (ca_dir() / "cert.pem").exists() + assert (ca_dir() / "private.pem").exists() + assert (ca_dir() / "private.pem").stat().st_mode & 0o777 == 0o600 + + +def test_uuidv8_round_trip_and_jid_accessor( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + + establish.lock_in() + state = LinkState.load() + assert state is not None + parsed = uuid.UUID(state.instance_id) + + assert parsed.version == 8 + assert uuid.UUID(bytes=parsed.bytes) == parsed + assert state.jid == state.instance_id + + +def test_create_link_state_uses_permanent_ca_without_locked_at( + tmp_path: Path, + monkeypatch, +) -> None: + _set_journal(monkeypatch, tmp_path) + + state = establish.create_link_state(default_label="laptop") + permanent = load_ca(ca_dir()) + payload = json.loads(state_path().read_text("utf-8")) + + assert state.instance_id == _derived_jid(permanent) + assert state.home_label == "laptop" + assert state.locked_at is None + assert "locked_at" not in payload diff --git a/tests/test_convey_config.py b/tests/test_convey_config.py index aed3b6f6f..0e53b2e51 100644 --- a/tests/test_convey_config.py +++ b/tests/test_convey_config.py @@ -28,6 +28,13 @@ def _read_convey_config(journal: Path) -> dict: return json.loads((journal / "config" / "convey.json").read_text("utf-8")) +def _commit_journal_identity() -> None: + from solstone.think.link.ca import load_or_generate_ca + from solstone.think.link.paths import ca_dir + + load_or_generate_ca(ca_dir()) + + def test_reporting_enabled_defaults_true_when_absent(monkeypatch, tmp_path): from solstone.convey.config import reporting_enabled @@ -157,6 +164,7 @@ def test_init_finalize_seeds_default_app_navigation(journal_copy): (journal_copy / "config" / "convey.json").unlink() app = create_app(str(journal_copy)) app.config["TESTING"] = True + _commit_journal_identity() resp = app.test_client().post( "/init/finalize", @@ -189,6 +197,7 @@ def test_init_finalize_logs_convey_seed_persist_failure( caplog.set_level(logging.ERROR, logger="solstone.convey.root") app = create_app(str(journal_copy)) app.config["TESTING"] = True + _commit_journal_identity() resp = app.test_client().post( "/init/finalize", diff --git a/tests/test_init.py b/tests/test_init.py index c2dab18de..f7c2ade92 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -31,6 +31,13 @@ def _make_empty_client(tmp_path, monkeypatch, *, timezone="America/Denver"): return app.test_client(), journal +def _commit_journal_identity() -> None: + from solstone.think.link.ca import load_or_generate_ca + from solstone.think.link.paths import ca_dir + + load_or_generate_ca(ca_dir()) + + def _clear_setup(journal_dir): config = _read_config(journal_dir) config.pop("setup", None) @@ -528,6 +535,7 @@ class TestInitFinalize: """Tests for the atomic finalize endpoint.""" def test_finalize_saves_all_config(self, fresh_client, journal_copy): + _commit_journal_identity() resp = fresh_client.post( "/init/finalize", json={ @@ -555,6 +563,7 @@ class TestInitFinalize: assert "completed_at" in config["setup"] def test_finalize_succeeds(self, fresh_client, journal_copy): + _commit_journal_identity() resp = fresh_client.post( "/init/finalize", json={"name": "Jane"}, @@ -570,6 +579,7 @@ class TestInitFinalize: def test_finalize_minimal(self, fresh_client, journal_copy): """Finalize with optional fields omitted.""" + _commit_journal_identity() resp = fresh_client.post( "/init/finalize", json={}, @@ -586,6 +596,7 @@ class TestInitFinalize: tmp_path, monkeypatch, timezone="America/Denver" ) client.get("/init") + _commit_journal_identity() resp = client.post( "/init/finalize", @@ -611,6 +622,7 @@ class TestInitFinalize: tmp_path, monkeypatch, timezone="America/Denver" ) client.get("/init") + _commit_journal_identity() resp = client.post( "/init/finalize", @@ -626,6 +638,7 @@ class TestInitFinalize: assert "completed_at" in config["setup"] def test_finalize_completes_setup_access(self, fresh_client, journal_copy): + _commit_journal_identity() response = fresh_client.post( "/init/finalize", json={}, @@ -643,6 +656,7 @@ class TestInitFinalize: def test_post_init_redirect(self, fresh_client, journal_copy): """After finalize, /init redirects away.""" + _commit_journal_identity() fresh_client.post( "/init/finalize", json={}, @@ -653,6 +667,7 @@ class TestInitFinalize: def test_finalize_with_retention_config(self, fresh_client, journal_copy): """Finalize with explicit retention config writes correct values.""" + _commit_journal_identity() resp = fresh_client.post( "/init/finalize", json={ @@ -668,6 +683,7 @@ class TestInitFinalize: def test_finalize_default_retention(self, fresh_client, journal_copy): """Finalize without retention fields writes default (keep/null).""" + _commit_journal_identity() resp = fresh_client.post( "/init/finalize", json={}, @@ -681,6 +697,7 @@ class TestInitFinalize: def test_finalize_corrupt_config_returns_reason_without_writing( self, fresh_client, journal_copy ): + _commit_journal_identity() config_path = journal_copy / "config" / "journal.json" config_path.write_bytes(b"{ invalid json }") before = config_path.read_bytes()