Vortos
Secrets

Secrets

Redact-by-construction secret values, envelope encryption with off-host key custody, and policy-driven two-phase rotation.

Secrets

vortos-secrets exists to make one mistake structurally impossible: a secret plaintext ending up somewhere it shouldn't — a log line, a stack trace, a var_dump() left in debug output, a serialized session. Most secrets-management advice is procedural ("don't log secrets"). Vortos Secrets makes the unsafe thing not compile, or not run, instead of relying on everyone remembering the rule.

Where this fits

This is not Security's secrets provider (the simple env-var-by-default lookup used for app-level config like API keys). vortos-secrets is the operational secrets layer — used by Deploy for credential injection, by Backup for encrypting backup payloads, and anywhere a secret needs envelope encryption, rotation, or off-host key custody rather than a plain getenv() call.

SecretValue — redaction by construction

Every secret plaintext that flows through this package, from the moment it leaves the cipher to the moment it's injected into a process environment, is wrapped in a SecretValue. It is built so that every accidental leak path is closed:

use Vortos\Secrets\Value\SecretValue;

$secret = SecretValue::fromString('super-secret-api-key');

echo $secret;                 // "***"
var_dump($secret);            // object(SecretValue)#1 (0) { } — nothing to leak
json_encode($secret);         // "***"
print_r($secret);             // *** (not the plaintext)

$secret->reveal();            // 'super-secret-api-key' — the ONLY way to get it back

The plaintext is never stored as a normal instance property. It lives in a private static WeakMap keyed by object identity — because PHP's var_export() and print_r() bypass __debugInfo() entirely and serialize an object's actual properties via engine-level reflection. A plaintext held as private string $plaintext would leak through those two functions despite every magic method being correctly overridden. Keeping the value out-of-band means there is genuinely nothing on the object for those functions to find.

$secret->wipe();               // zeroizes the buffer (sodium_memzero where available)
$secret->reveal();             // throws SecretAlreadyWipedException — fails closed, never ''

reveal() is the single method that returns the plaintext — every call site is a deliberate, auditable boundary crossing. Attempting to serialize() a SecretValue throws immediately rather than risk leaking the plaintext into a serialized blob.

Call reveal() as late as possible

Hold a SecretValue for as long as you can before calling reveal(). The moment you reveal, you're back to holding a plain PHP string with no protection — pass it straight into the API or process call that needs it, then let it go out of scope.

Envelope encryption

Payload encryption and key custody are deliberately two separate concerns, so the custody mechanism can change (today: age; later: KMS, Vault Transit) without ever touching the encryption code path:

plaintext

    ▼ EnvelopeCipher::generateDataKey()      → fresh random 32-byte DEK
    ▼ EnvelopeCipher::encryptPayload()       → XChaCha20-Poly1305 AEAD

    ▼ KeyProviderInterface::wrap($dek)       → DEK sealed under the recipient's public key

ciphertext + nonce + wrapped DEK = SecretEnvelope (safe to store anywhere)

EnvelopeCipher knows nothing about recipients or identities — only AEAD. The AEAD authentication tag covers both ciphertext and a fixed AAD string (vortos-secrets-envelope-v1), so a tampered ciphertext, nonce, or wrong key fails verification outright and throws DecryptionFailedException. There is no partial-plaintext failure mode.

use Vortos\Secrets\Crypto\EnvelopeCipher;

$cipher = new EnvelopeCipher();
$dek = $cipher->generateDataKey();
$result = $cipher->encryptPayload('plaintext value', $dek);
// ['ciphertext' => ..., 'nonce' => ...]

$plaintext = $cipher->decryptPayload($result['ciphertext'], $result['nonce'], $dek);

Off-host key custody with age

The age driver implements X25519 sealed-box key wrapping. The key property that makes this "off-host": wrap() only ever needs the public key, which can live anywhere, including baked into the application image. unwrap() sources the private identity from an environment variable at use-time — never from a file tracked by version control, never bundled into a deploy artifact.

The driver accepts the standard formats age-keygen emits — the bech32 recipient (age1…) and identity (AGE-SECRET-KEY-1…) — as well as raw base64 of the 32-byte X25519 keys. Every path validates length and (for bech32) the checksum, failing closed on anything malformed.

use Vortos\Secrets\Driver\Age\AgeKeyProvider;

$provider = new AgeKeyProvider(
    publicKey: $_ENV['VORTOS_SECRETS_AGE_PUBLIC_KEY'],   // age1… or base64 X25519
    identitySeedEnvVar: 'VORTOS_SECRETS_AGE_IDENTITY',   // holds AGE-SECRET-KEY-1… or base64; read only when unwrap() runs
);

A missing or malformed identity fails closed with KeyUnavailableException. A wrapped key that's tampered with, or sealed under a different identity entirely, fails closed with DecryptionFailedException. Neither path returns a default or empty value — both throw.

Why split wrap and unwrap this way

In production, the host that encrypts secrets (a CI runner, a build step) often shouldn't be trusted with the ability to decrypt them. Because wrap() only needs the public key, you can generate and seal secrets entirely from a CI pipeline that never has access to the private identity — only the runtime environment that's supposed to consume the secret does.

Secrets providers

SecretsProviderInterface is the swap seam for where a secret actually lives — built on the Ops Kit driver pattern, so vault or aws-ssm drivers can be added later without changing any calling code:

interface SecretsProviderInterface extends DriverInterface
{
    public function get(SecretKey $key): SecretValue;
    public function put(SecretKey $key, SecretValue $value): SecretVersion;
    public function rotate(SecretKey $key, RotationPolicy $policy): RotationResult;
    public function list(): array;       // names only — never values
    public function versions(SecretKey $key): SecretMetadata;
}

list() returning names only (never values) is deliberate — anything that just needs to check whether a secret exists (like preflight checks) never has to touch a value to do it.

Two-phase rotation

Rotation is split into a policy decision and a mechanism, so the "is this secret due for rotation" arithmetic lives in exactly one place instead of being reimplemented by every provider driver:

use Vortos\Secrets\Service\RotationManager;
use Vortos\Secrets\Rotation\RotationPolicy;

$manager = new RotationManager($provider);

$policy = new RotationPolicy(
    rotationInterval: 2592000,   // 30 days
    gracePeriod: 86400,          // 24 hours both versions stay valid
    maxAge: 5184000,             // 60 days — hard cutoff
);

// only rotates if the current version is actually due
$result = $manager->rotateIfDue($key, $policy, new \DateTimeImmutable());

// rotate regardless of due-date — an explicit operator action
$manager->forceRotate($key, $policy);

During the grace period, both the old and new versions remain valid — this is what makes rotation safe for a multi-instance deployment where not every process picks up the new value at the same instant.

CLI

# list known secret keys (names only)
php bin/console secrets:list --env=age

# set a value — interactive hidden prompt, or pipe via stdin for CI
php bin/console secrets:set API_KEY --env=age
echo -n "value" | php bin/console secrets:set API_KEY --env=age --stdin

# rotate a secret with explicit policy windows
php bin/console secrets:rotate API_KEY --env=age --interval=2592000 --grace=86400 --max-age=5184000

# check that every secret an environment needs is actually present
php bin/console secrets:preflight --env=production --json

For secrets:preflight, --env is the environment name (e.g. production, staging), resolved to a secrets driver — defaulting to the env driver, which is where production runtime secrets are envelope-decrypted at boot. So --env=production works out of the box (it does not throw UnknownDriverException). Map an environment to a different driver with VORTOS_SECRETS_ENVIRONMENT_DRIVERS="production:env,staging:vault", or bypass resolution entirely with --driver=<key>. (On secrets:list / secrets:set / secrets:rotate, --env still selects the driver directly.)

Preflight checks

SecretsPreflight diffs a declared RequiredSecrets list against what a provider actually has, using list() rather than get() — checking presence never requires revealing a value:

use Vortos\Secrets\Preflight\RequiredSecrets;
use Vortos\Secrets\Service\SecretsPreflight;

$report = (new SecretsPreflight())->check($provider, RequiredSecrets::of([
    SecretKey::fromString('DATABASE_URL'),
    SecretKey::fromString('JWT_SECRET'),
]));

$report->missing; // list<SecretKey> — what's missing, if anything

secrets:preflight runs this as a CI gate. The same PreflightReport is consumed by Deploy's doctor checks, so a deploy that's missing a required secret fails before it ever touches infrastructure.

Declare the required secrets in config/secrets.php — a list of SecretReference (or a closure returning one), loaded by RequiredSecretsFactory. This is what makes the preflight/deploy gate meaningful; no service override needed:

config/secrets.php
use Vortos\Secrets\Preflight\SecretReference;
use Vortos\Secrets\Key\SecretKey;

return [
    new SecretReference(new SecretKey('DATABASE_URL'), required: true,  description: 'Primary DB DSN'),
    new SecretReference(new SecretKey('SENTRY_DSN'),   required: false),
];

Default provider binding

SecretsProviderInterface now aliases to EnvSecretsProvider by default, so cross-package consumers (e.g. Deploy's credential provider) resolve it with zero app wiring.

Step-by-step: wiring a new secret

Generate an age keypair and store the public half in your environment, the private half off-host (a CI secret store, a hardware token — never a tracked file):

age-keygen -o identity.txt   # keep this off-host

Configure the provider:

config/secrets.php
use Vortos\Secrets\DependencyInjection\VortosSecretsConfig;

return static function (VortosSecretsConfig $config): void {
    $config->provider('age')
        ->publicKey($_ENV['SECRETS_AGE_PUBLIC_KEY']);
};

Declare it as required so preflight catches a missing value before deploy:

RequiredSecrets::of([SecretKey::fromString('STRIPE_SECRET_KEY')]);

Set the value:

echo -n "sk_live_..." | php bin/console secrets:set STRIPE_SECRET_KEY --stdin

Verify before deploying:

php bin/console secrets:preflight

On this page