| # M01 β Identity & Manifests |
|
|
| **Spec version:** v1.0 |
| **Depends on:** X04 (config), PyNaCl, cryptography (for TLS cert generation) |
| **Depended on by:** X02 (events), X01 (transport), M02 (discovery), M03 (bus), and every service that signs anything |
|
|
| --- |
|
|
| ## 1. Responsibility |
|
|
| - Cryptographic identity (Ed25519 keypair) for one device |
| - Signing and verification of arbitrary canonical-JSON payloads |
| - Creation, signing, verification of **node manifests** |
| - Creation, signing, verification of **community manifests** |
| - Canonical JSON encoding (the one that signatures are computed over) |
| - TLS certificate generation (self-signed, pinned to the device key on first contact) |
| - Optional: capability tokens (Phase 2) |
|
|
| This module owns no event log β that is X02. It owns no networking β that is X01. It owns only crypto and document formats. |
|
|
| --- |
|
|
| ## 2. File layout |
|
|
| ``` |
| hearthnet/identity/ |
| βββ __init__.py # re-exports |
| βββ keys.py # KeyPair, canonical_json, sign, verify, TLS cert |
| βββ manifest.py # NodeManifest, CommunityManifest, builders, verifiers |
| βββ tokens.py # Phase 2; stubs only in MVP |
| ``` |
|
|
| --- |
|
|
| ## 3. Public API |
|
|
| ### 3.1 `keys.py` |
|
|
| ```python |
| # hearthnet/identity/keys.py |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any |
| |
| NodeID = str # "ed25519:XXXX-XXXX-XXXX-XXXX" (short) or "ed25519:<base64-url-nopad>" (full) |
| Signature = str # "ed25519:<base64-url-nopad>" |
| |
| @dataclass(frozen=True) |
| class KeyPair: |
| """Wraps Ed25519 signing key + verify key plus their string forms.""" |
| signing_key: nacl.signing.SigningKey |
| verify_key: nacl.signing.VerifyKey |
| node_id_full: str # "ed25519:<base64-url-nopad of 32 bytes>" |
| node_id_short: str # "ed25519:XXXX-XXXX-XXXX-XXXX" (8 bytes, base32, 4-grouped) |
| |
| def sign(self, payload: dict) -> dict: |
| """Return a new dict equal to payload with signature field added.""" |
| |
| def sign_bytes(self, data: bytes) -> Signature: |
| """Sign raw bytes; return 'ed25519:<base64-url-nopad>'.""" |
| |
| # --- generation / loading --- |
| |
| def generate() -> KeyPair: |
| """Create a fresh Ed25519 keypair (uses os.urandom).""" |
| |
| def load(keys_dir: Path) -> KeyPair: |
| """Load device.ed25519 + device.pub from keys_dir. |
| Raises IdentityError('keys_missing') if not present, |
| IdentityError('keys_invalid') if malformed. |
| Validates permissions are 0600 on Unix.""" |
| |
| def load_or_generate(keys_dir: Path) -> KeyPair: |
| """Load if present; otherwise generate and persist.""" |
| |
| def save(kp: KeyPair, keys_dir: Path) -> None: |
| """Persist signing key as device.ed25519 (0600) and verify key as device.pub.""" |
| |
| # --- public-key handling --- |
| |
| def short_node_id(verify_key_bytes: bytes) -> str: |
| """First 8 bytes base32, grouped: 'ed25519:XXXX-XXXX-XXXX-XXXX'.""" |
| |
| def full_node_id(verify_key_bytes: bytes) -> str: |
| """All 32 bytes, base64-url no pad, prefixed: 'ed25519:<b64>'.""" |
| |
| def parse_node_id(node_id: str) -> bytes: |
| """Decode the full form back to 32 bytes. Short form raises ValueError.""" |
| |
| def verify_key_from_full(node_id_full: str) -> nacl.signing.VerifyKey: |
| """Convenience: parse and wrap.""" |
| |
| # --- canonical JSON --- |
| |
| def canonical_json(obj: Any) -> bytes: |
| """Sorted keys, no whitespace, no trailing zeros on numbers, UTF-8. |
| Used everywhere a signature is computed.""" |
| |
| # --- signing / verification --- |
| |
| def sign_payload(payload: dict, kp: KeyPair) -> dict: |
| """Add a 'signature' field over canonical_json(payload \\ {signature}).""" |
| |
| def verify_payload(payload: dict, vk: nacl.signing.VerifyKey) -> bool: |
| """True iff the 'signature' field validates over canonical_json(payload \\ {signature}).""" |
| |
| def verify_payload_with_node_id(payload: dict, expected_node_id_full: str) -> bool: |
| """Convenience: derive verify key from node_id and call verify_payload.""" |
| |
| # --- TLS --- |
| |
| def generate_self_signed_cert(kp: KeyPair, host: str = "0.0.0.0") -> tuple[bytes, bytes]: |
| """Generate an X.509 self-signed cert+key for TLS. The CN includes the short node id. |
| Returns (cert_pem, key_pem). The cert is rotated when the device key changes (i.e. never).""" |
| |
| class IdentityError(Exception): |
| """code in {'keys_missing','keys_invalid','keys_permissions','bad_node_id','sign_failed','verify_failed'}""" |
| code: str |
| ``` |
|
|
| ### 3.2 `manifest.py` |
|
|
| ```python |
| # hearthnet/identity/manifest.py |
| from dataclasses import dataclass, field |
| from typing import Any |
| from datetime import datetime, timezone, timedelta |
| |
| # ---- types ---- |
| |
| @dataclass(frozen=True) |
| class Endpoint: |
| transport: str # "https" |
| host: str |
| port: int |
| |
| @dataclass(frozen=True) |
| class HardwareSpec: |
| gpu: str | None |
| vram_gb: float |
| ram_gb: float |
| cpu_cores: int |
| disk_free_gb: float |
| |
| @dataclass(frozen=True) |
| class CapabilitySpec: |
| """As it appears INSIDE a node manifest (subset of the full descriptor in M03).""" |
| name: str |
| version: str # "1.0" |
| stability: str # "stable" | "beta" | "experimental" |
| schema_hash: str # "blake3:..." |
| params: dict[str, Any] |
| max_concurrent: int |
| |
| @dataclass(frozen=True) |
| class NodeManifest: |
| version: int # always 1 for this contract version |
| contract_version: str # "1.0" |
| node_id: str # full form |
| display_name: str |
| community_id: str |
| profile: str # "anchor" | "hearth" | "spark" | "bridge" |
| endpoints: list[Endpoint] |
| hardware: HardwareSpec |
| capabilities: list[CapabilitySpec] |
| uptime_seconds: int |
| load: dict[str, Any] |
| issued_at: str # RFC 3339 |
| expires_at: str # RFC 3339 |
| signature: str |
| |
| def as_dict(self) -> dict: ... |
| def is_expired(self, now: datetime | None = None) -> bool: ... |
| |
| # ---- node manifest builder & verifier ---- |
| |
| def build_node_manifest( |
| kp: KeyPair, |
| community_id: str, |
| display_name: str, |
| profile: str, |
| endpoints: list[Endpoint], |
| hardware: HardwareSpec, |
| capabilities: list[CapabilitySpec], |
| uptime_seconds: int, |
| load: dict[str, Any], |
| ) -> NodeManifest: |
| """Build and sign a fresh node manifest. issued_at = now, |
| expires_at = now + MANIFEST_TTL_SECONDS.""" |
| |
| def parse_node_manifest(blob: bytes | dict) -> NodeManifest: |
| """Parse JSON bytes or dict into a typed NodeManifest. Does NOT verify signature.""" |
| |
| def verify_node_manifest(manifest: NodeManifest, *, now: datetime | None = None) -> None: |
| """Verify signature + expiry. Raises IdentityError on failure: |
| 'invalid_signature' | 'expired' | 'bad_manifest' (malformed structure).""" |
| |
| # ---- community manifest ---- |
| |
| @dataclass(frozen=True) |
| class CommunityPolicy: |
| min_signatures_to_invite: int |
| min_signatures_to_demote: int |
| min_signatures_to_revoke: int |
| capability_token_ttl_seconds: int |
| federation_enabled: bool |
| default_member_can_invite: bool |
| |
| @dataclass(frozen=True) |
| class CommunityMember: |
| node_id: str |
| level: str # "anchor" | "trusted" | "member" |
| added_at: str |
| added_by: str |
| |
| @dataclass(frozen=True) |
| class RevokedEntry: |
| node_id: str |
| revoked_at: str |
| |
| @dataclass(frozen=True) |
| class CommunityManifest: |
| version: int |
| community_id: str |
| name: str |
| root_key: str |
| created_at: str |
| lamport_at_creation: int |
| policy: CommunityPolicy |
| members: list[CommunityMember] |
| revoked: list[RevokedEntry] |
| head_lamport: int |
| signature: str |
| |
| def is_member(self, node_id: str) -> bool: ... |
| def level_of(self, node_id: str) -> str | None: ... |
| def is_revoked(self, node_id: str) -> bool: ... |
| |
| def build_community_manifest( |
| root_kp: KeyPair, |
| name: str, |
| policy: CommunityPolicy, |
| ) -> CommunityManifest: |
| """Build and sign the genesis community manifest. lamport_at_creation = 0, head_lamport = 0, |
| members = [root as 'anchor'], revoked = [].""" |
| |
| def regenerate_community_manifest_from_state( |
| materialised_state: dict, |
| signing_kp: KeyPair, |
| ) -> CommunityManifest: |
| """Given a materialised view from the event log, produce a fresh signed manifest. |
| Signing key may be any anchor's key, not just root.""" |
| |
| def parse_community_manifest(blob: bytes | dict) -> CommunityManifest: ... |
| def verify_community_manifest(cm: CommunityManifest) -> None: ... |
| ``` |
|
|
| ### 3.3 `tokens.py` (Phase 2 β stubs only in MVP) |
|
|
| ```python |
| # hearthnet/identity/tokens.py |
| @dataclass(frozen=True) |
| class CapabilityToken: |
| issuer: str # NodeID issuing the token |
| subject: str # NodeID who may use it |
| capability: str # "rag.query" |
| issued_at: str |
| expires_at: str |
| nonce: str # ULID |
| signature: str |
| |
| def issue_token(...) -> CapabilityToken: ... # Phase 2 |
| def verify_token(token: CapabilityToken, expected_issuer: str) -> None: ... # Phase 2 |
| ``` |
|
|
| --- |
|
|
| ## 4. On-disk format |
|
|
| ``` |
| <DATA>/keys/ |
| βββ device.ed25519 # raw 32-byte signing key, no header, 0600 |
| βββ device.pub # raw 32-byte verify key, 0644 |
| ``` |
|
|
| Single-key design (no rotation, no chain). Rationale: a community member's key is their identity; rotation creates a new identity. If a key is compromised, the right action is to revoke that NodeID and the user re-onboards with a new key. |
|
|
| --- |
|
|
| ## 5. Behaviour |
|
|
| ### 5.1 First-run flow |
|
|
| ``` |
| load_or_generate(keys_dir) β |
| if device.ed25519 exists: |
| read, validate permissions, parse β KeyPair |
| else: |
| generate β save β KeyPair |
| ``` |
|
|
| ### 5.2 Manifest TTL and republish |
|
|
| The node orchestrator (`node.py`) republishes manifest every `MANIFEST_REPUBLISH_INTERVAL_SECONDS` (20s). Receivers consider any manifest with `expires_at < now` as `expired` and drop it. |
|
|
| ### 5.3 Verification budget |
|
|
| Verification is cheap (Ed25519 is fast), but at scale (1000 events/sec) it can add up. Verifying nodes cache `(payload_hash, sig, result)` for 60 seconds to avoid re-verifying identical bytes. |
|
|
| ### 5.4 Pinned keys |
|
|
| The first time we see a NodeID's verify key, we pin it in memory. A subsequent manifest with the same NodeID but a different verify key is rejected with `invalid_signature` and logged at `warning`. This is TOFU (trust-on-first-use) and is acceptable inside a community where invites carry the verify key. |
|
|
| --- |
|
|
| ## 6. Errors |
|
|
| All errors raise `IdentityError` with a `code` in: `keys_missing`, `keys_invalid`, `keys_permissions`, `bad_node_id`, `sign_failed`, `verify_failed`, `bad_manifest`, `expired`, `invalid_signature`. |
|
|
| These codes map cleanly onto the wire error codes in [CONTRACT Β§9](../CAPABILITY_CONTRACT.md). |
|
|
| --- |
|
|
| ## 7. Configuration |
|
|
| From [X04 Β§3](../cross-cutting/X04-config.md): |
|
|
| ```python |
| config.identity.keys_dir # where keys live |
| config.identity.auto_generate # default True |
| ``` |
|
|
| --- |
|
|
| ## 8. Tests |
|
|
| ### Unit |
| - `test_keypair_roundtrip` β save / load returns equal keys |
| - `test_short_node_id_format` β matches `ed25519:[A-Z2-7]{4}(-[A-Z2-7]{4}){3}` |
| - `test_canonical_json_is_stable` β sort + no-whitespace is deterministic |
| - `test_canonical_json_numbers` β `1.0`, `1.00`, `1` all canonicalise to `1` |
| - `test_sign_verify_roundtrip` β random payloads |
| - `test_verify_fails_on_modified_field` β flip one byte, verify returns False |
| - `test_node_manifest_expiry` β exact TTL boundary behaviour |
| - `test_community_manifest_genesis` β root key is sole anchor |
| - `test_tls_cert_includes_short_node_id` β CN match |
|
|
| ### Integration |
| - `test_manifest_round_trip_over_http` β server emits, client receives, parses, verifies |
| - `test_tofu_key_pinning` β second different key for same NodeID is rejected |
|
|
| --- |
|
|
| ## 9. Cross-references |
|
|
| | What | Where | |
| |------|-------| |
| | Wire signing rules | [CONTRACT Β§1.3, Β§5.1](../CAPABILITY_CONTRACT.md) | |
| | Canonical JSON | [CONTRACT Β§1.2](../CAPABILITY_CONTRACT.md) | |
| | Node manifest schema | [CONTRACT Β§6.1](../CAPABILITY_CONTRACT.md) | |
| | Community manifest schema | [CONTRACT Β§6.2](../CAPABILITY_CONTRACT.md) | |
| | Used by event signing | [X02 Β§3](../cross-cutting/X02-events.md) | |
| | Used by transport TLS pinning | [X01 Β§4](../cross-cutting/X01-transport.md) | |
| | Used by bus per-request verification | [M03 Β§5.6](M03-bus.md) | |
|
|
| --- |
|
|
| ## 10. Open questions |
|
|
| 1. **Mobile clients store keys in `localStorage`** β not great. Phase 2: WebCrypto + IndexedDB. |
| 2. **Key rotation** β currently impossible without identity change. Phase 3 may add a "linked-keys" event. |
| 3. **HSM / Yubikey support** β out of scope; possible Phase 3. |
|
|