Skip to content

Work through the 2026-08-03 review findings - #3

Merged
tomas-kulhanek merged 11 commits into
mainfrom
review-2026-08-03
Aug 3, 2026
Merged

Work through the 2026-08-03 review findings#3
tomas-kulhanek merged 11 commits into
mainfrom
review-2026-08-03

Conversation

@tomas-kulhanek

Copy link
Copy Markdown
Contributor

Works through every finding of the 2026-08-03 deep review of v1.0.0. That review passed the code
and 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/encryption 3.0. 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. New Upgrading section spelling out the order, and a CHANGELOG.md so the next
upstream 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 into var/cache/. The exception is now documented at
both moments it is thrown at.

F3 — needsReEncryption() is a structural check. The PHPDoc promised a DecryptionException
whenever 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 and
pinned down by a test in all three implementations, since it lives on the shared Encryptor
interface.

F6 — key id authentication, split by format. README 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. 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 secret_key ⚠️ breaking. An anonymous_asymmetric key carrying
secret_key: '' was treated as no secret key at all, so the whole group was quietly registered
write-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 a
different deployment than the one that wrote the data. Leave secret_key out to get a write-only
group; an empty value now throws while the container is compiled.

Tooling

F7a — PHP 8.5 in CI. composer.json requires php >=8.4 but 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.xml set
SYMFONY_DEPRECATIONS_HELPER=disabled, which read as a deliberate opt-out but was inert.
symfony/phpunit-bridge was never installed — and would not have helped: its bootstrap.php returns
early for PHPUnit >= 10 and DeprecationErrorHandler::register() is reached from nowhere else.
Verified by running a direct deprecation through the bridge with max[direct]=0 and through the
bridge wired as a SymfonyExtension: it passed either way. Replaced with what PHPUnit 12 has
natively, and verified the other way round — a trigger_deprecation() call in src/ now exits 1.

F8 — minimal permissions. ci and nightly declare contents: read; 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. No SHA pinning, by
decision.

F5 — stale Dependabot note. The comment still claimed spaze/encryption comes from the
helppc/encryption fork over VCS. Since c33f255 the opposite is true, and leaving the note would
invite someone to bring the forbidden fork dependency back.

F9 — dependency footprint and test hygiene. No production class references
symfony/framework-bundle; moved to require-dev, with symfony/filesystem and
symfony/error-handler declared there explicitly instead of arriving transitively. Verified by
installing with --no-dev in a scratch copy and reflecting every class under src/ — all fifteen
resolve 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 finally like the failing ones already did, and the andstor/file-existence-action steps are
gone — they produced an output nothing read.

F10 — the cryptography rule. AGENTS.md said all cryptography goes through spaze/encryption
while the keygen command calls random_bytes() and the libsodium keypair functions directly. Rule
now says what it means: no primitives, no schemes, all encryption and decryption through upstream,
key generation through the stable PHP libsodium API.

Verification

phpcs, phpstan level 10, rector:check, phpunit (84 tests / 177 assertions),
composer validate --strict, composer audit, check-platform-reqs and actionlint all 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 from
HttpKernel\Bundle\Bundle implementing HttpKernel\Bundle\BundleInterface@deprecated since
Symfony 8.1. The replacement is Symfony\Component\DependencyInjection\Kernel\AbstractBundle, and
Kernel::registerBundles() wraps such a bundle in a BundleAdapter, so it works in a normal app;
migrating would let symfony/http-kernel drop out of require entirely. Nothing emits a runtime
deprecation today, so it blocks nothing. Deliberately left for its own change.

🤖 Generated with Claude Code

tomas-kulhanek and others added 11 commits August 3, 2026 11:07
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>
@tomas-kulhanek
tomas-kulhanek merged commit 811d5db into main Aug 3, 2026
5 checks passed
@tomas-kulhanek
tomas-kulhanek deleted the review-2026-08-03 branch August 3, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant