Search by

northstar / cipher-laravel

dlogvin

Northstar Cipher for Laravel: transparent, batched field-level encryption and blind-index search for Eloquent.

v1.1.0 2026-10-18 21:35 UTC

This package is auto-updated.

Last update: 2026-10-18 21:44:47 UTC


README

Encrypt Eloquent attributes with Northstar Cipher: one cast, no cryptography to learn. Values are encrypted before they reach your database, decrypted lazily in batches when you read them, and every failure is explicit, so nothing is ever silently stored or shown in plaintext.

Built on northstar/cipher-php. Requires PHP 8.2+ (with json, openssl, sodium) and Laravel 11, 12 or 13.

Provisioning: describe your app's keys, one credential per role, blind indexes and policy rules in a cipher-policy.json and apply it with nsc provision, which writes the credential secrets (NORTHSTAR_CIPHER_CREDENTIAL, or one variable per role), endpoint and trust anchor into your env file.

Five-minute setup

composer require northstar/cipher-laravel:^1.1
php artisan cipher:install

Paste the values from the console (Integrations → Laravel) into .env:

NORTHSTAR_CIPHER_ENDPOINT=https://api.northstar.mx
NORTHSTAR_CIPHER_CREDENTIAL=nsc_dev_…                 # shown once when created
NORTHSTAR_CIPHER_KEY=default
# Production only: pin the attestation verifier
# NORTHSTAR_CIPHER_TRUST_ANCHOR=<kid>:<key>

Migration — encrypted columns are text:

Schema::create('todos', function (Blueprint $table) {
    $table->id();
    $table->encrypted('title');                    // text + CHECK constraint (PostgreSQL, MySQL ≥ 8.0.16)
    $table->encrypted('description')->nullable();
    $table->timestamps();
});

Model:

use Northstar\Cipher\Laravel\NorthstarEncrypted;

class Todo extends Model
{
    protected $casts = [
        'title' => NorthstarEncrypted::class,
        'description' => NorthstarEncrypted::class,
    ];
}

Check everything — connection, attestation, permissions, column types:

php artisan cipher:doctor

That's it. Todo::create([...]), $todo->update([...]), Todo::all(), $todo->toArray() and JSON responses work as usual.

What happens

  • Writes: when a model is saved, all of its encrypted attributes are encrypted in one request, bound to the table, column and primary key. On PostgreSQL, new rows reserve their id from the sequence first, so the INSERT already contains ciphertext. On MySQL/SQLite auto-increment tables the row is inserted with a non-decryptable placeholder and updated with ciphertext immediately after; plaintext is never written in either case.
  • Reads: nothing is decrypted at query time. The first time you read an encrypted attribute, that attribute is decrypted for every model loaded with it, in one batch request (≤ 1 000 values per request). Listing 100 todos with two encrypted fields makes two requests, not 200. Set NORTHSTAR_CIPHER_BATCH_SCOPE=model to decrypt all encrypted attributes of the loaded models in a single request.
  • Binding: a ciphertext only decrypts for its own record and field. Copying it to another row (or replicate()) fails; assign the plaintext again.

Batch decryption needs the credential's Batch decrypt permission (cipher.decrypt.batch). Without it the package still works, one value per request, and cipher:doctor tells you.

Encrypting files and large values

Uploads, scans and exports are too large for an encrypted column (1 MiB per value). Encrypt them as objects (protocol §13): the bytes are sealed in your application with a random per-object key, and Cipher encrypts only that 32-byte key, bound to the record. The file never reaches Cipher.

use Illuminate\Support\Facades\Storage;
use Northstar\Cipher\Laravel\Facades\Cipher;

// migration: $table->encrypted('file_key');  text + the nsc: CHECK, no cast (it is already ciphertext)
$ctx = ['collection' => 'documents', 'field' => 'file_key', 'record_id' => (string) $document->id];

$enc = Cipher::encryptObject($request->file('scan')->get(), $ctx);
Storage::disk('s3')->put("documents/{$document->id}", $enc->object);
$document->update(['file_key' => $enc->wrappedKey]);

$bytes = Cipher::withPurpose('support_case', fn () => Cipher::decryptObject(
    Storage::disk('s3')->get("documents/{$document->id}"), $document->file_key, $ctx,
));

A stolen bucket holds only NSOB ciphertext; opening an object needs its wrapped key from your database and a decrypt through Cipher (scope, policy, purpose, audit). A wrong record, another document's key, or a truncated or altered file raises CipherException INVALID_CIPHERTEXT. cipher:rewrap does not know about these columns; after a key rotation pass the stored wrapped keys to Cipher::client()->rewrapMany(...) (the files stay as they are). Deleting the file and its wrapped key deletes the document; destroying the key version makes every file whose key it wraps unreadable.

Exact-match search (blind indexes)

protected $casts = [
    'email' => NorthstarEncrypted::class.':searchable,email',     // normalization: email
];
// migration
$table->encrypted('email');
$table->blindIndex('email');      // email_bidx varchar(64) + index

User::cipherWhere('email', $request->email)->first();
User::cipherWhereIn('email', $emails)->get();
User::where('active', true)->orCipherWhere('email', $email)->get();

Normalizations: exact, case_insensitive, email, alphanumeric (document numbers), digits (phone numbers). The credential needs cipher.index and cipher.search. In production environments, enable the field in the console (Search indexes) first.

Search limitations. Only equality works. where('email', 'like', …), ranges, orderBy and aggregates on encrypted attributes cannot work, because the database only holds ciphertext. Blind indexes reveal which rows share a value (equality and frequency), so index high-entropy fields (emails, document numbers) — not booleans, statuses or countries.

Full-text search (Northstar Search, Laravel Scout)

Typo-tolerant, prefix, phrase, filtered, sorted and faceted search over encrypted attributes, computed inside the attested enclave over encrypted indexes.

composer require laravel/scout
echo "SCOUT_DRIVER=northstar" >> .env
use Northstar\Cipher\Laravel\Search\NorthstarSearchable;

class Customer extends Model
{
    use NorthstarSearchable;   // includes Laravel\Scout\Searchable

    protected $casts = ['name' => NorthstarEncrypted::class, 'email' => NorthstarEncrypted::class];

    // Optional — inferred from casts/fillable otherwise (passwords and tokens are never indexed).
    public function searchableSchema(): array
    {
        return ['language' => 'spanish', 'fields' => [
            'name' => ['type' => 'text', 'boost' => 2], 'email' => 'keyword',
            'city' => 'keyword', 'balance' => 'number', 'created_at' => 'date',
        ]];
    }
}

Customer::search('ana garsia')
    ->where('city', 'CDMX')
    ->where('balance', '>=', 1000)
    ->whereIn('tier', ['gold', 'platinum'])
    ->whereNotIn('segment', ['gobierno'])
    ->options(['highlight' => ['name'], 'facets' => ['city']])
    ->orderBy('balance', 'desc')
    ->paginate(20);

$customer->scoutMetadata()['_highlights']['name'];   // "Ana <mark>García</mark>" (HTML-escaped)
  • Saving, updating and deleting models keeps the index in sync (Scout's model events, sync or queued). Encrypted attributes are sent as the ciphertext already in the row; the enclave decrypts them to index them — no extra decrypt round trip.
  • Writes bypassing Eloquent (raw SQL, mass update()) do not fire model events: re-sync with php artisan cipher:search:rebuild.
  • Existing rows: php artisan scout:import "App\Models\Customer".
  • Turning search on in a running app: queue workers read SCOUT_DRIVER only when they start. With SCOUT_QUEUE on, scout:import only queues the work, and a worker still running with the old driver drops those jobs without an error: the index stays empty and nothing fails. Backfill with php artisan cipher:search:rebuild "App\Models\Customer" (writes directly, prints the document count), then php artisan queue:restart so later saves are indexed. Check with php artisan cipher:search:status. Records the enclave cannot index are logged as warnings: keep LOG_LEVEL at warning or lower.
  • Schema change: php artisan cipher:search:rebuild "App\Models\Customer" builds a new version next to the live one and switches when done (zero downtime). php artisan cipher:search:status lists indexes.
  • Config (northstar-cipher.search): language, key (key protecting new indexes), wait (NORTHSTAR_SEARCH_WAIT=true makes writes searchable before they return — tests), indexes (schemas by index name).
  • Credentials need cipher.index (writes) and cipher.search (queries); select/highlight and indexing ciphertext also need cipher.decrypt.

Existing data, rotation, reindexing

php artisan cipher:migrate "App\Models\User"   # encrypt existing plaintext + backfill indexes (resumable)
php artisan cipher:rewrap                       # after rotating a key in the console
php artisan cipher:reindex                      # after rotating an index key or making a field searchable

Migrating an existing column: change it to text in a migration, add the cast, run php artisan cipher:migrate, then add the database guard in a follow-up migration with $table->cipherConstraint('notes').

Options

'ssn'   => NorthstarEncrypted::class.':key=pii',                  // a different key alias
'email' => NorthstarEncrypted::searchable('email', key: 'pii'),   // in a casts() method

Declare an access purpose for audit and policy:

use Northstar\Cipher\Laravel\Facades\Cipher;

Cipher::withPurpose('support_case', fn () => $customer->ssn);

Direct use: Cipher::encrypt($value, ['collection' => 'notes', 'field' => 'body', 'record_id' => '42']), Cipher::decrypt(...), Cipher::client()->decryptMany(...).

Failure behaviour

Situation Result
Cipher unreachable on save CipherException NETWORK_ERROR; nothing is written
Credential missing/malformed/revoked CONFIGURATION_ERROR / AUTHENTICATION_FAILED
Permission revoked PERMISSION_DENIED on the next read
Corrupted or moved ciphertext INVALID_CIPHERTEXT on read
Column contains plaintext INVALID_CIPHERTEXT (“run cipher:migrate”)
saveQuietly(), withoutEvents(), query-builder insert/update refused (exception or database CHECK); use Eloquent saves

Render them as you like, for example in bootstrap/app.php:

$exceptions->render(fn (\Northstar\Cipher\CipherException $e) => response()->view('errors.cipher', ['error' => $e], 503));

Security notes

  • Do not use Cipher for passwords — keep Hash::make().
  • Query logs and exceptions only ever contain ciphertext; pending plaintext refuses to be serialized, JSON-encoded or cast to string.
  • Access tokens and session keys are cached (encrypted with APP_KEY) so workers don't re-attest per request; they protect transport only.
  • Encrypted attributes are returned as plain strings to your code — hide them from API resources ($hidden) where they shouldn't be exposed.

Transfer between environments

cipher:transfer moves cast-managed fields to another environment, regenerates blind indexes and verifies each destination value before an atomic database update. It supports dry runs, resumable progress, verification and rollback. It requires a prepared destination and a stopped-writer maintenance window; it leaves maintenance enabled for the deployment configuration switch.

php artisan cipher:transfer --name=dev-to-production --to=/secure/cipher-production.json --dry-run
php artisan cipher:transfer --name=dev-to-production --to=/secure/cipher-production.json --maintenance

php artisan cipher:transfer --help lists the options (destination client file, explicit source configuration, resume, verification and rollback). This transfers ciphertext to new keys; cipher:rewrap only rotates versions of an existing key.

Partitions (tenant-scoped authorization)

Give a model a cipherPartition() hook to bind every encrypted attribute of a record to its tenant/organization. Independent data grants can then be scoped to partitions instead of exact record IDs.

class Invoice extends Model
{
    protected $casts = ['total' => NorthstarEncrypted::class];

    public function cipherPartition(): ?string
    {
        return $this->organization_id === null ? null : 'org_'.$this->organization_id;
    }
}
  • Compute the partition from plaintext columns only. null means "no partition" (readable only by unrestricted principals).
  • Changing the partition columns on save re-encrypts every encrypted attribute of the record under the new partition (ciphertext cannot be relabeled).
  • cipher:migrate, cipher:rewrap, cipher:reindex, cipher:transfer and Scout use the partition automatically; Scout indexes of such models are partitioned (a schema change: cipher:search:rebuild).
  • Blind-index queries (whereEncrypted) need an unrestricted partition grant; partition-scoped users search through Scout instead.

Existing rows were encrypted without a partition. Adopt the hook in a maintenance window:

php artisan down
# deploy the code with cipherPartition()
php artisan cipher:relabel --name=invoices-to-orgs --dry-run
php artisan cipher:relabel --name=invoices-to-orgs --maintenance
php artisan cipher:relabel --name=invoices-to-orgs --verify
php artisan cipher:search:rebuild "App\Models\Invoice"
php artisan up

cipher:relabel uses the cipher:transfer engine: each row is decrypted under its stored context, encrypted under its partition, blind indexes are regenerated, the result is verified, and row + journal commit together. It is resumable (same --name), skips rows already in their partition, and --rollback restores the journaled originals (then deploy the code without the hook before reopening). A model may define cipherPreviousPartition() to relabel from an older partition scheme.

Independent data authorization

When the environment requires independent authorization (every data operation also needs a short-lived grant from an identity provider independent of the app):

// config/northstar-cipher.php → 'authorization'
NORTHSTAR_CIPHER_AUTHORIZATION_EXCHANGE_URL=https://CONTROL_PLANE/api/v1/authorization/exchange
NORTHSTAR_CIPHER_AUTHORIZATION_AUTHORITY=customer-auth
NORTHSTAR_CIPHER_AUTHORIZATION_FILE=/run/cipher/app-worker.grant   # artisan + queue workers

Route::middleware(['auth', 'cipher.authorize'])->group(function () { /* ... */ });

class SendInvoice implements ShouldQueue
{
    public function middleware(): array { return [new WithCipherServiceAuthorization]; }
}
  • cipher.authorize resolves the caller's identity token with authorization.identity_token_resolver (default: bearer token; implement Authorization\IdentityTokenResolver for sessions/cookies), exchanges it once per request (lazily) and runs the request with the grant. Requests without a token pass through without a grant (missing_identity=passthrough, an enforcing environment then denies data access) or get a 401 (deny). If the exchange refuses the identity or fails, the request continues without a grant (off/report allow, enforce denies), a warning is logged without any token, and Northstar\Cipher\Laravel\Events\AuthorizationExchangeFailed is dispatched for your own alerting. Set NORTHSTAR_CIPHER_AUTHORIZATION_EXCHANGE_FAILURE=fail to fail fast instead.
  • Workers and artisan read the service grant file written by nsc identity-agent; it is re-read for every operation and fails closed when missing or expired.
  • Cipher::withAuthorizationProvider($provider, fn () => ...) and Cipher::withAuthorizationFile($path, fn () => ...) scope any block.
  • Scout index writes (saves, deletes, rebuilds) run as the service identity: set NORTHSTAR_CIPHER_SERVICE_AUTHORIZATION_FILE (defaults to NORTHSTAR_CIPHER_AUTHORIZATION_FILE) or bind a GrantProvider to CipherManager::SERVICE_GRANT. Users scoped to one partition can then save rows that still get indexed; searches use the user's grant. The service needs index on the search index and on the table (every field, record and partition). Without a service grant, writes use the current client.
  • Search scopes must name the Scout index (searchableAs(), including SCOUT_PREFIX), not the table: table members with prefix app_ is searched as app_members. The search service needs index on both the table (the stored ciphertext's context) and the index. cipher:authorization:check warns when a search scope names the table.
  • Check an environment: php artisan cipher:authorization:check --authorization-file=/run/cipher/check.grant --partition=org_1 (allowed round trip; no grant, tampered grant, other record and other partition must be denied). Give the identity a binding for the probe collection nsc_authorization_check.

Tests

composer install
vendor/bin/phpunit tests

The tests boot a Laravel container with SQLite and an in-process fake of the Cipher API; they need no network access or credentials.

Versioning

The package follows Semantic Versioning; northstar/cipher-laravel and northstar/cipher-php are released together with the same version number. Changes are listed in CHANGELOG.md.

Security

Please report vulnerabilities privately through GitHub's private vulnerability reporting, not in a public issue.

License

Apache License 2.0; see LICENSE and NOTICE. "Northstar" and "Northstar Cipher" are trademarks of Northstar Technologies and are not licensed under the Apache License.