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.
96 lines
2.9 KiB
Python
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) |