From 854db7c6a7fa55922f256dd770b65159a4add9ba Mon Sep 17 00:00:00 2001 From: bigpickle Date: Mon, 5 Oct 2026 21:24:40 +0800 Subject: [PATCH] Harden Sigil: sigil/keys.py --- sigil/keys.py | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/sigil/keys.py b/sigil/keys.py index 6e806dd..b787d81 100644 --- a/sigil/keys.py +++ b/sigil/keys.py @@ -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) \ No newline at end of file + 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 \ No newline at end of file