Harden Sigil: sigil/keys.py

This commit is contained in:
bigpickle 2026-10-05 21:24:40 +08:00
parent 995920321a
commit 854db7c6a7

View File

@ -11,7 +11,7 @@ from __future__ import annotations
import base64
import hashlib
import os
from typing import Tuple
from typing import Optional, Tuple
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
@ -75,22 +75,56 @@ def verify(public: PublicKey, signature: bytes, message: bytes) -> bool:
return False
def save_private(private: PrivateKey, path: str) -> None:
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=serialization.NoEncryption(),
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 load_private(path: str) -> PrivateKey:
def is_encrypted(path: str) -> bool:
"""True if the file on disk holds an encrypted key."""
with open(path, "rb") as handle:
return serialization.load_pem_private_key(handle.read(), password=None)
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