sigil/sigil/keys.py
bigpickle f4a044f1d6 Sigil: agent identity for the new internet
Replace passcode identity with Ed25519 challenge-response.

- Enrolment consumes a one-time bootstrap secret, after which no static
  secret is used on the wire again.
- Nonces are single-use and consumed before the signature is judged, so a
  captured or bad signature cannot be retried.
- Sessions are short-lived, scoped, and typed agent vs human_via_agent.
- Human view access uses one-time codes with a 5 minute redemption window
  and a 1 hour session, deliberately different numbers: the code is a key,
  the session is the door.
- Invariant: only an agent signature may act as an agent. Enforced and tested.
- Append-only audit trail.

54 tests, covering refusals rather than only the happy path.

Reference implementation, not reviewed or hardened. See README Status.
2026-10-06 06:28:01 +08:00

96 lines
2.9 KiB
Python

"""Ed25519 keys: the whole point of Sigil.
An agent holds a private key that never leaves its machine. The platform stores
only the public half. Possession of a public key is not a credential; a
signature produced by the matching private key is. That is the difference from
a passcode, which *is* the credential and can be copied.
"""
from __future__ import annotations
import base64
import hashlib
import os
from typing import Tuple
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
PrivateKey = Ed25519PrivateKey
PublicKey = Ed25519PublicKey
def generate() -> Tuple[PrivateKey, PublicKey]:
"""Create a fresh keypair for an agent."""
private = Ed25519PrivateKey.generate()
return private, private.public_key()
def public_bytes(public: PublicKey) -> bytes:
"""Raw 32-byte public key, the form we store and compare."""
return public.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
def from_public_bytes(raw: bytes) -> PublicKey:
return Ed25519PublicKey.from_public_bytes(raw)
def export_public_b64(public: PublicKey) -> str:
"""Base64 public key, for enrolment over the wire."""
return base64.b64encode(public_bytes(public)).decode("ascii")
def import_public_b64(text: str) -> PublicKey:
return from_public_bytes(base64.b64decode(text.encode("ascii")))
def fingerprint(public: PublicKey) -> str:
"""Short stable handle for a key, safe to print in logs.
This is an identifier, not a secret. Seeing it tells you nothing that
would let you sign as the agent.
"""
digest = hashlib.sha256(public_bytes(public)).hexdigest()
return "ed25519:" + ":".join(
[digest[:8], digest[8:16], digest[16:24], digest[24:32]]
)
def sign(private: PrivateKey, message: bytes) -> bytes:
return private.sign(message)
def verify(public: PublicKey, signature: bytes, message: bytes) -> bool:
"""Constant-shape verification: never raises on malformed input."""
try:
public.verify(signature, message)
return True
except Exception:
return False
def save_private(private: PrivateKey, path: str) -> None:
"""Write a private key to disk with owner-only permissions.
Created 0600 from the outset rather than chmod-ed after, so there is no
window where the key is briefly world-readable.
"""
pem = private.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as handle:
handle.write(pem)
def load_private(path: str) -> PrivateKey:
with open(path, "rb") as handle:
return serialization.load_pem_private_key(handle.read(), password=None)