Work through the 2026-08-03 review findings - #3
Merged
Conversation
Version 3 writes a marked cipher text format that earlier versions cannot read. When several deployments share the same data, one of them upgrading first and writing a single value hands the others data they cannot decrypt. README said nothing about it. Add an "Upgrading to spaze/encryption 3.0" section spelling out the order, and a CHANGELOG so an upstream format change lands somewhere a reader of this repository will find it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three claims were stronger than the implementation: The intro and the exception table promised that a misconfigured group fails while the container is compiled. Only the shape of the configuration and type errors do; key material is validated when the encryption service is created, because resolving %env() at compile time would write the keys into var/cache/. Split the exception into the two moments it is really thrown at. The key rotation section said the key id travels unauthenticated. Since upstream 3.0 that holds only for anonymous_asymmetric and for values without a marker; the new symmetric and asymmetric format binds the key id and the marker into what decryption verifies. Split the claim by format and type. The keygen prefix section read as if a prefix containing _ were invalid. Upstream accepts it, verified at runtime; the ban is the command's own rule so its output stays readable in one direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PHPDoc promised a DecryptionException whenever the value is not cipher text of this group. Upstream needsReEncrypt() only parses the envelope, checks the marker and compares the key id with the active one; it verifies neither that the key id is configured nor that the payload authenticates. A structurally valid value with an unknown key id therefore reports true, which migration code could read as proof that the value belongs to the group. Describe what the method really does and pin the behaviour down with a test in all three implementations, since the contract lives on the shared Encryptor interface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An anonymous_asymmetric key carrying secret_key: '' was treated as a key without a secret key at all, so the whole group was quietly registered as WriteOnlyAnonymousAsymmetricEncryptor. A config generator emitting an empty value could therefore take away the ability to decrypt, and nothing would notice until something asked that group for a Decryptor -- possibly in a different deployment than the one that wrote the data. The config node defaults to null, so "not given" and "given but empty" are distinguishable. Reject the second. BREAKING CHANGE: an anonymous_asymmetric key with an explicitly empty secret_key now throws InvalidEncryptionConfigurationException while the container is compiled. Leave secret_key out to get a write-only group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
composer.json requires php >=8.4, but every workflow pinned 8.4 alone, so nothing kept the bundle honest on 8.5. Turn both the push/PR run and the nightly one into a matrix over 8.4 and 8.5; php-actions/composer builds on the official php image and php:8.5-cli exists, so no other change is needed. Declare permissions explicitly while there. The reusable workflow already had contents: read; ci and nightly now say the same, and the assign-author workflow gets exactly the two scopes its API call needs -- it calls issues.addAssignees against a pull request -- and no contents access, since it never checks the repository out. Drop the andstor/file-existence-action steps. They produced an output nothing read, and in a repository where composer.json has to exist they branched no behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
phpunit.xml set SYMFONY_DEPRECATIONS_HELPER=disabled, which read as a deliberate opt-out but was inert: symfony/phpunit-bridge was never installed, and it could not have helped anyway. Its bootstrap.php returns early for PHPUnit >= 10, and DeprecationErrorHandler::register() is reached from nowhere else, so on PHPUnit 12 the variable means nothing whichever value it holds. Verified by running a direct deprecation through both the bridge with max[direct]=0 and the bridge wired as a SymfonyExtension: it passed either way. Use what PHPUnit 12 has instead. failOnDeprecation turns a deprecation into a failed run, ignoreIndirectDeprecations was already keeping upstream Symfony out of it, and Symfony raises deprecations through trigger_deprecation(), which wraps @trigger_error() -- so the trigger has to be named and its suppression ignored, or nothing is ever reported. Verified the other way round too: a trigger_deprecation() call in src/ now exits 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No production class references symfony/framework-bundle. The bundle builds on AbstractBundle from http-kernel, and the only mention in the repository is the test kernel. Requiring it made every installation pull in cache, finder, routing and the rest of the framework for nothing. Move it to require-dev, and declare symfony/filesystem and symfony/error-handler there as well: the tests use both directly and were getting them only transitively. Verified by installing with --no-dev in a scratch copy and reflecting every class under src/ -- all fifteen resolve without the framework present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The YAML-configured peer group only asserted that the cipher text was not the plain text, which passes for any output at all. Give the consumer an AdditionalDataDecryptor for the same group and do the round trip, plus the mismatched additional data case that is the whole point of binding it. Wrap the passing integration tests in try/finally like the failing ones already were. Without it a failed assertion left the kernel booted, its cache directory behind and Symfony's exception handler installed, which PHPUnit then reports as a risky test somewhere else entirely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment still said spaze/encryption comes from the helppc/encryption fork as a dev branch over VCS and cannot be updated automatically. Since c33f255 the opposite is true: composer.json requires the upstream stable ^3.0 from Packagist, which Dependabot updates like anything else. Leaving the note would invite someone to bring the forbidden fork dependency back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule said all cryptography goes through spaze/encryption, while GenerateEncryptionKeyCommand calls random_bytes() and the libsodium keypair functions directly. That is not a home-grown scheme and it follows the upstream documentation, but read literally the rule forbade it, so the next reader either breaks the command or breaks the rule. Say what is actually meant: no primitives, no schemes, all encryption and decryption through spaze/encryption, key generation through the stable PHP libsodium API. Upstream has no public API for it and its key role enum is @internal, which is worth writing down so nobody reaches for that instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four accuracy fixes earlier in this branch changed no behaviour, but code written against the old wording may have relied on a guarantee that was never implemented. Someone upgrading deserves to read that in one place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Works through every finding of the 2026-08-03 deep review of
v1.0.0. That review passed the codeand the test suite but failed the repository on release safety: the documentation promised stronger
behaviour than the implementation delivers, and nothing warned about the coordination the new
upstream cipher text format demands.
Each finding is its own commit, so any of them can be reverted alone.
Release safety
F1 — coordinating the upgrade to
spaze/encryption3.0. Version 3 writes a marked cipher textformat that earlier versions cannot read. When several deployments share the same data, one of them
upgrading first and writing a single value hands the others data they cannot decrypt. README said
nothing about it. New Upgrading section spelling out the order, and a
CHANGELOG.mdso the nextupstream format change lands somewhere a reader will find it.
Documentation that promised more than the code delivers
No behaviour changed by these, but code written against the old wording may have rested on a
guarantee that was never there.
F2 — when a misconfigured group actually fails. The intro and the exception table said a
misconfigured group fails while the container is compiled. Only the shape of the configuration and
type errors do; key material is validated when the encryption service is created, because resolving
%env()%at compile time would write the keys intovar/cache/. The exception is now documented atboth moments it is thrown at.
F3 —
needsReEncryption()is a structural check. The PHPDoc promised aDecryptionExceptionwhenever the value is not cipher text of this group. Upstream only parses the envelope, checks the
marker and compares the key id with the active one — it verifies neither that the key id is
configured nor that the payload authenticates, so a structurally valid value with an unknown key id
reports
true. Migration code could have read that as proof of ownership. Contract rewritten andpinned down by a test in all three implementations, since it lives on the shared
Encryptorinterface.
F6 — key id authentication, split by format. README said the key id travels unauthenticated.
Since upstream 3.0 that holds only for
anonymous_asymmetricand for values without a marker; thenew
symmetricandasymmetricformat binds the key id and the marker into what decryptionverifies. Now a table per format and type.
Prefix
_. The keygen section read as if a prefix containing_were invalid configuration.Verified at runtime that upstream accepts it — the ban is the command's own rule so its output stays
readable in one direction.
Behaviour
F4 — reject an explicitly empty⚠️ breaking. An
secret_keyanonymous_asymmetrickey carryingsecret_key: ''was treated as no secret key at all, so the whole group was quietly registeredwrite-only. A config generator emitting an empty value could therefore take away the ability to
decrypt, and nothing would notice until something asked that group for a
Decryptor— possibly in adifferent deployment than the one that wrote the data. Leave
secret_keyout to get a write-onlygroup; an empty value now throws while the container is compiled.
Tooling
F7a — PHP 8.5 in CI.
composer.jsonrequiresphp >=8.4but every workflow pinned 8.4 alone.Both the push/PR run and the nightly one are now a matrix over 8.4 and 8.5.
F7b — a deprecation policy that actually holds.
phpunit.xmlsetSYMFONY_DEPRECATIONS_HELPER=disabled, which read as a deliberate opt-out but was inert.symfony/phpunit-bridgewas never installed — and would not have helped: itsbootstrap.phpreturnsearly for PHPUnit >= 10 and
DeprecationErrorHandler::register()is reached from nowhere else.Verified by running a direct deprecation through the bridge with
max[direct]=0and through thebridge wired as a
SymfonyExtension: it passed either way. Replaced with what PHPUnit 12 hasnatively, and verified the other way round — a
trigger_deprecation()call insrc/now exits 1.F8 — minimal permissions.
ciandnightlydeclarecontents: read; the assign-author workflowgets exactly the two scopes its API call needs (it calls
issues.addAssigneesagainst a pullrequest) and no contents access, since it never checks the repository out. No SHA pinning, by
decision.
F5 — stale Dependabot note. The comment still claimed
spaze/encryptioncomes from thehelppc/encryptionfork over VCS. Sincec33f255the opposite is true, and leaving the note wouldinvite someone to bring the forbidden fork dependency back.
F9 — dependency footprint and test hygiene. No production class references
symfony/framework-bundle; moved torequire-dev, withsymfony/filesystemandsymfony/error-handlerdeclared there explicitly instead of arriving transitively. Verified byinstalling with
--no-devin a scratch copy and reflecting every class undersrc/— all fifteenresolve without the framework. The YAML-configured peer group now does a real AAD round trip instead
of asserting the cipher text is not the plain text, the passing integration tests clean the kernel up
in
finallylike the failing ones already did, and theandstor/file-existence-actionsteps aregone — they produced an output nothing read.
F10 — the cryptography rule.
AGENTS.mdsaid all cryptography goes throughspaze/encryptionwhile the keygen command calls
random_bytes()and the libsodium keypair functions directly. Rulenow says what it means: no primitives, no schemes, all encryption and decryption through upstream,
key generation through the stable PHP libsodium API.
Verification
phpcs,phpstanlevel 10,rector:check,phpunit(84 tests / 177 assertions),composer validate --strict,composer audit,check-platform-reqsandactionlintall pass.A throwaway runtime smoke driver covered all three modes, AAD mismatch, malformed and truncated
input, wrong key and wrong recipient, key rotation, the write-only boundary, and the new F2/F3/F4
behaviour: 33 assertions, 0 failures.
Out of scope
The bundle extends
Symfony\Component\HttpKernel\Bundle\AbstractBundle, which inherits fromHttpKernel\Bundle\BundleimplementingHttpKernel\Bundle\BundleInterface—@deprecatedsinceSymfony 8.1. The replacement is
Symfony\Component\DependencyInjection\Kernel\AbstractBundle, andKernel::registerBundles()wraps such a bundle in aBundleAdapter, so it works in a normal app;migrating would let
symfony/http-kerneldrop out ofrequireentirely. Nothing emits a runtimedeprecation today, so it blocks nothing. Deliberately left for its own change.
🤖 Generated with Claude Code