v1.2.0
Changelog
All notable changes to crumbls/sealcraft are tracked here.
Format loosely follows Keep a Changelog;
this package is pre-1.0 so breaking changes can land without a major
bump until the 1.0 release.
[Unreleased]
Fixed
Model::replicate()on per-row models no longer shares a DEK with the original. Previously,replicate()copied thesealcraft_keycolumn, which meant the clone shared the original's DEK — shredding one would also destroy the other's data. The trait now hooks thereplicatingevent, decrypts the original's encrypted attributes on the replica, nulls the row-key socreatingmints a fresh UUID, and lets the cast re-encrypt under the new DEK on save.- Cloud KEK provider error classification is now wired up. The
Vault / GCP / Azure providers now call
->throw()on their HTTP chains, soRequestExceptionactually propagates and the provider'sisAuthError()branch can classify 400s asDecryptionFailedException(context mismatch / AAD mismatch) while 403/5xx becomeKekUnavailableException. Previously the auth-error branch was unreachable — every non-2xx response fell through to "response missing plaintext" via body parsing. HasEncryptedAttributesresolvers no longer return""when config is explicitlynullor"". The(string) config(...)cast yielded an empty string (bypassing the intended default), which surfaced as'' !== 'per_group'logic bugs in downstream code. All three resolvers (resolveSealcraftStrategy,resolveSealcraftContextType,resolveSealcraftContextColumn) now fall through empty strings to the intended default.sealcraft:install --forcenow forwards--forcetomigrateso production-install workflows can bypass the migration confirmation prompt without a separate step.
Added
- Unified
$sealcraftarray for model context configuration. Replaces the four separate$sealcraftStrategy,$sealcraftContextType,$sealcraftContextColumn, and$sealcraftRowKeyColumnproperties with one array that reads like$castsor$fillable:
The legacy individual properties still work — no migration required.protected array $sealcraft = [ 'strategy' => 'per_row', // 'per_group' (default) | 'per_row' 'type' => 'patient', // context type 'column' => 'patient_id', // per_group: id column; per_row: row-key column ]; - Per-column context override via cast parameters.
EncryptedandEncryptedJsonnow accepttype=X,column=Ypairs in the cast string to route one attribute to a different encryption context than the rest of the model:
Onlyprotected $casts = [ 'ssn' => Encrypted::class, 'work_notes' => Encrypted::class . ':type=employer,column=employer_id', ];type=andcolumn=together are supported; passing one without the other raisesSealcraftExceptionat construction time. sealcraft:doctorend-to-end diagnostic that combines config validation, a provider+cipher round-trip, and the model inventory scan in one command. Exits non-zero if any check fails — suitable for deploy-gate CI. Supports--skip-roundtripand--skip-modelsfor environments that can't run every step.sealcraft:modelsscans the app for models usingHasEncryptedAttributesand prints a table with strategy, context, encrypted columns, and active DEK count per model. Supports--path=<dir>to scope and--jsonfor machine-readable output.sealcraft:installis now idempotent at the filesystem layer. Detects existingconfig/sealcraft.phpand existing*_create_sealcraft_data_keys_table.phpmigration files and skips re-publishing to prevent duplicate timestamped migration files.--forceoverrides the skip.- Bounded DEK cache with LRU eviction.
DekCachenow caps atsealcraft.dek_cache.max_entries(default1024) and evicts the least-recently-used entry when full. Prevents unbounded plaintext-DEK retention in long-running workers (Horizon / Octane) that touch many tenants over time. SetSEALCRAFT_DEK_CACHE_MAX_ENTRIES=0to disable the cap. sealcraft:installone-shot onboarding command. Publishes config, publishes the migration, and runsmigrate— idempotent and safe to re-run. Replaces the three-stepvendor:publish/migratedance.sealcraft:verifyend-to-end smoke test. Round-trips a synthetic DEK through the configured provider and cipher, then shreds the context. Exits non-zero with an actionable message on failure.- Fail-fast config validation at boot. A new
ConfigValidatorservice validates the entiresealcraft.*block duringSealcraftServiceProvider::boot()whensealcraft.validate_on_boot=true(the default). Missing env vars, typo'd provider names, and out-of-range values now fail at deploy time with messages that name the exact env var to set. Disable per app withSEALCRAFT_VALIDATE_ON_BOOT=falsewhen testing bad config. .env.exampleships in the package root with every env var the config honors, grouped by provider.- Upgraded error messages across
ProviderRegistry,CipherRegistry, and theEncryptedcast. Unknown-provider errors now list the valid provider names; unknown-cipher errors list the valid cipher names; the cast's "no recognizable cipher ID prefix" message now points at the legacy-plaintext migration path from the README.
Changed
- BREAKING:
sealcraft:rotate-dekcontext arguments are now positional instead of options, matchingsealcraft:generate-dekandsealcraft:shred.- Before:
sealcraft:rotate-dek "App\\Models\\Patient" --context-type=patient --context-id=42 - After:
sealcraft:rotate-dek "App\\Models\\Patient" patient 42 - For
Artisan::callcallers, rename keys--context-type/--context-idtocontext_type/context_id.
- Before:
- BREAKING:
sealcraft.providers.azure_kvrenamed tosealcraft.providers.azure_key_vault. The config key now matches the driver name for consistency withaws_kms,gcp_kms, andvault_transit.- Env: change
SEALCRAFT_PROVIDER=azure_kvtoSEALCRAFT_PROVIDER=azure_key_vault. - Code: change any
config(['sealcraft.providers.azure_kv.*' => ...])bindings toconfig(['sealcraft.providers.azure_key_vault.*' => ...]). - One-liner migration:
sed -i '' 's/azure_kv/azure_key_vault/g' .env config/sealcraft.php app/Providers/*.php
- Env: change
[0.1.4]
Fixed
CipherRegistry::peekId()no longer returns false positives for non-ciphertext values whose first 8 characters contain a colon. Previously, any<word>:<anything>payload — data URIs (data:image/png;base64,...), URLs (http://...,mailto:...), JSON-wrapped strings ("{"foo":"bar"}") — was reported as ciphertext and the prefix returned as a "cipher ID." The method now validates the full sealcraft envelope shape (<id>:v<n>:<b64>:<b64>[:<b64>...]) AND that the prefix matches a registered cipher driver. This unblocks legacy-plaintext-backfill commands that usedpeekId !== nullas the "already encrypted, skip" gate — they will now correctly identify colon-prefixed plaintext as plaintext and re-encrypt it. It also replaces the opaquecipherById('data')failure deep in theEncryptedcast with the clearer "no recognizable cipher ID prefix"DecryptionFailedExceptionfrom the cast itself.
Changed
CipherRegistry::peekId()is now an instance method. The new validation requires access to the registry's cipher index, which is not available statically. Internal callers (Casts\Encrypted,Casts\EncryptedJson) have been updated to use the instance method via the registry they already resolve from the container. Consumers who call the method statically must either resolve the registry (app(CipherRegistry::class)->peekId(...)) or switch to the deprecatedpeekIdUnsafe()shim documented below.
Deprecated
CipherRegistry::peekIdUnsafe()(static) preserves the legacy prefix-only behavior for one release as a transitional escape hatch. Scheduled for removal in 0.2.0. Do not introduce new call sites; this method has the same false-positive bug that the newpeekId()fixes.
Migration notes
- No database or ciphertext format changes. Existing ciphertext is unchanged and remains readable.
- If you extend
EncryptedorEncryptedJsonand overrideget()/ any tree-walking helper, replaceCipherRegistry::peekId($value)with$ciphers->peekId($value)where$ciphersis your resolvedCipherRegistryinstance. - Consumers running legacy-plaintext detection should re-run their
reencrypt command after upgrading; rows that were silently skipped
on v0.1.3 (because their plaintext happened to contain a
:in the first 8 chars) will now be detected and re-encrypted.
[0.1.3]
Fixed
HasEncryptedAttributes::sealcraftContext()no longer silently mints a throwaway row-key UUID on an already-persisted row. Previously, when a per-row model was loaded with an emptysealcraft_key(or the configured row-key column), every read minted a fresh UUID into in-memory attributes and returned it as the encryption context. The UUID was never persisted, so each subsequent read produced another UUID, andKeyManager::getOrCreateDek()inserted a freshsealcraft_data_keysrow every time. The original ciphertext was bound to a different (also discarded) context, so decryption always failed — while the orphan-DEK table grew unbounded.sealcraftContext()now throwsInvalidContextExceptionwhen the row exists and the row-key column is empty, pointing the operator at the new backfill command. Lazy mint behavior is preserved on unsaved models so the existing fill-then-save flow still works.
Added
creatingevent hook onHasEncryptedAttributesmints the per-row row-key before INSERT, so newly-created rows always carry a row-key even when no encrypted attribute is touched during fill (covers theModel::create([...])→ encrypt-later pattern).sealcraft:backfill-row-keys {model} [--chunk=500] [--dry-run]command — backfills the per-row row-key column with fresh UUIDs on rows where it isNULLor empty. Bypasses model events and casts so it is safe to run on tables that already contain ciphertext under a legacy/missing context. Idempotent.
[0.1.2]
Fixed
KeyManager::getActiveDataKey()no longer queries the database on every encrypted column read/write.DekCachenow stores theDataKeymodel alongside the plaintext DEK, sharing one cache and one invalidation path.getActiveDataKey()checks the cache first;getOrCreateDek(),createDek(), andunwrapInto()populate both slots on first access. Existingforget()andflush()clear both stores, so Octane/queue lifecycle is unchanged.
[0.1.1]
Fixed
- Stack overflow during
Model::fill()with encrypted attributes. BothEncryptedandEncryptedJsoncasts now cache the resolvedEncryptionContextper model instance via a staticWeakMap. When a model has N encrypted attributes,sealcraftContext()is called once instead of N times, eliminating redundant relationship loads and deep autoload chains that could exhaustzend.max_allowed_stack_size. Cache entries are automatically cleaned up on garbage collection. A publicforgetContext(Model)static method is available on both cast classes for explicit invalidation. HasEncryptedAttributes::handleSealcraftContextChange()now clears both cast context caches when the context column is dirty, preventing stale context from surviving a re-encryption pass.HasEncryptedAttributes::sealcraftEncryptedAttributes()now detectsEncryptedJsoncolumns in addition toEncryptedcolumns, so per-group models with JSON-encrypted attributes are included in the auto re-encryption logic during context column changes.
[0.1.0]
Added
EncryptedJsoncast — encrypts every leaf scalar of a JSON structure while preserving keys and nesting, so admin tools and analytics can still see shape without decrypting. Mirrors the scalarEncryptedcast on context resolution, null handling, cipher-ID dispatch, AAD binding, andDecryptionFailedevent emission. Non-string scalars, empty strings, and nulls pass through on write. String leaves lacking a cipher prefix pass through on read (supports mixed-content columns); prefix-bearing leaves that fail authentication raiseDecryptionFailedExceptionrather than silently returning tampered data. Fixtures + regression suite intests/Feature/Casts/EncryptedJsonTest.php. Limitation: auto re-encrypt on context column change currently only walks scalarEncryptedcolumns; per-group models withEncryptedJsoncolumns must migrate context via an explicit maintenance path.ConfigKekProvider— reads KEK bytes from config (pipeline-driven env workflows). Multi-version support for non-destructive rotation. Registered as driverconfiginProviderRegistry.
Fixed
Encryptedcast now persists attributes whose value was mutated during context resolution, not just keys that were newly added. This closes a bug wheresealcraft_keygenerated lazily on an already-persisted row (loaded withsealcraft_key = NULL) was never written back to the DB, so the next read generated a new UUID, created a new DEK, and failed to decrypt the prior ciphertext withAES-GCM authentication failed. Regression test intests/Feature/Casts/EncryptedTest.php.
Package foundation
- Laravel 11 / 12 / 13 compatibility. PHP 8.2+. Pest 3 test suite, Laravel Pint, Rector, PHPStan configured.
- Autodiscovered
SealcraftServiceProviderwith publishable config and migrations (sealcraft-config,sealcraft-migrationstags). sealcraft_data_keystable: polymorphiccontext_type+context_id, versioned provider metadata,retired_at+shredded_atlifecycle columns.
Contracts + value objects
- Capability-based
KekProvidercontract hierarchy:GeneratesDataKeys,SupportsNativeAad,SupportsKeyVersioning. Ciphercontract with 3-char cipher ID dispatch (ag1,xc1).EncryptionContextvalue object +ContextSerializerwith locked canonical rules (NFC normalization, byte-sort keys, scalar coercion, escape rules, 4096-byte cap). AWS / GCP / Vault Transit / synthetic HMAC adapters on the value object itself.WrappedDekwith versionedsc1:storage format for forward compatibility.
KEK providers
AwsKmsKekProvider—GenerateDataKey+Decryptwith nativeEncryptionContextAAD; retry + exponential backoff on throttling/internal errors.GcpCloudKmsKekProvider— REST API via Laravel HTTP client;additionalAuthenticatedDatabound to canonical context; closure- based token resolver.AzureKeyVaultKekProvider—wrapKey/unwrapKeywith synthetic AAD strategy (HMAC-SHA256 prepended to DEK, verified on unwrap) as default,cipher_onlystrategy as opt-out.VaultTransitKekProvider— Transit engine with nativecontextparameter; parsesvault:vN:version prefix from ciphertext.LocalKekProvider— file-backed, versioned rotation, refusesproductionenv without explicit opt-in.NullKekProvider— test passthrough.ConfigKekProvider— see Unreleased.
Ciphers
AesGcmCipher(default) — AES-256-GCM, 12-byte IV, 16-byte tag, emitsag1:v1:<iv>:<tag>:<ct>.XChaCha20Cipher— libsodium-backed, graceful fallback whenext-sodiumis absent.
Eloquent integration
Encryptedcast — transparent encrypt/decrypt, null passthrough, cipher-ID dispatch on read, AAD binding,DecryptionFailedevents.HasEncryptedAttributestrait —sealcraftContext()default resolver for bothper_groupandper_rowstrategies; auto- generatedsealcraft_keyUUID for per-row models; auto-reencrypt on context column change (config-gated, event-wired, cancellable).
HIPAA primitives
- Relationship-delegated context pattern (related models override
sealcraftContext()to walk to an owning user). Fixtures + regression suite intests/Feature/HipaaPatternsTest.php. - Crypto-shred:
KeyManager::shredContext(),sealcraft:shredcommand.ContextShreddedExceptiondistinct fromDecryptionFailedExceptionso apps can render "destroyed at user request" cleanly. Shred-aware reads AND writes.
Services
KeyManager— DEK lifecycle orchestration with capability-based branching and cache-first reads.DekCache— request-scoped plaintext DEK store, zero-on-flush best-effort.ProviderRegistry/CipherRegistry— driver-based resolution withextend()for custom implementations.
Events (audit surface)
DekCreated,DekUnwrapped(withcacheHitflag),DekRotated,DekShredded,DecryptionFailed,ContextReencrypting(pre, cancellable),ContextReencrypted(post).
Artisan commands
sealcraft:generate-dek {type} {id}— manual DEK provisioningsealcraft:rotate-kek [--context-type] [--context-id] [--provider] [--chunk] [--dry-run]sealcraft:rotate-dek {model} --context-type --context-id [--chunk] [--dry-run]sealcraft:migrate-provider --from --to [...scope]sealcraft:reencrypt-context {model} {id} {new_value} [--column]sealcraft:shred {type} {id} [--force]sealcraft:audit [--provider] [--context-type] [--roundtrip]
Hardening
- Per-context unwrap rate-limit guard (
rate_limit.unwrap_per_minuteconfig; 0 disables). Cache hits don't consume slots. hash_equalsused for all MAC comparisons.- Request-terminating
DekCache::flush()overwrites plaintext bytes with nulls (best-effort).
Docs
- Comprehensive
README.mdwith quick starts for every provider, per-group + per-row + delegated context patterns, rotation playbook, threat model, HIPAA/PCI compliance notes. SECURITY.mdwith disclosure policy.
Known limitations
- Searchable encryption (
WHERE encrypted_col = ?) intentionally out of scope for v1; planned for v2 via CipherSweet-style blind indexing. - Level 3 multi-provider (dual-wrap redundancy) deferred to v1.1.
sealcraft:rotate-dekassumes no concurrent writes during execution (run in a maintenance window).- Cloud provider token resolvers are the app's responsibility (no built-in Managed Identity / ADC helpers yet).
Test coverage at release
- 164 tests, 332 assertions. Covers cipher round-trips, provider capabilities, key management lifecycle, Eloquent integration, HIPAA delegation + shred patterns, rate limiting, and every artisan command.