Key Management - Steel-SecAdv-LLC/AMA-Cryptography GitHub Wiki
Key Management
Comprehensive documentation for the AMA Cryptography key management system, including hierarchical deterministic (HD) key derivation, key lifecycle management, rotation, and HSM integration.
Overview
The key management system provides enterprise-grade capabilities:
- HD Key Derivation — BIP32-compatible hierarchical deterministic keys
- Key Lifecycle — Active → Rotating → Deprecated → Revoked → Compromised
- Zero-Downtime Rotation — Seamless key rotation with versioned metadata
- Secure Storage — Encrypted key storage at rest
- HSM Support — FIPS 140-2 Level 3+ Hardware Security Module integration
Core Classes
KeyStatus Enum
from ama_cryptography.key_management import KeyStatus
class KeyStatus(Enum):
ACTIVE # Key is active and in use
ROTATING # Key is being rotated (transitional state)
DEPRECATED # Key is deprecated; verify only, don't sign
REVOKED # Key is revoked; reject all operations
COMPROMISED # Key is compromised; requires immediate action
KeyMetadata
from ama_cryptography.key_management import KeyMetadata
# Metadata attached to every managed key
meta = KeyMetadata(
key_id="kid-abc123",
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + timedelta(days=365),
status=KeyStatus.ACTIVE,
version=1,
usage_count=0,
max_usage=10000,
derivation_path="m/44'/0'/0'/0'",
)
KeyRotationManager
The policy-level class that tracks keys under rotation. It does not
hold key material itself — that stays with the application (or an HSM,
or SecureKeyStorage below); the manager tracks metadata (status,
version, expiry, usage counts) and exposes rotation hooks.
from datetime import timedelta
from ama_cryptography.key_management import KeyRotationManager
mgr = KeyRotationManager(rotation_period=timedelta(days=90))
# Register a key under the rotation policy
meta = mgr.register_key(
key_id="signing-key-v1",
purpose="document-signatures",
expires_in=timedelta(days=90),
max_usage=100_000,
)
# Policy queries
should_rotate = mgr.should_rotate("signing-key-v1") # bool
active_id = mgr.get_active_key() # Optional[str]
# Rotation lifecycle: old key -> ROTATING -> DEPRECATED.
# IMPORTANT (key_management.py:435): initiate_rotation() raises
# ValueError("Key not found") if either key_id is missing from the
# manager. Register the replacement key first — the manager tracks
# metadata only, so you still provision the actual key material in
# your keystore separately.
mgr.register_key(
key_id="signing-key-v2",
purpose="document-signatures",
expires_in=timedelta(days=90),
max_usage=100_000,
)
mgr.initiate_rotation("signing-key-v1", "signing-key-v2")
# ... provision the new key material in your keystore ...
mgr.complete_rotation("signing-key-v1")
# Accounting
mgr.increment_usage("signing-key-v2")
mgr.revoke_key("signing-key-v1", reason="superseded")
# Audit snapshot
audit = mgr.export_metadata() # or export_metadata(filepath=Path(...))
For seed-derived keys, construct an HDKeyDerivation(seed=...)
and call derive_key(...) — see the next section.
Hierarchical Deterministic (HD) Key Derivation
Overview
AMA Cryptography implements BIP32-compatible hierarchical deterministic
key derivation. The PRF is HMAC-SHA-512 (BIP32-standard, delegated to
the native C backend via ama_cryptography.pqc_backends.native_hmac_sha512
to satisfy INVARIANT-1 — no stdlib hmac). Non-hardened child derivation
uses the native secp256k1 public-key computation.
Path format: m/{purpose}'/{account}'/{change}'/{index}' (BIP-44) or
any explicit BIP32 path.
HDKeyDerivation.derive_key(purpose, account, change, index)is a convenience wrapper that always emits a fully hardened path — all four components are hardened by construction.HDKeyDerivation.derive_path(path)accepts an explicit BIP32-style path and supports both hardened and non-hardened components. Hardened indices are written with a trailing'(or equivalently passed asindex ≥ 2^31 = HARDENED_OFFSET). Hardened derivation gives stronger branch isolation — compromise of a hardened child cannot be used to recover sibling or parent private keys — but non-hardened indices are supported for the cases where public-child derivation is required.
HDKeyDerivation
import os
from ama_cryptography.key_management import HDKeyDerivation
# Provide the seed (or a BIP-39-style seed_phrase) at construction. The
# instance holds the seed for subsequent derivations; the caller is
# responsible for seed lifetime — HDKeyDerivation does not currently
# perform secure wipe of self.master_seed on __del__.
seed = os.urandom(64)
hd = HDKeyDerivation(seed=seed)
# Structured BIP-44-style derivation: always produces a FULLY hardened
# path m/{purpose}'/{account}'/{change}'/{index}'
key_material: bytes = hd.derive_key(purpose=44, account=0, change=0, index=0)
# Explicit-path derivation: returns (derived_key, chain_code).
# Accepts both hardened (with trailing ') and non-hardened components.
key, chain_code = hd.derive_path("m/44'/0'/0'/0'")
Derivation Path Conventions
| Path | Purpose |
|---|---|
m/44'/0'/0'/0' |
Standard account key (fully hardened) |
m/84'/0'/0'/0' |
Native segwit-style (fully hardened) |
m/0'/0'/0'/0' |
Custom derivation (fully hardened) |
HARDENED_OFFSET = 2^31 = 2,147,483,648
Hardened indices are written with a trailing ' and correspond to
values ≥ 2^31.
HDKeyDerivation.derive_key(purpose, account, change, index)always constructs a fully hardened BIP-44-style path — every component emitted by this convenience API is hardened.HDKeyDerivation.derive_path(path)accepts an explicit path and parses both hardened (44') and non-hardened (44) components. Non-hardened derivation produces a public child from which sibling keys can be derived; use it only when public-child derivation is required, and prefer hardened components otherwise for stronger branch isolation.
Key Lifecycle Management
Lifecycle Transitions
ACTIVE
│
├── rotate_key() ──► ROTATING ──► (old: DEPRECATED, new: ACTIVE)
│
└── revoke_key() ──► REVOKED
│
└── mark_compromised() ──► COMPROMISED
Full Lifecycle Example
from datetime import timedelta
from ama_cryptography.key_management import KeyRotationManager, KeyStatus
mgr = KeyRotationManager(rotation_period=timedelta(days=90))
# 1. Register the initial active key
old_meta = mgr.register_key(
key_id="signing-key-v1",
purpose="document-signatures",
expires_in=timedelta(days=90),
max_usage=100_000,
)
assert old_meta.status == KeyStatus.ACTIVE
# 2. Begin zero-downtime rotation: old key moves to ROTATING
# (provision the new key material in your keystore before registering it)
mgr.register_key("signing-key-v2", purpose="document-signatures",
parent_id="signing-key-v1", expires_in=timedelta(days=90))
mgr.initiate_rotation("signing-key-v1", "signing-key-v2")
# 3. Finalize rotation: old key becomes DEPRECATED, new key is ACTIVE
mgr.complete_rotation("signing-key-v1")
# 4. DEPRECATED keys can verify but not sign — let them age out, then revoke
mgr.revoke_key("signing-key-v1", reason="superseded")
Secure Key Storage
SecureKeyStorage
For encrypted storage of key material at rest:
import os
from pathlib import Path
from ama_cryptography.key_management import (
SecureKeyStorage,
KeyRotationManager,
)
# SecureKeyStorage takes a storage directory and an optional master
# password.
#
# IMPORTANT (key_management.py:563-673): if `master_password` is truthy,
# it is stretched through a password-based KDF into a 32-byte AES-256
# key. Algorithm selection is automatic on first use of a fresh keystore:
# * Argon2id (RFC 9106; t=3, m=64 MiB, p=4) is preferred whenever the
# native Argon2 backend is compiled in (`_ARGON2_NATIVE_AVAILABLE`
# is True) — this is KDF_VERSION 3 and becomes the default on any
# modern build of the library.
# * PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2024) is the
# fallback when the native Argon2 backend is unavailable — this is
# KDF_VERSION 2.
# * `migrate_kdf()` exists to opportunistically upgrade an existing
# v2 (PBKDF2) keystore to v3 (Argon2id); it is not required for
# fresh installations.
# Selection is persisted in `.kdf_metadata.json` alongside the salt so
# existing keystores remain decryptable across algorithm changes.
#
# If `master_password` is None or empty, a *random in-memory* 32-byte
# encryption key is generated via `secrets.token_bytes(32)` — there is
# no stable KDF derivation in that mode, so keys stored under a
# process's random in-memory key **cannot be decrypted after process
# restart**. Use a stable master_password whenever the store must
# survive across processes.
storage = SecureKeyStorage(
storage_path=Path("/var/lib/myapp/keys"),
master_password=os.environ["AMA_KEY_PASSWORD"], # stable → persistable
)
mgr = KeyRotationManager()
key_data = os.urandom(32)
meta = mgr.register_key("my-key-id", purpose="doc-signing")
# store_key takes an optional plain dict of metadata; the KeyMetadata
# returned by register_key lives in the rotation manager, not the store.
storage.store_key("my-key-id", key_data, metadata={"purpose": "doc-signing"})
retrieved: bytes | None = storage.retrieve_key("my-key-id")
assert retrieved == key_data
# Metadata for active/deprecated/revoked status is maintained by the
# rotation manager:
active_meta = mgr.export_metadata()
Key Storage Security
- In-Memory: Key material is stored as
bytearrayto allow in-place zeroing - At-Rest: Keys are encrypted with AES-256-GCM before serialization
- Memory Lock: Uses
secure_mlock()to prevent key swapping to disk - Zeroing: Automatic multi-pass zeroing via
SecureBuffercontext manager
Production Key Security
Option 1: Hardware Security Module (HSM) — Recommended
For production deployments, store master secrets in FIPS 140-2 Level 3+ HSMs:
# AWS CloudHSM Example
import boto3
def store_master_secret_hsm(master_secret: bytes, key_label: str) -> str:
client = boto3.client('cloudhsmv2')
response = client.import_key(
KeyLabel=key_label,
KeyMaterial=master_secret,
KeySpec='AES_256'
)
# NEVER store master_secret on disk after this point
# Zero out memory immediately
master_secret_buf = bytearray(master_secret)
for i in range(len(master_secret_buf)):
master_secret_buf[i] = 0
return response['KeyId']
Option 2: Hardware Token (YubiKey, Nitrokey)
For personal/small-team use (FIPS 140-2 Level 2):
from ykman.device import connect_to_device
from ykman.piv import PivController
def store_key_yubikey(master_secret: bytes, slot: int = 0x82):
device, _ = connect_to_device()[0]
piv = PivController(device.driver)
piv.authenticate(management_key)
piv.import_key(slot, master_secret)
Option 3: Password-Encrypted Keystore (Software)
Minimum security for development. Use PBKDF2 with 600,000+ iterations (OWASP 2024):
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.fernet import Fernet
import base64, os
def encrypt_master_secret(master_secret: bytes, password: str, path: str):
salt = os.urandom(32)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=600000, # OWASP 2024 recommendation
)
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
encrypted = Fernet(key).encrypt(master_secret)
with open(path, 'wb') as f:
f.write(salt + encrypted)
Key Expiry and Rotation Policy
Recommended Rotation Intervals
| Key Type | Recommended Rotation |
|---|---|
| Master Secret | Annually or on compromise |
| HMAC Key | 90 days |
| Ed25519 Signing Key | 1 year |
| ML-DSA-65 Signing Key | 2 years |
| Session Keys | Per session |
Automated Rotation
from datetime import timedelta
from ama_cryptography.key_management import KeyRotationManager
def check_and_rotate(
mgr: KeyRotationManager,
key_id: str,
new_key_id: str,
*,
purpose: str,
expires_in: timedelta,
max_usage: int | None = None,
) -> str:
"""Rotate `key_id` → `new_key_id` if policy says so; return the active id.
Note: `mgr.initiate_rotation()` raises `ValueError("Key not found")`
(key_management.py:435) unless both key ids are already registered
with the manager. We register the replacement id first so the call
site can't accidentally rotate into a key the manager has never
seen. Key *material* still lives in the caller's keystore.
"""
if not mgr.should_rotate(key_id):
return key_id
if new_key_id not in mgr.keys:
mgr.register_key(
key_id=new_key_id,
purpose=purpose,
expires_in=expires_in,
max_usage=max_usage,
)
mgr.initiate_rotation(key_id, new_key_id)
# ... provision the new key material in the caller's keystore ...
mgr.complete_rotation(key_id)
return new_key_id
Thread Safety
KeyRotationManager is not thread-safe. The implementation
(ama_cryptography/key_management.py) stores its state in a plain
dict (self.keys) and mutates it directly from register_key(),
initiate_rotation(), complete_rotation(), revoke_key(), and
increment_usage() with no internal threading.Lock / RLock.
Timestamps use datetime.now(timezone.utc) (timezone-aware), but that
does not protect against concurrent dict mutation.
If you share a KeyRotationManager instance across threads, you are
responsible for serializing every mutating call (not just rotation)
behind your own lock:
import threading
_lock = threading.Lock()
with _lock:
mgr.register_key(..., purpose="...", expires_in=...)
mgr.initiate_rotation(old_id, new_id)
mgr.complete_rotation(old_id)
For single-process workloads, prefer keeping the manager inside one thread (e.g., a dedicated key-management worker) and routing all rotation requests through a queue.
Exception Handling
from ama_cryptography.exceptions import (
KeyManagementError,
PQCUnavailableError,
QuantumSignatureUnavailableError,
SecurityWarning,
)
from ama_cryptography.pqc_backends import generate_dilithium_keypair
try:
meta = mgr.register_key("my-key", purpose="doc-signing")
# ... later ...
mgr.revoke_key("my-key", reason="operator_action")
except KeyManagementError as e:
print(f"Key management error: {e}")
try:
kp = generate_dilithium_keypair()
except PQCUnavailableError as e:
# INVARIANT-7: we do NOT silently fall back to a classical-only path.
# The correct response is to surface the failure, not hide it.
raise
See Secure Memory for memory-safety details, or Architecture for the full key management architecture diagram.