sigil/sigil/keys.py

130 lines
4.2 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 Optional, 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, passphrase: Optional[str] = None) -> 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.
If a passphrase is given the key is encrypted at rest (PBKDF2-HMAC-SHA256,
480k iterations, AES-256). Prefer that for anything long-lived: 0600 stops
other *users*, not root, a backup, or anyone who copies the disk.
"""
if passphrase:
if len(passphrase) < 12:
raise ValueError(
"passphrase for a private key must be at least 12 characters"
)
encryption = serialization.BestAvailableEncryption(passphrase.encode("utf-8"))
else:
encryption = serialization.NoEncryption()
pem = private.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=encryption,
)
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 is_encrypted(path: str) -> bool:
"""True if the file on disk holds an encrypted key."""
with open(path, "rb") as handle:
head = handle.read()
return b"ENCRYPTED" in head
def load_private(path: str, passphrase: Optional[str] = None) -> PrivateKey:
"""Load a private key.
A wrong or missing passphrase raises rather than yielding a wrong key, so
a caller cannot accidentally proceed with a key it did not really open.
"""
with open(path, "rb") as handle:
data = handle.read()
if b"ENCRYPTED" in data and not passphrase:
raise ValueError(
f"{path} is encrypted: a passphrase is required"
)
key = serialization.load_pem_private_key(
data, password=passphrase.encode("utf-8") if passphrase else None
)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError(f"{path} is not an Ed25519 private key")
return key