⚠️ Migrating from v1.x to v2.0.0
v2.0.0 is a major release with breaking changes. Existing v1.x keystores are readable — PyHSM migrates the KDF automatically on the next write. The code changes required are minimal but mandatory.
Breaking Changes
| Change | v1.x | v2.0.0 |
|---|---|---|
| Constructor arguments | PyHSM("path", "pw") |
PyHSM(storage_path="path", master_password="pw") |
decrypt() return type |
str or bytes |
Always bytes — add .decode("utf-8") if needed |
get_audit_log() return type |
Raw list |
AuditLog object — call .export_jsonl() for the list |
allow_export policy default |
True (when unset, some paths) |
Always False — must be set explicitly |
CLI generate --type |
Optional (defaulted to aes-256) |
Required — no implicit default |
| TypeScript factory | Sync constructor supported | Use PyHSM.create() for production; sync constructor emits deprecation warning |
Migration Steps
- Upgrade:
pipx upgrade vectorguard-pyhsm - Open your keystore once — PyHSM automatically migrates the KDF from PBKDF2 to Argon2id on the next write.
- Update constructor calls to use keyword arguments.
- Add
.decode("utf-8")anywhere you passdecrypt()output directly to astr-expecting API. - Replace
hsm.get_audit_log()list access withhsm.get_audit_log().export_jsonl(). - Add
"allow_export": Trueto any key policy that relies on JWK export.
⚠️ v2.2.0 IPC Server: Required Authentication
v2.2.0 makes --ipc-secret mandatory for the IPC server. The server refuses to start without it. This is the only breaking change from v2.1.0.
Generating and Using the IPC Secret
openssl rand -hex 32 > /run/secrets/pyhsm-ipc-secret
chmod 600 /run/secrets/pyhsm-ipc-secret
chown pyhsm:pyhsm /run/secrets/pyhsm-ipc-secret
vectorguard-pyhsm-server \
--store /var/lib/pyhsm/keystore.enc \
--password-file /run/secrets/pyhsm-password \
--socket /run/pyhsm/pyhsm.sock \
--ipc-secret "$(cat /run/secrets/pyhsm-ipc-secret)"
from hsm.ipc_client import IPCClient
ipc_secret = open("/run/secrets/pyhsm-ipc-secret").read().strip()
with IPCClient("/run/pyhsm/pyhsm.sock", ipc_secret=ipc_secret) as hsm:
ct = hsm.encrypt("my-key", "sensitive data")
Other Security Improvements in v2.2.0
| Change | Impact |
|---|---|
| IPC global lock replaced with per-key locking + connection timeout | Eliminates DoS from slow operations; read-only ops hold no lock |
| Shamir: per-share HMAC-SHA256 (256-bit, index-bound) | Replaces 4-byte truncated checksum; prevents share transplantation |
| Startup KATs expanded from 4 to 10 primitives | All active crypto verified at startup against NIST/RFC vectors |
Audit log cross-rotation chain (rotationSeal/rotationOpen) | Deletion of any rotated log file is cryptographically detectable |
⚠️ v2.3.0 License Change: MIT → AGPL-3.0
v2.3.0 contains a single change: the license has been updated from MIT to the GNU Affero General Public License v3.0 (AGPL-3.0). There are no code changes in this release — the version bump exists solely to mark the license transition clearly in the package history.
What This Means
| Use Case | Before v2.3.0 | v2.3.0+ (AGPL-3.0) |
|---|---|---|
| Open-source projects | Free to use | Free to use |
| Personal / research use | Free to use | Free to use |
| Proprietary product distribution | Free to use | Requires commercial license |
| Network service (SaaS) without releasing source | Free to use | Requires commercial license |
Users who had v2.2.5 already installed in their infrastructure prior to October 28, 2026 are unaffected by this change.
Commercial licensing: If you are embedding PyHSM in a proprietary product or running it as part of a network service without releasing your application source under AGPL, contact pavon@vectorguardlabs.com or see COMMERCIAL_LICENSE.md in the repository.
⚠️ v2.3.1 Security Hardening (No Breaking Changes)
v2.3.1 (released October 29, 2026) ships four targeted security fixes and 10 new regression tests. There are no breaking changes from v2.3.0. Update with pipx upgrade vectorguard-pyhsm.
What Changed
| Fix | Impact |
|---|---|
Thread-safe rate limiter (RateLimiter.allow(), usage(), reset()) |
Prevents two concurrent threads from both passing the rate-limit check before either records the operation; eliminates the 2× burst over-admission race. |
Constant-time HMAC comparisons in audit.verify(), audit.verify_across_rotations(), and the legacy Shamir checksum path |
Replaces plain != with hmac.compare_digest() everywhere. Eliminates timing-oracle leakage on HMAC-derived strings. |
RSA key import size enforcement in import_key_jwk() |
Rejects RSA keys < 2048 bits with a clear error citing NIST SP 800-131A Rev. 2. Previously, 512 and 1024-bit keys were silently accepted. |
| Stronger RSA-PSS and Ed25519 startup KATs | RSA-PSS KAT now uses a fixed embedded private key (fingerprint-pinned) to catch library regressions. Both RSA-PSS and Ed25519 KATs add wrong-key rejection checks. |
Updated Test Counts
v2.3.1 adds 10 new regression tests: TestRateLimiterThreadSafety, TestJWKWeakKeyRejection, TestAuditVerifyConstantTime, and TestKATVectors. Total test count is now 274 tests (180 Python + 94 TypeScript).
RSA Import Validation Example
from hsm import PyHSM
with PyHSM(storage_path="keystore.enc", master_password="...") as hsm:
# This will raise ValueError in v2.3.1+
hsm.import_key_jwk("weak-rsa", {
"kty": "RSA",
"n": "...", # 1024-bit modulus
"e": "AQAB",
"d": "...",
})
# ValueError: RSA key size 1024 bits is below the minimum of 2048 bits
# (NIST SP 800-131A Rev. 2). Use rsa-2048 or rsa-4096.
Overview
PyHSM is a production-ready software Key Management Service (KMS) that provides:
- AES-256-GCM (Python) / AES-256-GCM-SIV (TypeScript) authenticated encryption with Argon2id key derivation (required, OWASP recommended)
- 9 key types: AES-128/256, RSA-2048/4096, EC P-256/P-384/P-521/secp256k1, Ed25519
- Double-encrypted keystore (AES-GCM envelope + AES-KWP per-key wrapping)
- Salt-bound KEK derivation — KEK uses a dedicated salt via full Argon2id → HKDF path
- HMAC-chained tamper-evident audit logs with configurable rotation and cross-rotation chain integrity (
rotationSeal/rotationOpen+verify_across_rotations()) - Shamir M-of-N secret sharing over GF(256) with per-share HMAC-SHA256 integrity (v2.2.0+)
- Key rotation with version tracking; automatic rotation via
rotate_every_dayspolicy - Key metadata search (by type, metadata tags, status, or policy fields)
- Caller ACLs, rate limiting, and operation caps
- Encrypted backup/restore with HMAC verification
- Prometheus metrics + OpenTelemetry (OTLP) JSON export
- Process isolation mode (Unix domain socket IPC) with mandatory HMAC mutual authentication and per-key locking (v2.2.0+)
- Pluggable storage backends (file, memory, or custom)
- Dual implementation: Python and TypeScript with full feature parity
- Self-test Known-Answer Tests (KATs) on startup — all 10 active primitives verified against published NIST/RFC vectors
- 274 tests (180 Python + 94 TypeScript), 80%+ coverage enforced in CI
PyHSM is open-source software licensed under the AGPL-3.0. It requires no hardware, no cloud dependency, and no per-operation fees. Commercial use in proprietary products or network services without releasing application source requires a commercial license.
Installation
From PyPI (Recommended)
pipx install vectorguard-pyhsm
From Source
git clone https://github.com/pavondunbar/PyHSM.git
cd PyHSM
pipx install .
Development Install
pipx install ".[dev]" # includes pytest + pytest-cov
pipx install -e . # editable mode
Requirements
| Dependency | Version |
|---|---|
| Python | ≥ 3.11 |
| cryptography | ≥ 43.0.0, < 44.0.0 |
| argon2-cffi | ≥ 23.1.0, < 25.0.0 |
Quickstart
Get up and running in 3 steps:
1. Install
pipx install vectorguard-pyhsm
2. Generate a Key and Encrypt
from hsm import PyHSM
# v2: all constructor parameters must be passed as keyword arguments
with PyHSM(storage_path="my-keystore.enc", master_password="strong-password-here") as hsm:
# Generate an AES-256 encryption key
hsm.generate_key("my-key", key_type="aes-256")
# Encrypt sensitive data
ciphertext = hsm.encrypt("my-key", "secret data")
print(f"Ciphertext: {ciphertext}")
# Decrypt it back — decrypt() always returns bytes in v2
plaintext = hsm.decrypt("my-key", ciphertext)
print(f"Plaintext: {plaintext.decode('utf-8')}")
3. Or Use the CLI
# Generate an AES-256 key
vectorguard-pyhsm --store my-keystore.enc generate my-key --type aes-256
# Encrypt data
vectorguard-pyhsm --store my-keystore.enc encrypt my-key -d "secret data"
# Decrypt data
vectorguard-pyhsm --store my-keystore.enc decrypt my-key -d "<ciphertext-hex>"
Python API — PyHSM Class
The PyHSM class is the primary interface. It manages the keystore, enforces policies, handles auditing, and exposes all cryptographic operations.
Constructor
from hsm import PyHSM
# v2: all parameters MUST be keyword arguments (positional args removed)
hsm = PyHSM(
storage_path="keystore.enc", # Path to encrypted keystore file
master_password="your-password", # Required, minimum 12 characters
audit_log_path="audit.jsonl", # Optional audit log path
session_timeout_s=300.0, # Session idle timeout (seconds)
rate_limit_max_ops=100, # Max operations per key per window
rate_limit_window_s=60.0, # Rate limit window (seconds)
license_file="/etc/pyhsm/license.jwt", # Commercial license token file (v2.3.0+, optional)
# license_token="", # Or: inline JWT string
)
PyHSM("keystore.enc", "password") will raise a TypeError — use PyHSM(storage_path="keystore.enc", master_password="password") instead.
Supports context manager usage for automatic session cleanup:
with PyHSM(storage_path="keystore.enc", master_password="...") as hsm:
# operations here
pass
# Session closed, memory zeroized
Import Paths
# Primary class
from hsm import PyHSM
# Storage and errors
from hsm import KeyStore, TamperError
from hsm.backends import StorageBackend, FileBackend, MemoryBackend
# Security utilities
from hsm import SecureBytes, zeroize_bytearray
from hsm import AuditLog, RateLimiter, MetricsCollector
from hsm import run_self_tests
# Shamir secret sharing
from hsm import split_secret, reconstruct_secret, zeroize
# JWK support
from hsm import export_symmetric_jwk, export_ec_jwk
from hsm import export_rsa_jwk, export_ed25519_jwk
Key Generation
Method Signature
hsm.generate_key(
key_id: str, # Unique identifier for the key
key_type: str = "aes-256", # Key type (see Key Types table)
metadata: dict = None, # Optional metadata dict
policy: dict = None, # Optional policy dict
caller_id: str = None, # Caller identity for ACL/audit
) -> str # Returns key_id
Examples
# AES-256 for symmetric encryption
hsm.generate_key("encryption-key", key_type="aes-256")
# Ed25519 for fast signing (Solana, SSH)
hsm.generate_key("signing-key", key_type="ed25519")
# secp256k1 for Ethereum/Bitcoin transaction signing
hsm.generate_key("eth-wallet", key_type="ec-secp256k1")
# RSA-4096 for legacy systems
hsm.generate_key("legacy-rsa", key_type="rsa-4096")
# With policy constraints
hsm.generate_key("limited-key", key_type="aes-256", policy={
"max_operations": 10000,
"allowed_callers": ["payment-service", "auth-service"],
"allow_encrypt": True,
"allow_decrypt": True,
"expires_at": "2027-01-01T00:00:00Z",
"rotate_every_days": 90,
})
Other Key Lifecycle Methods
| Method | Description |
|---|---|
list_keys() | List all stored keys with metadata |
has_key(key_id) | Check if a key exists |
search_keys(key_type=, metadata=, status=, policy_filter=) | Search keys by criteria |
destroy_key(key_id) | Permanently destroy a key (zeroizes material) |
enforce_expiry() | Archive all expired keys |
Encryption & Decryption
AES keys support authenticated encryption using AES-256-GCM with hybrid nonces (random + counter + random) to eliminate birthday-bound collisions.
Method Signatures
hsm.encrypt(
key_id: str, # Key to encrypt with
plaintext: str | bytes, # Data to encrypt
caller_id: str = None, # Caller identity
) -> str # Hex-encoded ciphertext
hsm.decrypt(
key_id: str, # Key to decrypt with
ciphertext_hex: str, # Hex ciphertext from encrypt()
caller_id: str = None, # Caller identity
) -> bytes # Decrypted plaintext (always bytes in v2)
decrypt() now always returns bytes. If you previously relied on getting a str back, add an explicit .decode("utf-8") call: plaintext.decode("utf-8").
Example
with PyHSM(storage_path="keystore.enc", master_password="...") as hsm:
hsm.generate_key("data-key", key_type="aes-256")
# Encrypt a private key, API token, or any sensitive data
ciphertext = hsm.encrypt("data-key", "sk_live_abc123xyz")
# Later, decrypt it — returns bytes in v2
plaintext = hsm.decrypt("data-key", ciphertext)
# plaintext = b"sk_live_abc123xyz"
secret_str = plaintext.decode("utf-8") # explicit decode if you need str
decrypt() automatically selects the correct key version for each ciphertext.
Signing & Verification
Asymmetric keys (EC, RSA, Ed25519) support digital signatures. The private key never leaves the HSM; you get back only the signature.
Method Signatures
hsm.sign(
key_id: str, # Asymmetric key to sign with
message: str | bytes, # Data to sign
caller_id: str = None,
) -> str # Hex-encoded signature
hsm.verify(
key_id: str, # Key to verify with
message: str | bytes, # Original message
signature_hex: str, # Hex signature from sign()
caller_id: str = None,
) -> bool # True if valid
hsm.get_public_key(key_id: str) -> str # PEM-encoded public key
Blockchain Transaction Signing
with PyHSM("keystore.enc", master_password="...") as hsm:
# Generate or import a secp256k1 wallet key
hsm.generate_key("eth-wallet", key_type="ec-secp256k1")
# Sign a transaction hash (32 bytes)
tx_hash = bytes.fromhex("a1b2c3...") # Your tx hash
signature_hex = hsm.sign("eth-wallet", tx_hash)
# Parse DER signature into (r, s, v) for broadcasting
# The private key was unwrapped, used, and zeroized internally
Supported Signing Algorithms
| Key Type | Algorithm | Hash | Use Case |
|---|---|---|---|
| ec-p256 | ECDSA | SHA-256 | General-purpose, TLS |
| ec-p384 | ECDSA | SHA-384 | Higher security |
| ec-p521 | ECDSA | SHA-512 | Maximum NIST security |
| ec-secp256k1 | ECDSA | SHA-256 | Ethereum, Bitcoin |
| ed25519 | EdDSA | Built-in | Solana, SSH, high-perf |
| rsa-2048 | RSA-PSS | SHA-256 | Legacy compatibility |
| rsa-4096 | RSA-PSS | SHA-256 | Legacy, high-security |
Key Rotation
AES keys support rotation with automatic version tracking. Old ciphertexts remain decryptable because the version number is embedded in the ciphertext format.
Manual Rotation
with PyHSM(storage_path="keystore.enc", master_password="...") as hsm:
# Rotate to a new version
new_version = hsm.rotate_key("data-key")
# new_version = 2
# New encryptions use version 2
ct_new = hsm.encrypt("data-key", "new data")
# Old ciphertexts still decrypt (version 1 auto-selected)
plaintext = hsm.decrypt("data-key", old_ciphertext)
Automatic Rotation
hsm.generate_key("auto-rotate-key", key_type="aes-256", policy={
"rotate_every_days": 90, # Auto-rotate every 90 days
})
encrypt() call, PyHSM checks if the current key version's age exceeds the policy. If so, it rotates before encrypting. This is lazy (on-demand) rotation.
JWK Import & Export
Import existing keys from standard JWK format (RFC 7517) or export keys for interoperability. Supported key types: AES (oct), EC P-256/P-384/P-521/secp256k1, Ed25519 (OKP), and RSA.
Import
# Import an existing EC key from JWK
jwk_data = {
"kty": "EC",
"crv": "secp256k1",
"x": "...",
"y": "...",
"d": "..." # private key component
}
hsm.import_key_jwk("imported-wallet", jwk_data)
# Import an RSA key from JWK (v2 addition)
# NOTE (v2.3.1+): RSA keys below 2048 bits are rejected per NIST SP 800-131A Rev. 2
hsm.import_key_jwk("rsa-key", {
"kty": "RSA",
"n": "...", # base64url modulus — must be 2048 or 4096 bits
"e": "AQAB", # public exponent
"d": "...", # private exponent
})
Export
# v2: allow_export defaults to False — must be explicitly set to True
hsm.generate_key("exportable-key", key_type="ed25519", policy={
"allow_export": True, # required in v2; previously could be unset
})
jwk = hsm.export_jwk("exportable-key")
# Returns standard JWK dict
allow_export now defaults to False on all keys. In v1.x, some code paths allowed export when the policy key was absent. If you rely on JWK export, add "allow_export": True explicitly to the key's policy.
CLI Reference
The vectorguard-pyhsm CLI provides full access to all PyHSM operations from the terminal.
Global Options
| Flag | Default | Description |
|---|---|---|
--store PATH | keystore.enc | Path to the encrypted keystore file |
The master password is prompted interactively via getpass, or can be provided via the PYHSM_MASTER_PASSWORD environment variable for scripting.
All Commands
| Command | Description |
|---|---|
generate <key_id> | Generate a new cryptographic key |
list | List all keys in the keystore |
stores [dir] | List keystore files in a directory (no password required) |
rotate <key_id> | Rotate an AES key to a new version |
delete <key_id> [--yes] | Permanently destroy a key; --yes / -y skips confirmation prompt |
license | Show active license tier, licensee, expiry, and key limits (commercial or AGPL) |
encrypt <key_id> | Encrypt data with an AES key |
decrypt <key_id> | Decrypt hex ciphertext |
sign <key_id> | Sign data with an asymmetric key |
verify <key_id> <msg> <sig> | Verify a signature (exit 0=valid, 1=invalid) |
pubkey <key_id> | Export the public key in PEM format |
split | Split a secret into Shamir shares |
reconstruct | Reconstruct a secret from Shamir shares |
metrics [--prometheus] | Show operational metrics; --prometheus outputs Prometheus text format |
audit | Inspect or verify the audit log |
CLI: generate
Generate a new cryptographic key inside the keystore.
vectorguard-pyhsm --store <path> generate <key_id> [options]
Options
| Flag | Description |
|---|---|
--type TYPE | Required. Key type: aes-128, aes-256, rsa-2048, rsa-4096, ec-p256, ec-p384, ec-p521, ec-secp256k1, ed25519 |
--no-encrypt | Disallow encryption with this key |
--no-decrypt | Disallow decryption with this key |
--max-operations N | Maximum total operations allowed |
--expires-at ISO8601 | Key expiration timestamp |
--type is now required. The implicit aes-256 default has been removed to prevent accidental key type mismatches.
Examples
# Generate an AES-256 key
vectorguard-pyhsm generate vault-key --type aes-256
# Generate an Ed25519 signing key with max 5000 operations
vectorguard-pyhsm generate signer --type ed25519 --max-operations 5000
# Generate a key that expires
vectorguard-pyhsm generate temp-key --type aes-256 --expires-at 2027-06-01T00:00:00Z
CLI: encrypt / decrypt
# Encrypt with -d flag
vectorguard-pyhsm encrypt my-key -d "sensitive data"
# Encrypt from stdin
echo "sensitive data" | vectorguard-pyhsm encrypt my-key
# Decrypt hex ciphertext
vectorguard-pyhsm decrypt my-key -d "0200000001f383ad67..."
# Decrypt from stdin
echo "0200000001f383ad67..." | vectorguard-pyhsm decrypt my-key
CLI: sign / verify
vectorguard-pyhsm sign my-signing-key -d "message to sign"
# Output: hex-encoded signature
vectorguard-pyhsm verify my-signing-key "message to sign" "3045022100..."
# Exit code: 0 = VALID, 1 = INVALID
vectorguard-pyhsm pubkey my-signing-key
# Output: PEM-encoded public key
CLI: split / reconstruct
Split a secret into Shamir shares or reconstruct from shares.
vectorguard-pyhsm split -k 3 -n 5 -s "64656d6f2d6d61737465722d70617373776f7264"
# Output (v2.2.0+ format with per-share HMAC-SHA256):
# Share 1: {"index": 1, "data": "d18a992fab...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}
# Share 2: {"index": 2, "data": "0985e4adb3...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}
# Share 3: {"index": 3, "data": "bc6a10ed35...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}
# Share 4: {"index": 4, "data": "ce2a97e318...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}
# Share 5: {"index": 5, "data": "7bc563a39e...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}
vectorguard-pyhsm reconstruct \
--share '{"index": 1, "data": "d18a992fab...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}' \
--share '{"index": 3, "data": "bc6a10ed35...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}' \
--share '{"index": 5, "data": "7bc563a39e...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}'
# Output: 64656d6f2d6d61737465722d70617373776f7264
share_hmac, hmac_salt, and reconstruction_hmac fields. Old shares with only index and data remain backward-compatible via the legacy checksum verification path.
Split Options
| Flag | Description |
|---|---|
-k, --threshold | Minimum shares needed to reconstruct (2–255) |
-n, --shares | Total number of shares to generate (k–255) |
-s, --secret | Hex-encoded secret to split |
CLI: audit / metrics
# Show all audit entries
vectorguard-pyhsm audit
# Verify audit log integrity (HMAC chain)
vectorguard-pyhsm audit --verify
# Filter by operation type
vectorguard-pyhsm audit --operation encrypt --since 2026-01-01T00:00:00Z
# Raw JSON output
vectorguard-pyhsm audit --raw
# Human-readable metrics
vectorguard-pyhsm metrics
# Prometheus text format
vectorguard-pyhsm metrics --prometheus
Audit Options
| Flag | Description |
|---|---|
--verify | Verify HMAC chain integrity |
--raw | Output raw JSON Lines |
--operation TYPE | Filter by operation type |
--key-id ID | Filter by key ID |
--since ISO8601 | Filter entries after this timestamp |
--until ISO8601 | Filter entries before this timestamp |
CLI: license
Show the active license tier, licensee, expiry, and per-tier limits. Available in v2.3.0+.
vectorguard-pyhsm --license-file /etc/pyhsm/license.jwt license
vectorguard-pyhsm --store keystore.enc license
export PYHSM_LICENSE_TOKEN="$(cat /etc/pyhsm/license.jwt)"
vectorguard-pyhsm --store keystore.enc license
Python API
info = hsm.get_license_info()
print(info.tier.value) # "startup" | "growth" | "scale" | "agpl"
print(info.licensee) # e.g. "you@example.com" or "agpl-user"
print(info.expires_at) # ISO 8601 timestamp
print(info.max_keys) # 5 for Startup, None for Growth/Scale/AGPL
print(info.is_commercial) # True for paid tiers
LicenseError at construction time before any key operation is permitted.
Key Types
PyHSM supports 9 key types covering symmetric encryption, ECDSA signing, EdDSA signing, and RSA signing.
| Key Type | CLI Name | Operations | Use Case |
|---|---|---|---|
| AES-128 | aes-128 | encrypt, decrypt | Lightweight symmetric encryption |
| AES-256 | aes-256 | encrypt, decrypt | Standard symmetric encryption (default) |
| RSA-2048 | rsa-2048 | sign, verify | Legacy RSA-PSS signatures |
| RSA-4096 | rsa-4096 | sign, verify | High-security RSA-PSS signatures |
| EC P-256 | ec-p256 | sign, verify | NIST standard, TLS, general-purpose |
| EC P-384 | ec-p384 | sign, verify | Higher NIST security level |
| EC P-521 | ec-p521 | sign, verify | Maximum NIST security level |
| EC secp256k1 | ec-secp256k1 | sign, verify | Ethereum, Bitcoin, blockchain |
| Ed25519 | ed25519 | sign, verify | Solana, Cosmos, SSH, high-performance |
Access Control
PyHSM provides three layers of access control that are enforced on every operation:
1. Caller ACLs
Restrict which services can use a key:
hsm.generate_key("payment-key", key_type="aes-256", policy={
"allowed_callers": ["payment-service", "billing-service"],
})
# This succeeds:
hsm.encrypt("payment-key", data, caller_id="payment-service")
# This raises ValueError + logs accessDenied:
hsm.encrypt("payment-key", data, caller_id="unknown-service")
2. Rate Limiting
Sliding-window rate limiting per key prevents abuse:
# Configured at HSM initialization
hsm = PyHSM(
"keystore.enc",
master_password="...",
rate_limit_max_ops=100, # 100 operations...
rate_limit_window_s=60.0, # ...per 60-second window
)
3. Operation Caps
Hard limit on total operations per key (lifetime):
hsm.generate_key("limited-key", key_type="aes-256", policy={
"max_operations": 10000, # After 10,000 ops, key is unusable
})
Policy Enforcement Order
- Caller ACL check (no side effects)
- Operation permission (
allow_encrypt,allow_decrypt,allow_sign) - Max operations check
- Expiry check
- Rate limiter (consumes a token only after all other checks pass)
Audit Log
Every operation is recorded in an HMAC-chained, tamper-evident audit log. Each entry's HMAC is computed over the entry content plus the previous entry's HMAC, forming a cryptographic chain similar to a blockchain.
Configuration
from hsm import AuditLog
audit = AuditLog(
log_path="pyhsm-audit.jsonl",
hmac_key=derived_key, # Derived from master via HKDF
webhook_url="https://siem.example.com/ingest", # Optional
max_bytes=50 * 1024 * 1024, # 50 MB rotation threshold
max_rotated_files=10, # Keep 10 rotated files
)
Tracked Operations
| Operation | Description |
|---|---|
encrypt | Data encrypted with a key |
decrypt | Data decrypted with a key |
sign | Message signed with a key |
verify | Signature verified |
generateKey | New key generated |
importKey | Key imported from JWK |
destroyKey | Key permanently destroyed |
rotateKey | Key rotated to new version |
exportKey | Key exported as JWK |
exportKeyDenied | Export attempt denied by policy |
accessDenied | Caller ACL violation |
rateLimited | Rate limit exceeded |
tamperDetected | Keystore integrity violation detected |
selfTestPass / selfTestFail | Startup self-test results (all 10 KAT primitives) |
rotationSeal | Appended to closing log file at rotation; contains final chain HMAC |
rotationOpen | First entry in new log file; records the previous seal HMAC |
Verification
# get_audit_log() returns an AuditLog object in v2 (not a raw list)
audit = hsm.get_audit_log()
# Programmatic verification (active log file only)
result = audit.verify()
# Returns -1 if chain is clean
# Returns sequence number of first corrupted entry otherwise
# Cross-rotation chain verification (v2.2.0+)
# Verifies the full chain across all retained rotated files.
# Deletion of any rotated file is detectable.
from hsm.audit import AuditLog
log = AuditLog('/var/log/pyhsm/keystore.enc.audit.jsonl', hmac_key=key)
result = log.verify_across_rotations()
# {"ok": True, "files_checked": 5} — or error on tamper
# Export entries as a list of dicts (SIEM-ready)
entries = audit.export_jsonl() # all entries
entries = audit.export_jsonl(operation="encrypt") # filter by operation
# CLI verification (active log)
vectorguard-pyhsm audit --verify
# CLI export for SIEM (CEF format for Splunk/QRadar/ArcSight)
vectorguard-pyhsm audit --siem-format cef | logger -t pyhsm -p security.info
# CLI export as JSON for Elastic/OpenSearch
vectorguard-pyhsm audit --siem-format json
get_audit_log() now returns an AuditLog object instead of a raw list. Call .export_jsonl() on the returned object to get the list of entries.
Webhook Shipping
When configured, each audit entry is POSTed to the webhook URL with an X-PyHSM-Signature HMAC header for authenticity verification.
Shamir Secret Sharing
Split the master password into N shares where any K shares can reconstruct it, but K-1 or fewer reveal zero information. Implemented over GF(256) using the AES irreducible polynomial.
Python API
from hsm import split_secret, reconstruct_secret, zeroize
# Split a secret into 5 shares, threshold of 3
secret = b"my-master-password"
shares = split_secret(secret, k=3, n=5)
# shares = [{"index": 1, "data": "hex...", "share_hmac": "...", "hmac_salt": "...", "reconstruction_hmac": "..."}, ...]
# Reconstruct from any 3 shares
recovered = reconstruct_secret(shares[:3])
# recovered = bytearray(b"my-master-password")
# Always zeroize after use
zeroize(recovered)
index, data (hex-encoded share bytes), share_hmac (HMAC-SHA256 of index_byte || data, keyed on hmac_salt), hmac_salt (32-byte random hex, shared across all shares from the same split), and reconstruction_hmac (HMAC-SHA256 of the full reconstructed secret, catches insufficient-share reconstruction). Old shares with only a checksum field remain backward-compatible.
Constraints
- 2 ≤ k ≤ n ≤ 255
- Secret must be non-empty
- Shares from different split ceremonies are not interchangeable
- K-1 shares reveal mathematically zero information (information-theoretic security)
Production Use
In production, distribute shares to separate custodians in different locations. At startup, gather the threshold number of shares to reconstruct the master password and unlock the HSM.
splitMasterPassword() and reconstructMasterPassword() helpers, plus support for injecting shares via the PYHSM_SHARES environment variable as comma-separated JSON.
Process Isolation
The highest security mode separates key material into a dedicated OS process communicating over a Unix domain socket. Even a full RCE in your application cannot read the HSM process's memory without a kernel exploit.
Architecture
┌─────────────────────┐ ┌─────────────────────┐
│ Your Application │ IPC │ PyHSM Process │
│ │ ──────► │ │
│ PyHSMClient │ Unix │ Keys in memory │
│ (sends requests) │ Socket │ Crypto operations │
│ │ ◄────── │ Zeroization │
└─────────────────────┘ └─────────────────────┘
TypeScript Client
import { PyHSMClient } from "pyhsm-ts/client";
const client = new PyHSMClient("/tmp/pyhsm.sock", "my-service");
// All operations go through IPC
const ciphertext = await client.encrypt("data-key", "secret");
const plaintext = await client.decrypt("data-key", ciphertext);
const health = await client.health();
// { status: "ok", uptime: 3600 }
Security Properties
- Socket permissions restricted to
0600, owned by the pyhsm service account - Mutual authentication is mandatory —
--ipc-secretrequired at server startup; unauthenticated requests rejected before any operation - Per-key locking — operations on different keys run in parallel (no global lock DoS)
- 30-second per-connection read timeout — stalled clients dropped cleanly
- Read-only operations (
health,list_keys,has_key,get_public_key,get_metrics) hold no lock at all - Max IPC message size: 1 MB (prevents memory exhaustion)
- Graceful shutdown on SIGTERM/SIGINT
- Your application never sees raw key bytes
Configuration
| Variable | Default | Description |
|---|---|---|
PYHSM_SOCKET_PATH | /tmp/pyhsm.sock | Unix socket path for IPC |
PYHSM_IPC_SECRET / --ipc-secret | Required | Shared HMAC secret for mutual authentication (mandatory since v2.2.0) |
PYHSM_CALLER_SECRET | none | TypeScript layer: shared secret for IPC caller auth |
Environment Variables
Python
| Variable | Default | Description |
|---|---|---|
PYHSM_MASTER_PASSWORD | none | Master password for CLI scripting (avoid in production) |
PYHSM_LOG_LEVEL | WARNING | Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL |
PYHSM_ALLOW_PBKDF2_FALLBACK | 0 | Set to 1 to allow PBKDF2 fallback (testing only) |
PYHSM_AUDIT_HMAC_KEY | derived | Hex 32-byte audit HMAC key override |
PYHSM_AUDIT_WEBHOOK | none | URL for audit event webhook delivery |
PYHSM_LICENSE_TOKEN | none | Inline commercial JWT license token (v2.3.0+) |
PYHSM_LICENSE_FILE | none | Path to commercial JWT license token file (v2.3.0+) |
TypeScript
| Variable | Default | Description |
|---|---|---|
PYHSM_MASTER_PASSWORD | none | Required (or use PYHSM_SHARES) |
PYHSM_SHARES | none | Comma-separated Shamir share JSONs for unlock |
PYHSM_KEYSTORE_PATH | ./pyhsm-keystore.enc | Keystore file location |
PYHSM_AUDIT_LOG_PATH | <store>.audit.jsonl | Audit log file path |
PYHSM_BACKUP_DIR | none | Backup directory |
PYHSM_SOCKET_PATH | /tmp/pyhsm.sock | IPC socket path (process isolation) |
PYHSM_CALLER_SECRET | none | Shared secret for IPC caller auth |
PYHSM_SESSION_TIMEOUT_MS | 300000 | Session idle timeout (milliseconds) |
PYHSM_RATE_LIMIT | 100 | Max operations per key per window |
PYHSM_RATE_WINDOW_MS | 60000 | Rate limit window (milliseconds) |
PYHSM_KEY_ID | pyhsm-master | Default key ID for singleton helpers |
Key Policies
Policies are set at key generation time and enforce security constraints throughout the key's lifecycle.
| Policy Field | Type | Description |
|---|---|---|
allow_encrypt | bool | Allow encryption operations (default: true) |
allow_decrypt | bool | Allow decryption operations (default: true) |
allow_sign | bool | Allow signing operations (default: true) |
allow_export | bool | Allow JWK export (default: false — must be explicitly set to true) |
max_operations | int | Maximum total operations (lifetime cap) |
expires_at | ISO 8601 | Key expiration timestamp |
rotate_every_days | int | Auto-rotation interval in days (AES only) |
allowed_callers | list[str] | Authorized caller IDs (ACL) |
Example: Full Policy
hsm.generate_key("production-key", key_type="aes-256", policy={
"allow_encrypt": True,
"allow_decrypt": True,
"allow_export": False,
"max_operations": 1000000,
"expires_at": "2028-01-01T00:00:00Z",
"rotate_every_days": 90,
"allowed_callers": ["api-gateway", "worker-service"],
})
Compliance Coverage
PyHSM is a software key management system. It uses FIPS-approved algorithms but is not FIPS 140-2/3 certified. For environments where certification of the specific binary is mandatory (PCI-DSS Level 1 SAQ D, FedRAMP High, DoD IL4+), a validated hardware HSM is required. PyHSM is appropriate as a software KMS for all other environments where software key management is acceptable.
Framework Coverage Summary
| Framework | Coverage | Notes |
|---|---|---|
| SOC 2 Type II | Partial | CC6.1, CC6.2, CC6.6, CC7.2, CC9.1 covered; CC6.3, CC7.3 partial; CC6.8 out of scope |
| PCI-DSS v4.0 | Partial | Req 3.5–3.7, 6.3, 8.2, 10.2, 10.3, 10.5 covered; Req 12.3.3 not met (no FIPS cert) |
| HIPAA Security Rule | Partial | §164.312(a)(1), §164.312(b), §164.308(a)(1)(ii)(D) covered; physical safeguards out of scope |
| NIST SP 800-57 | Meets | Full key lifecycle; all algorithms per SP 800-131A Rev. 2; 10-primitive KATs |
| NIST SP 800-63B | Meets | Argon2id KDF, 12-char minimum, weak-password blocklist, Shamir with per-share HMAC |
| ISO/IEC 27001:2022 | Partial | A.8.24, A.8.10, A.8.15, A.8.16, A.5.33, A.8.20 covered; A.8.7, A.8.9 partial |
| FIPS 140-2/3 | Does not meet | FIPS-approved algorithms; not CMVP-validated; Argon2id is not a FIPS-approved KDF |
| GDPR Article 32 | Meets (technical) | Encryption, integrity, availability, regular testing covered; organizational measures out of scope |
Audit Evidence Package
When preparing for a compliance audit, provide the following:
THREAT_MODEL.md— security boundary documentationSECURITY_POLICY.md— formal security policyCOMPLIANCE.md— per-control coverage matrix- Audit log export —
audit --verify && audit --raw - Cross-rotation chain verification — output of
verify_across_rotations()showingok: true - Dependency scan results —
pip-auditoutput from latest CI run - Test coverage report — from latest CI run (76%+ line coverage)
requirements.lock— hash-pinned dependency manifest- KAT results —
selfTestPassentries from the audit log confirming all 10 primitives passed
Deployment Hardening
For production deployments, follow the full guide in HARDENING.md in the repository. The critical requirements are summarized here.
Required Steps
useradd --system --no-create-home --shell /usr/sbin/nologin pyhsm
install -d -m 750 -o pyhsm -g pyhsm /var/lib/pyhsm /run/pyhsm /var/log/pyhsm
# Place on tmpfs so it never touches disk
mount -t tmpfs -o size=1m,mode=700 tmpfs /run/secrets
printf '%s' 'YourStr0ng!Passphrase' > /run/secrets/pyhsm-password
chmod 600 /run/secrets/pyhsm-password
chown pyhsm:pyhsm /run/secrets/pyhsm-password
openssl rand -hex 32 > /run/secrets/pyhsm-ipc-secret
chmod 600 /run/secrets/pyhsm-ipc-secret
chown pyhsm:pyhsm /run/secrets/pyhsm-ipc-secret
pipx install --require-hashes -r requirements.lock
python3 -c "
from hsm.audit import AuditLog
import sys
key = open('/run/secrets/pyhsm-audit-key', 'rb').read()
log = AuditLog('/var/log/pyhsm/keystore.enc.audit.jsonl', hmac_key=key)
result = log.verify_across_rotations()
if result['ok']:
print('Audit chain OK — files checked:', result['files_checked'])
else:
print('TAMPER DETECTED:', result['first_error'], file=sys.stderr)
sys.exit(1)
"
Pre-Production Checklist
[ ] PyHSM runs as a dedicated unprivileged user (not root)
[ ] Keystore file: chmod 600, owned by pyhsm user
[ ] Password file: chmod 600, on tmpfs
[ ] IPC secret file: chmod 600, on tmpfs (openssl rand -hex 32)
[ ] IPC server started with --ipc-secret (mandatory as of v2.2.0)
[ ] Audit log: chmod 640, shipped to SIEM
[ ] verify_across_rotations() returns ok=true
[ ] Swap encrypted or disabled
[ ] pipx install --require-hashes -r requirements.lock
[ ] systemd unit: NoNewPrivileges=true, MemoryDenyWriteExecute=true
[ ] Backup tested with verify-backup
[ ] NTP synchronized (key expiry depends on accurate time)
[ ] auditd watching keystore and audit log files
See HARDENING.md for complete systemd unit configuration, Docker hardening flags, swap/memory protection, and Shamir key ceremony procedures.
TypeScript Layer
PyHSM includes a full TypeScript/Node.js implementation with feature parity. It uses AES-256-GCM-SIV (nonce-misuse resistant) via @noble/ciphers.
Installation
cd pyhsm-ts
npm install
npm run build
Usage
import { PyHSM } from "./core";
// v2: PyHSM.create() is the ONLY supported factory for production use
// It guarantees Argon2id KDF (64 MB / 3 passes / 4 threads)
const hsm = await PyHSM.create({
storePath: "./keystore.enc",
masterPassword: "strong-password",
});
// Generate a key
hsm.generateKey("my-key");
// Encrypt and decrypt
const ct = hsm.encrypt("my-key", "secret data");
const pt = hsm.decrypt("my-key", ct); // returns string
// Clean up
hsm.closeSession();
new PyHSM({...})) is retained for testing only and now emits a deprecation warning at runtime. Use await PyHSM.create({...}) in all production code.
Key Differences from Python
- Uses AES-256-GCM-SIV (nonce-misuse resistant) instead of AES-256-GCM
PyHSM.create()is the production factory (Argon2id); sync constructor is testing-only and emits a deprecation warning- Deferred persistence with dirty-flag flushing for higher throughput
SecureBufferclass for deterministic zeroization- Pinned exact dependency versions in
package.json