HearthNet-Nemotron / docs /modules /M01-identity.md
GitHub Actions
Add all-to-all internet mesh over relay hub (P1-P3) + user-story screenshot proof
8f53c4c
|
Raw
History Blame
12.8 kB
# 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.