phylax
Misuse-resistant cryptography for Ruby, backed by the Windows CNG provider.
phylax (Greek φύλαξ, "guardian") is a thin, safe-by-default binding to the
cryptography Windows already ships and validates: the Cryptography API: Next
Generation (CNG / bcrypt.dll) and DPAPI (crypt32.dll). It implements no
cryptography of its own — it exposes the operating system's own primitives through
an ergonomic, hard-to-misuse API.
The design goal is that the easy way is the safe way:
- Authenticated encryption that can't be nonce-reused.
SecretBox(AES-256-GCM) generates a fresh 96-bit nonce on everysealand frames it into the output, so the catastrophic GCM failure mode — reusing a(key, nonce)pair — is impossible through the public API. There is no nonce parameter to get wrong. - Decryption is all-or-nothing.
openverifies the authentication tag inside the OS call and raisesPhylax::AuthenticationErrorrather than ever returning unauthenticated plaintext. - No silent weakening. A key that isn't exactly 32 bytes is a loud error, not a
silently-truncated AES-128 key. Only SHA-2 is accepted —
:md5/:sha1raise. - Constant-time comparison is provided so you don't verify MACs with
==.
| What | API |
|---|---|
| Secure random | Phylax.random_bytes(n) |
| Hashing (one-shot) | Phylax.sha256(data), .sha384, .sha512 |
| Hashing (streaming) | Phylax::Digest.new(:sha256) |
| HMAC (one-shot) | Phylax.hmac_sha256(key, data), .hmac_sha384, .hmac_sha512 |
| HMAC (streaming) | Phylax::HMAC.new(:sha256, key), Phylax::HMAC.verify(...) |
| Key derivation | Phylax.pbkdf2(password:, salt:, iterations:, length:, hash:) |
| Authenticated encryption | Phylax::SecretBox (AES-256-GCM) |
| Constant-time compare | Phylax.secure_compare(a, b) |
| Secrets at rest | Phylax.protect(data, scope:, entropy:) / Phylax.unprotect |
Requirements
- Windows with a native MSVC (mswin) Ruby. Not supported on MinGW/UCRT.
- Visual Studio 2017+ / Build Tools with the Desktop development with C++ workload.
Install
gem install phylax
Authenticated encryption — SecretBox
require "phylax"
key = Phylax::SecretBox.generate_key # 32 cryptographically random bytes
box = Phylax::SecretBox.new(key)
sealed = box.seal("attack at dawn") # => nonce ‖ ciphertext ‖ tag (binary)
box.open(sealed) # => "attack at dawn"
# Additional authenticated data (authenticated, not encrypted) — must match.
sealed = box.seal(payload, aad: "v1:user-42")
box.open(sealed, aad: "v1:user-42") # ok
box.open(sealed, aad: "v1:user-99") # raises Phylax::AuthenticationError
# Any tampering with the nonce, ciphertext, tag, or aad fails closed:
box.open(sealed.tap { |s| s.setbyte(20, s.getbyte(20) ^ 1) })
# => raises Phylax::AuthenticationError (no plaintext is returned)
Derive the key from a password instead (PBKDF2-HMAC-SHA256):
salt = Phylax.random_bytes(16) # store this alongside the ciphertext
box = Phylax::SecretBox.from_password("correct horse battery staple",
salt: salt, iterations: 600_000)
Wire format. seal returns nonce (12 bytes) ‖ ciphertext (= plaintext length) ‖ tag (16 bytes),
so the overhead is a constant Phylax::SecretBox::OVERHEAD (28) bytes. This is
standard AES-256-GCM: a blob from seal can be decrypted by any GCM
implementation (e.g. OpenSSL) given the same key, and vice versa.
Hashing and HMAC
Phylax.sha256("data") # => 32 raw bytes (ASCII-8BIT)
Phylax.sha256("data").unpack1("H*") # => hex
# Streaming — non-destructive #digest, so you can keep updating:
d = Phylax::Digest.new(:sha256)
d << "chunk one" << "chunk two"
d.hexdigest
Phylax.hmac_sha256(key, "message") # keyed MAC, raw bytes
# Verify a MAC the safe way (constant-time, never raises on mismatch):
Phylax::HMAC.verify(:sha256, key, , received_tag) # => true / false
Key derivation, random, and comparison
Phylax.pbkdf2(password: pw, salt: salt, iterations: 600_000, length: 32)
Phylax.random_bytes(16) # system CSPRNG (CTR_DRBG)
Phylax.secure_compare(tag_a, tag_b) # constant-time for equal-length inputs
Secrets at rest — DPAPI
protect encrypts data with a key the OS derives from the current user (default)
or the machine, so the blob can only be read back on the same machine (and, for
:user scope, by the same user). Great for storing a credential or a SecretBox
key on disk without a master password.
blob = Phylax.protect("api-token-value") # bound to this Windows user
Phylax.unprotect(blob) # => "api-token-value"
# Optional second secret ("entropy") that must be supplied to unprotect:
blob = Phylax.protect(secret, entropy: app_salt)
Phylax.unprotect(blob, entropy: app_salt)
# Machine scope: any account on this machine can unprotect.
Phylax.protect(secret, scope: :machine)
Errors
StandardError
└─ Phylax::Error # base for everything phylax raises
├─ Phylax::AuthenticationError # GCM tag mismatch / tamper, or DPAPI integrity failure
└─ Phylax::OSError # other Windows API failure (#api, #code)
ArgumentError/TypeError are raised for bad sizes or types before any OS call
(e.g. a key that isn't 32 bytes, an unknown hash, a non-String input).
Security notes
- Algorithms. AES-256-GCM (128-bit tag), SHA-256/384/512, HMAC-SHA-2,
PBKDF2-HMAC-SHA-2,
BCryptGenRandom(SP 800-90A CTR_DRBG). All are the OS's validated implementations; phylax only marshals bytes. - Random-nonce limit. With random 96-bit nonces, keep a single
SecretBoxkey under ~2³² messages (the GCM birthday bound). Rotate keys for very high volume. secure_compareleaks length. It returnsfalseimmediately when lengths differ and is constant-time only across equal-length inputs — fine for comparing fixed-length MACs, which is its purpose.- DPAPI tamper detection is best-effort. Windows may return an error or, rarely,
succeed with corrupted output on a mangled blob. If you need a hard integrity
guarantee, wrap the data in a
SecretBox(or add your own HMAC) first. - Windows/MSVC only. The extension links
bcrypt.lib+crypt32.liband is built withcl.exeagainst a native-MSVC Ruby.
License
MIT.