130 lines
4.2 KiB
Python
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 |