laravel-kms-encryption maintained by asdrubalp9
asdrubalp9/laravel-kms-encryption
Cifra columnas de Eloquent en reposo con una llave de datos (DEK) por tenant. Google Cloud KMS envuelve cada DEK.
Estado: versión 0.1.0 sin publicar.
Qué hace
- Cifra cada columna con AES-256-GCM. Cada valor guardado es un sobre autocontenido:
<prefijo>:<tenant>:<versión>:<nonce>:<texto cifrado>. - Usa una DEK por tenant. Cloud KMS guarda la DEK envuelta en una tabla. La DEK en claro vive solo en una caché en memoria.
- Descifra sin tenant resuelto, porque el sobre lleva su tenant. Los workers de cola funcionan sin contexto.
- Rota la DEK por tenant, reescribe lo pendiente y purga las versiones viejas con un plazo de guarda.
- Cifra lo que ya existe en la base con
kms:encrypt-existing. - Amortigua una caída del KMS con una caché de respaldo y emite el evento
KmsDegraded.
El proyecto no cifra el payload de las colas (jobs, failed_jobs). Solo cifra columnas.
Requisitos
- PHP 8.2 o superior, con
ext-openssl. - Laravel 11 o 12.
- PostgreSQL en producción. SQLite en las pruebas del paquete. No se soporta MySQL.
- En producción, APCu (
ext-apcu) o Octane, para la caché de la DEK.
Instalación
composer require asdrubalp9/laravel-kms-encryption
php artisan vendor:publish --tag=kms-encryption-config
php artisan vendor:publish --tag=kms-encryption-migrations
php artisan migrate
Antes de migrar, define tenant_model en config/kms-encryption.php (o pon foreign_key en false). La migración lee esa clase para declarar la FK.
Configuración
| Clave | Variable de entorno | Por defecto |
|---|---|---|
driver |
KMS_ENCRYPTION_DRIVER |
fake |
gcp.key_name |
KMS_ENCRYPTION_GCP_KEY_NAME |
null |
gcp.key_file |
KMS_ENCRYPTION_GCP_KEY_FILE |
null |
gcp.timeout |
KMS_ENCRYPTION_GCP_TIMEOUT |
5 |
format_prefix |
KMS_ENCRYPTION_FORMAT_PREFIX |
kms1 |
key_type |
KMS_ENCRYPTION_KEY_TYPE |
int |
tenant_model |
null |
|
tenant_column |
tenant_id |
|
tenant_route_key |
null (usa la PK del tenant) |
|
table |
kms_data_keys |
|
foreign_key |
true |
|
cache.store |
KMS_ENCRYPTION_CACHE_STORE |
array |
cache.ttl |
KMS_ENCRYPTION_CACHE_TTL |
300 |
cache.fallback_ttl |
KMS_ENCRYPTION_CACHE_FALLBACK_TTL |
86400 |
cache.allowed_stores |
['apc', 'array', 'octane'] |
|
degraded_notice_window |
900 |
|
purge_guard_days |
KMS_ENCRYPTION_PURGE_GUARD_DAYS |
90 |
legacy_laravel_encrypted |
true |
|
columns |
[] |
Producción con Google Cloud KMS:
KMS_ENCRYPTION_DRIVER=gcp
KMS_ENCRYPTION_GCP_KEY_NAME=projects/mi-proyecto/locations/global/keyRings/app/cryptoKeys/datos
KMS_ENCRYPTION_GCP_KEY_FILE=/etc/app/kms-cuenta-de-servicio.json
KMS_ENCRYPTION_CACHE_STORE=apc
KMS_ENCRYPTION_KEY_TYPE=int
La cuenta de servicio necesita el rol roles/cloudkms.cryptoKeyEncrypterDecrypter.
Tipo del id del tenant
key_type vale int, uuid o string. Define el tipo de la columna del tenant en la tabla de llaves y valida el id antes de cifrar.
int: entero positivo sin ceros a la izquierda.uuid: UUID. El paquete lo escribe en minúsculas.string:[A-Za-z0-9-], de 1 a 64 caracteres.
Los tres tipos rechazan :, % y _. El sobre se parte por : y la rotación filtra con LIKE.
Caché de la DEK
cache.store solo admite los almacenes de cache.allowed_stores. Todos viven en memoria. El paquete se niega a arrancar con otro almacén, y el resolutor lo vuelve a comprobar en cada acceso por si la config cambia en ejecución. Si amplías la lista, asumes que ese almacén no persiste fuera de memoria.
El almacén apc necesita apc.enable_cli=1 para que los workers y los comandos de Artisan lo vean. Sin esa opción, APCu no guarda nada en CLI y cada lectura llama al KMS.
apc.enabled=1
apc.enable_cli=1
Con array, la caché dura lo que dura el proceso. Sirve para local y pruebas.
Uso
1. Modelo
use Asdrubalp9\KmsEncryption\Casts\Encrypted;
use Asdrubalp9\KmsEncryption\Concerns\HasEncryptedColumns;
class Orden extends Model
{
use HasEncryptedColumns;
protected function casts(): array
{
return [
'destinatario_telefono' => Encrypted::class,
'metadata' => Encrypted::class,
];
}
}
2. Mapa de columnas
El tipo de cada columna vive en la config, no en el cast:
'columns' => [
App\Models\Orden::class => [
'destinatario_telefono' => 'string',
'metadata' => ['type' => 'array', 'empty_to_null' => false],
],
],
Una entrada corta es el tipo. Una entrada larga es un arreglo con type y empty_to_null. empty_to_null vale true por defecto: una cadena vacía se guarda como null.
| Tipo | Se guarda como | Se lee como |
|---|---|---|
string |
texto | string |
array |
JSON | array |
date |
Y-m-d |
CarbonImmutable |
datetime |
ISO 8601 con microsegundos y desplazamiento | CarbonImmutable |
int |
entero en texto | int |
float |
JSON, sin pérdida de dígitos | float |
bool |
1 o 0 |
bool |
Una subclase de un modelo del mapa hereda su mapa.
La columna de la base debe ser de tipo text. Un sobre mide más que el valor original.
3. Quién es el tenant de una fila
encryptionTenantKey() resuelve el tenant al escribir, en este orden:
- La clave primaria, si el modelo es instancia de
tenant_model. - El atributo
tenant_columndel modelo, si no está vacío. TenantContext::current().- Si nada resuelve, lanza
MissingTenantException. El paquete nunca guarda en claro.
El modelo puede sobrescribir encryptionTenantKey(string $column): int|string. Al leer, el cast compara el tenant del sobre con el de la fila (decisiones 36 a 38 en docs/superpowers/decisiones-de-implementacion.md).
Para el paso 3, implementa Asdrubalp9\KmsEncryption\Contracts\TenantContext y regístralo en el contenedor:
$this->app->bind(TenantContext::class, MiTenantContext::class);
El valor por defecto es NullTenantContext, que no tiene tenant activo.
El tenant que cifra sus propias columnas
- Con UUID funciona al crear el tenant. El trait asigna el id antes del INSERT.
- Con id autoincremental el id no existe al crear. El paquete lanza
MissingTenantExceptiony no guarda nada. Crea el tenant sin esa columna y asígnala después:
$empresa = Empresa::create(['nombre' => 'Acme']);
$empresa->update(['token' => 'secreto']);
- En PostgreSQL con
foreign_key = true, la DEK de un tenant que cifra sus columnas nace antes que su fila. La FK esDEFERRABLE INITIALLY IMMEDIATEy el trait difiere el chequeo al COMMIT conSET CONSTRAINTSdurante el alta. Si tu modelo sobrescribeperformInsert(), llama al del trait. Si el alta ya está dentro de una transacción tuya, la FK queda diferida el resto de esa transacción.
Cuándo se cifra
El cifrado ocurre en los eventos creating y updating, no al asignar el atributo. fill() y las factorías asignan columnas antes que la columna del tenant, y en creating el modelo ya está completo.
Estas escrituras no disparan los eventos y dejan el valor en claro:
saveQuietly(),withoutEvents().Model::query()->update([...])yDB::table(...)->update([...]).insert()masivo.
El cast detecta esa fila al leerla y lanza UnencryptedColumnException. Ejecuta kms:encrypt-existing para repararla. Una escritura que nadie vuelve a leer no da alarma.
Sobres asignados a mano
Un sobre kms1:... o un payload eyJpdiI6... que asignas en memoria se trata como texto en claro y se cifra de nuevo. Solo cuenta como ya cifrado lo que viene de la base. Por eso fill($otro->getAttributes()) doble-cifra los valores. Asigna los valores descifrados. replicate() ya lo hace por ti.
Un texto que solo se parece a un sobre, como kms1:5:1:x:y asignado por un usuario, queda cifrado como texto y se lee igual que lo escribió.
Respaldo del cast encrypted de Laravel
Con legacy_laravel_encrypted = true, el cast lee los payloads del cast encrypted de Laravel (eyJpdiI6...) que vienen de la base. Al guardar el modelo, los convierte al sobre nuevo. kms:encrypt-existing convierte el resto. Apaga la opción cuando ya no queden payloads viejos.
Bitácora de actividad
El paquete no depende de spatie/laravel-activitylog. Si la usas, excluye las columnas cifradas. De lo contrario la bitácora guarda el valor en claro:
use Asdrubalp9\KmsEncryption\Support\EncryptedColumns;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()->logAll()->logExcept(EncryptedColumns::for(self::class));
}
Comandos
kms:rotate {tenant?} {--purge} {--force}
Sin argumentos rota todos los tenants de tenant_model. Con {tenant} rota uno, identificado por tenant_route_key o por la clave primaria.
Para cada tenant, dentro de TenantContext::runAs():
- En una transacción, retira la versión activa N y crea la N+1. Desde ese instante las escrituras usan la N+1.
- Reescribe las filas que todavía llevan una versión retirada. Incluye las filas con borrado lógico.
- Emite
DataKeyRotated.
El comando es reanudable. La versión vive en cada fila, y cada corrida reescribe todas las versiones retiradas. La rotación conserva updated_at.
--purge borra las DEK retiradas que ya no tienen filas pendientes y cuyo purgeable_after ya pasó. --force ignora el plazo y lo advierte. Ni con --force se purga una versión con filas pendientes.
La purga es irreversible. Borrar una DEK deja ilegibles los respaldos de la base anteriores a la rotación. El plazo (purge_guard_days, 90 por defecto) debe cubrir el tiempo que conservas los respaldos.
Rotar la llave maestra de Cloud KMS no requiere rotar las DEK. Cloud KMS descifra con las versiones anteriores de la llave.
kms:encrypt-existing {--tenant=} {--dry-run}
Cifra los valores no nulos que no tienen la forma de un sobre. Convierte también los payloads del cast encrypted de Laravel. Es idempotente y reanudable.
--dry-runcuenta y no escribe.--tenant=IDfiltra portenant_column, o por la clave primaria cuando el modelo es el tenant. Corre dentro deTenantContext::runAs(). Omite con aviso los modelos que no se pueden acotar.- Sin
--tenant, una fila sin tenant resoluble detiene el comando y queda como estaba. - Una app con RLS por tenant debe correr el comando una vez por tenant con
--tenant.
El comando guarda cada fila con save() para disparar el cifrado. Si usas una bitácora, desactívala mientras corre. El paquete no lo hace por ti.
Eventos
| Evento | Cuándo | Datos |
|---|---|---|
KmsDegraded |
El KMS cayó y una lectura usó la DEK de respaldo. Una vez por degraded_notice_window. |
tenant, versión |
DataKeyCreated |
Se creó una DEK. | tenant, versión |
DataKeyRotated |
Se rotó la DEK de un tenant. | tenant, versión, versión anterior, valores reescritos |
DataKeyPurged |
Se borró una DEK retirada. | tenant, versión, si fue forzado |
Ningún evento lleva la DEK. Conecta KmsDegraded al canal de avisos de tu app, preferiblemente con un listener ShouldQueue. Un listener síncrono que falle no interrumpe la lectura. El resolutor entrega su excepción a report().
Errores
Todas las excepciones implementan Asdrubalp9\KmsEncryption\Exceptions\KmsEncryptionException y exponen errorCode(). Ningún mensaje incluye el valor en claro ni la DEK.
| Excepción | Código | Cuándo |
|---|---|---|
KmsUnavailableException |
kms_unavailable |
El KMS falla y no hay respaldo |
MissingTenantException |
kms_missing_tenant |
No se resuelve tenant al escribir |
UnencryptedColumnException |
kms_unencrypted_column |
La base devuelve en claro una columna del mapa |
InvalidEnvelopeException |
kms_invalid_envelope |
Sobre mal formado, que no autentica o de otro tenant |
InvalidKeyException |
kms_invalid_key |
DEK de largo incorrecto, id de tenant inválido o versión inexistente |
InvalidColumnTypeException |
kms_invalid_column_type |
Tipo desconocido, columna con el cast fuera del mapa o valor que no convierte |
InvalidConfigurationException |
kms_invalid_configuration |
Fallan las guardas de arranque |
Guardas de arranque
El service provider lanza InvalidConfigurationException al arrancar si:
cache.storeno está encache.allowed_stores.driver = gcpygcp.key_fileestá vacío o no se puede leer, ogcp.key_nameestá vacío.driver = fakefuera de los entornoslocalytesting.drivertiene un valor desconocido.format_prefixokey_typetienen un valor inválido.
Modos de fallo
| Qué tiene que ser cierto | Qué pasa si no lo es |
|---|---|
La cuenta de servicio tiene roles/cloudkms.cryptoKeyEncrypterDecrypter |
Toda lectura y escritura cifrada sin caché falla con KmsUnavailableException |
APCu está instalado en producción, con apc.enable_cli=1 para workers |
El arranque falla con el almacén inválido en el mensaje, o cada lectura llama al KMS |
El modelo trae tenant_column o es el tenant |
MissingTenantException, nunca un guardado en claro |
| La DEK retirada sobrevive al plazo de respaldos | Un respaldo dentro del plazo queda ilegible en las columnas cifradas |
| La app excluye las columnas cifradas de la bitácora | La bitácora guarda el valor en claro |
Nadie escribe las columnas con saveQuietly() ni con el query builder |
El valor queda en claro hasta que alguien lo lee y obtiene UnencryptedColumnException |
| Borrar un tenant significa perder sus datos | Con foreign_key = true, borrar el tenant borra sus DEK y deja ilegibles sus datos cifrados, también en los respaldos |
Si el KMS cae, una lectura usa la DEK de la caché de respaldo (cache.fallback_ttl, 24 horas por defecto) y emite KmsDegraded. Sin DEK en la caché, la lectura lanza KmsUnavailableException. Una escritura de un tenant sin DEK en caché también falla.
Límites conocidos
- El AAD no incluye la fila. El sobre queda atado a tabla, columna y tenant. Copiar un valor a otra columna, tabla o tenant no autentica. Copiarlo a otra fila del mismo tenant y la misma columna sí autentica. Es la misma limitación de gimVirtual, y hace falta para abrir sus sobres.
- Sin búsqueda. No puedes filtrar con
WHERE,ILIKEni índices sobre una columna cifrada. El formato no impide agregar un índice ciego en el futuro. - Un select parcial sin la columna del tenant abre el sobre con su propio tenant, sin compararlo con la fila.
- Fuera de alcance de 0.1.0: otros proveedores de KMS, reenvolver las DEK al rotar la llave maestra, cifrar el payload de las colas.
Pruebas en tu aplicación
El trait AssertsEncryptedColumns comprueba que el mapa y los modelos digan lo mismo:
use Asdrubalp9\KmsEncryption\Testing\AssertsEncryptedColumns;
class CifradoTest extends TestCase
{
use AssertsEncryptedColumns;
public function test_el_mapa_de_columnas_es_coherente(): void
{
$this->assertEncryptedColumnsAreCoherent();
}
}
Comprueba cuatro cosas. Cada modelo del mapa existe y usa HasEncryptedColumns. Cada columna del mapa tiene el cast Encrypted. Cada atributo con ese cast está en el mapa. Cada tipo del mapa es uno de los siete. Pasa [Modelo::class] para revisar también modelos que no están en el mapa.
Con driver = fake el paquete usa FakeKms, una envoltura reversible que no sale a la red. FakeKms::failNextCall() simula una caída.
Adopción desde gimVirtual
El paquete abre los sobres de gimVirtual sin recifrar. Configura:
'format_prefix' => 'gv1',
'key_type' => 'uuid',
'table' => 'organization_data_keys',
'tenant_column' => 'organization_id',
La tabla de llaves de gimVirtual usa estado con los valores activa y retirada. El paquete usa status con active y retired. La adopción necesita una migración que renombre la columna y los valores. Esa migración pertenece al ciclo de adopción de gimVirtual.
Desarrollo
composer install
vendor/bin/phpunit
vendor/bin/pint --test
Para correr la suite contra PostgreSQL, define KMS_TEST_DB_DRIVER=pgsql, KMS_TEST_DB_HOST, KMS_TEST_DB_DATABASE, KMS_TEST_DB_USERNAME y KMS_TEST_DB_PASSWORD (y KMS_TEST_DB_PORT si no es 5432). Cada prueba parte de una base vacía: usa una base desechable. El CI corre ambos motores.
tests/Integration/KmsRealTest.php llama a Google Cloud KMS de verdad. Se salta si faltan KMS_ENCRYPTION_GCP_KEY_FILE y KMS_ENCRYPTION_GCP_KEY_NAME.
Las decisiones de implementación están en docs/superpowers/decisiones-de-implementacion.md. El diseño está en docs/superpowers/specs/.
Licencia
MIT.