Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/crypto.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Direct cryptographic primitives and operations:
- stream encryption
- binary codec

This is the lower-level surface of Epicrypt.

For most new applications, prefer the higher-level ``Password``, ``Token``, ``DataProtection``, and ``Security`` domains first, then drop down into ``Crypto`` only when you truly need primitive-level control.

AEAD Cipher
-----------

Expand Down
48 changes: 39 additions & 9 deletions docs/data-protection.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ String Protector

use Infocyph\Epicrypt\DataProtection\StringProtector;
use Infocyph\Epicrypt\Generate\KeyMaterial\KeyMaterialGenerator;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$key = (new KeyMaterialGenerator())->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);

$protector = new StringProtector();
$protector = StringProtector::forProfile(SecurityProfile::MODERN);
$ciphertext = $protector->encrypt('sensitive data', $key);
$plaintext = $protector->decrypt($ciphertext, $key);

Expand All @@ -35,10 +36,12 @@ Rotation and Migration

use Infocyph\Epicrypt\DataProtection\StringProtector;
use Infocyph\Epicrypt\Security\KeyRing;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$ring = new KeyRing(['legacy' => $legacyKey, 'current' => $currentKey], 'current');
$plaintext = (new StringProtector())->decryptWithAny($ciphertext, $ring);
$migrated = (new StringProtector())->reencryptWithAny($ciphertext, $ring, $currentKey);
$result = StringProtector::forProfile(SecurityProfile::MODERN)->decryptWithAnyKeyResult($ciphertext, $ring);
$plaintext = $result->plaintext;
$migrated = StringProtector::forProfile(SecurityProfile::MODERN)->reencryptWithAnyKey($ciphertext, $ring, $currentKey);

Envelope Protector
------------------
Expand All @@ -47,12 +50,14 @@ Envelope Protector

use Infocyph\Epicrypt\DataProtection\EnvelopeProtector;
use Infocyph\Epicrypt\Generate\KeyMaterial\KeyMaterialGenerator;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$masterKey = (new KeyMaterialGenerator())->generate(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$envelope = (new EnvelopeProtector())->encrypt('payload', $masterKey);
$envelopeProtector = EnvelopeProtector::forProfile(SecurityProfile::MODERN);
$envelope = $envelopeProtector->encrypt('payload', $masterKey);

$encoded = (new EnvelopeProtector())->encodeEnvelope($envelope);
$plain = (new EnvelopeProtector())->decrypt($encoded, $masterKey);
$encoded = $envelopeProtector->encodeEnvelope($envelope);
$plain = $envelopeProtector->decrypt($encoded, $masterKey);

Envelope payload includes:

Expand All @@ -66,8 +71,32 @@ Envelope Re-Encryption

.. code-block:: php

$migrated = (new EnvelopeProtector())->reencryptWithAny($encoded, [$legacyMasterKey], $currentMasterKey);
$plain = (new EnvelopeProtector())->decrypt($migrated, $currentMasterKey);
use Infocyph\Epicrypt\DataProtection\EnvelopeProtector;
use Infocyph\Epicrypt\Security\KeyRing;

$protector = EnvelopeProtector::forProfile(SecurityProfile::MODERN);
$ring = new KeyRing(['legacy' => $legacyMasterKey, 'current' => $currentMasterKey], 'current');
$result = $protector->decryptWithAnyKeyResult($encoded, $ring);
$migrated = $protector->reencryptWithAnyKey($encoded, $ring, $currentMasterKey);
$plain = $protector->decrypt($migrated, $currentMasterKey);

File Re-Encryption
------------------

.. code-block:: php

use Infocyph\Epicrypt\DataProtection\FileMigrationResult;
use Infocyph\Epicrypt\DataProtection\FileProtector;
use Infocyph\Epicrypt\Security\KeyRing;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$ring = new KeyRing(['legacy' => $legacyFileKey, 'current' => $currentFileKey], 'current');
$result = FileProtector::forProfile(SecurityProfile::MODERN)->reencryptWithAnyKey(
'/tmp/input.txt.epc',
'/tmp/input.txt.new.epc',
$ring,
$currentFileKey,
);

File Protector
--------------
Expand All @@ -76,11 +105,12 @@ File Protector

use Infocyph\Epicrypt\DataProtection\FileProtector;
use Infocyph\Epicrypt\Generate\KeyMaterial\KeyMaterialGenerator;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$key = (new KeyMaterialGenerator())
->generate(SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES);

$file = new FileProtector();
$file = FileProtector::forProfile(SecurityProfile::MODERN);
$file->encrypt('/tmp/input.txt', '/tmp/input.txt.epc', $key);
$file->decrypt('/tmp/input.txt.epc', '/tmp/input.dec.txt', $key);

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Documentation Map
getting-started
architecture
security-recommendations
migration-and-rotation

.. toctree::
:maxdepth: 2
Expand Down
131 changes: 131 additions & 0 deletions docs/migration-and-rotation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
Migration and Rotation Cookbook
===============================

This page shows the preferred Epicrypt migration pattern once your application has:

- one active key for new writes
- one or more fallback keys for legacy reads
- a short migration window where successful fallback reads trigger re-issue or re-encryption

Core Model
----------

Use the same mental model across domains:

- active key: used for all new writes
- fallback keys: accepted only during migration
- matched key id: recorded when you need to know which candidate succeeded
- used fallback key: tells you whether the value should be rewritten under the active key

Represent that with ``KeyRing``:

.. code-block:: php

use Infocyph\Epicrypt\Security\KeyRing;

$ring = new KeyRing([
'legacy-2025' => $legacyKey,
'active-2026' => $activeKey,
], 'active-2026');

Protected Strings
-----------------

Use ``decryptWithAnyKeyResult()`` when a protected value may have been encrypted with an older key.

.. code-block:: php

use Infocyph\Epicrypt\DataProtection\StringProtector;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$protector = StringProtector::forProfile(SecurityProfile::MODERN);
$result = $protector->decryptWithAnyKeyResult($ciphertext, $ring);

$plaintext = $result->plaintext;

if ($result->usedFallbackKey) {
$ciphertext = $protector->reencryptWithAnyKey($ciphertext, $ring, $activeKey);
}

Envelope-Protected Data
-----------------------

``EnvelopeProtector`` follows the same flow.

.. code-block:: php

use Infocyph\Epicrypt\DataProtection\EnvelopeProtector;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$protector = EnvelopeProtector::forProfile(SecurityProfile::MODERN);
$result = $protector->decryptWithAnyKeyResult($encodedEnvelope, $ring);

if ($result->usedFallbackKey) {
$encodedEnvelope = $protector->reencryptWithAnyKey($encodedEnvelope, $ring, $activeMasterKey);
}

Wrapped Secrets
---------------

Wrapped secrets should also move forward when an older master key matches.

.. code-block:: php

use Infocyph\Epicrypt\Password\Secret\WrappedSecretManager;

$manager = new WrappedSecretManager();
$result = $manager->unwrapWithAnyKeyResult($wrappedSecret, $ring);

$secret = $result->plaintext;

if ($result->usedFallbackKey) {
$wrappedSecret = $manager->rewrapWithAnyKey($wrappedSecret, $ring, $activeMasterSecret);
}

JWT and Signed Payload Verification
-----------------------------------

When you only need verification metadata, prefer ``verifyWithAnyKeyResult()``.

.. code-block:: php

$result = $jwt->verifyWithAnyKeyResult($token, $ring);

if (!$result->verified) {
throw new RuntimeException('Token verification failed.');
}

if ($result->usedFallbackKey) {
// Re-issue the token with the active signing key when appropriate.
}

Files
-----

Protected files should be re-encrypted into a new destination or migrated in place.

.. code-block:: php

use Infocyph\Epicrypt\DataProtection\FileProtector;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;

$files = FileProtector::forProfile(SecurityProfile::MODERN);
$result = $files->reencryptWithAnyKey(
'/secure/archive.epc',
'/secure/archive.current.epc',
$ring,
$activeFileKey,
);

if ($result->usedFallbackKey) {
// The migration consumed a fallback key and is now on the active key.
}

Operational Guidance
--------------------

- keep fallback keys only as long as legacy artifacts still exist
- rewrite on successful fallback reads when doing so is safe for your workflow
- remove old keys after the migration window closes
- prefer ``SecurityProfile::MODERN`` for new writes unless you are intentionally operating a compatibility boundary
- use ``SecurityProfile::LEGACY_DECRYPT_ONLY`` on profile-aware factories when a service should keep reading old artifacts but must stop producing new ones
3 changes: 2 additions & 1 deletion docs/password.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ Wrapped Secret Rotation
$rotated = $manager->rewrap($wrapped, $oldMasterSecret, $newMasterSecret);

$ring = new KeyRing(['old' => $oldMasterSecret, 'new' => $newMasterSecret], 'new');
$plain = $manager->unwrapWithAny($rotated, $ring);
$result = $manager->unwrapWithAnyKeyResult($rotated, $ring);
$plain = $result->plaintext;

Secure Secret Serialization
---------------------------
Expand Down
23 changes: 22 additions & 1 deletion docs/security-recommendations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ If you are starting fresh, prefer these defaults:
- Browser workflow tokens: ``Security`` domain helpers such as ``PasswordResetToken`` and ``SignedUrl``
- New ciphertext formats: modern ``DataProtection`` APIs, not compatibility helpers

Public Surface First
--------------------

For new applications, start from these domains first:

- ``Password``
- ``Token``
- ``DataProtection``
- ``Security``

Treat these as lower-level or advanced:

- ``Crypto`` for direct primitive control
- compatibility helpers only for migration or interop boundaries

Choose the Right Tool
---------------------

Expand Down Expand Up @@ -66,11 +81,13 @@ Use:
- ``StringProtector`` for ordinary application payloads
- ``EnvelopeProtector`` when you want a structured encoded envelope
- ``FileProtector`` for large files and streaming-safe encryption
- ``FileProtector::reencryptWithAnyKey()`` when migrating protected files across rotating keys

Prefer:

- re-encrypting stored legacy ciphertext into the current format
- ``decryptWithAny()`` or ``reencryptWithAny()`` only during migration windows
- ``decryptWithAnyKey()`` or ``reencryptWithAnyKey()`` only during migration windows
- result helpers like ``decryptWithAnyKeyResult()`` when migration code needs to know which key matched

Avoid:

Expand All @@ -91,6 +108,7 @@ Prefer:

- ``kid`` plus key-set mode when rotating JWT signing keys
- ``KeyRing``-based verification helpers during short transition windows
- ``verifyWithAnyKeyResult()`` when callers need to know whether a fallback key matched

Avoid:

Expand Down Expand Up @@ -139,10 +157,13 @@ Prefer this pattern:
2. re-encrypt or re-issue using the current Epicrypt format
3. keep fallback verification only for a short migration period

If you want a hard migration boundary, use ``SecurityProfile::LEGACY_DECRYPT_ONLY`` on profile-aware factories. It allows decrypt/verify flows while blocking new encrypt/issue operations.

In particular:

- prefer ``StringProtector`` and ``EnvelopeProtector`` for new protected data
- treat ``OpenSSL\InteroperabilityCryptoHelper`` as migration-oriented, not as the preferred default
- follow the migration cookbook for active-key and fallback-key rollout patterns

Binary vs Base64URL
-------------------
Expand Down
25 changes: 13 additions & 12 deletions docs/token.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Symmetric JWT

.. code-block:: php

use Infocyph\Epicrypt\Security\Policy\SecurityProfile;
use Infocyph\Epicrypt\Token\Jwt\SymmetricJwt;
use Infocyph\Epicrypt\Token\Jwt\Enum\SymmetricJwtAlgorithm;
use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims;

$now = time();
Expand All @@ -32,10 +32,10 @@ Symmetric JWT
'scope' => 'admin',
];

$token = (new SymmetricJwt(SymmetricJwtAlgorithm::HS512))->encode($claims, 'super-secret-key');
$token = SymmetricJwt::forProfile(SecurityProfile::MODERN)->encode($claims, 'super-secret-key');

$jwt = new SymmetricJwt(
SymmetricJwtAlgorithm::HS512,
$jwt = SymmetricJwt::forProfile(
SecurityProfile::MODERN,
new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-1'),
);

Expand All @@ -54,26 +54,27 @@ JWT Key Rings
.. code-block:: php

use Infocyph\Epicrypt\Security\KeyRing;
use Infocyph\Epicrypt\Token\Jwt\Enum\SymmetricJwtAlgorithm;
use Infocyph\Epicrypt\Security\Policy\SecurityProfile;
use Infocyph\Epicrypt\Token\Jwt\SymmetricJwt;
use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims;

$jwt = new SymmetricJwt(
SymmetricJwtAlgorithm::HS512,
$jwt = SymmetricJwt::forProfile(
SecurityProfile::MODERN,
new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-1'),
);

$ring = new KeyRing(['legacy' => 'legacy-secret', 'active' => 'active-secret'], 'active');
$claims = $jwt->decodeWithAnyKey($token, $ring);
$isValid = $jwt->verifyWithAnyKey($token, $ring);
$result = $jwt->verifyWithAnyKeyResult($token, $ring);

Asymmetric JWT
--------------

.. code-block:: php

use Infocyph\Epicrypt\Security\Policy\SecurityProfile;
use Infocyph\Epicrypt\Token\Jwt\AsymmetricJwt;
use Infocyph\Epicrypt\Token\Jwt\Enum\AsymmetricJwtAlgorithm;
use Infocyph\Epicrypt\Token\Jwt\Validation\RegisteredClaims;

$resource = openssl_pkey_new([
Expand All @@ -94,10 +95,9 @@ Asymmetric JWT
'exp' => $now + 600,
];

$token = (new AsymmetricJwt(null, AsymmetricJwtAlgorithm::RS512))->encode($claims, $privateKey);
$jwt = new AsymmetricJwt(
null,
AsymmetricJwtAlgorithm::RS512,
$token = AsymmetricJwt::forProfile(SecurityProfile::MODERN)->encode($claims, $privateKey);
$jwt = AsymmetricJwt::forProfile(
SecurityProfile::MODERN,
new RegisteredClaims('issuer-service', 'audience-service', 'subject-service', 'token-1'),
);
$isValid = $jwt->verify($token, $publicKey);
Expand Down Expand Up @@ -132,6 +132,7 @@ Signed Payload Key Rings
$ring = new KeyRing(['legacy' => 'legacy-secret', 'active' => 'active-secret'], 'active');
$claims = $payload->decodeWithAnyKey($token, $ring);
$isValid = $payload->verifyWithAnyKey($token, $ring);
$result = $payload->verifyWithAnyKeyResult($token, $ring);

Opaque Token
------------
Expand Down
Loading
Loading