Search by

northstar / cipher-php

dlogvin

Northstar Cipher PHP SDK: field-level encryption backed by attested enclaves.

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

This package is auto-updated.

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


README

PHP SDK for Northstar Cipher: field-level encryption, blind-index search, object encryption and encrypted full-text search, backed by attested enclaves. Framework-agnostic; Laravel users should install northstar/cipher-laravel, which builds on this package.

Installation

Requires PHP 8.2 or later with the json, openssl and sodium extensions.

composer require northstar/cipher-php:^1.1

Usage

use Northstar\Cipher\Client;

$cipher = Client::fromEnv(); // NORTHSTAR_CIPHER_ENDPOINT, NORTHSTAR_CIPHER_CREDENTIAL, NORTHSTAR_CIPHER_KEY

$ctx = ['collection' => 'citizens', 'field' => 'passport', 'record_id' => '123'];
$enc = $cipher->encrypt('G12345678', $ctx);             // EncryptResult {ciphertext, keyId, keyVersion, index}
$plain = $cipher->decrypt($enc->ciphertext, $ctx);      // "G12345678"

// Batches (≤ 1 000 per request, positional results; item errors are CipherException instances)
$results = $cipher->decryptMany([['ciphertext' => $a, 'context' => $ctxA], ['ciphertext' => $b, 'context' => $ctxB]]);

// Blind indexes for exact-match search
$enc = $cipher->encrypt('ana@example.com', ['collection' => 'users', 'field' => 'email', 'record_id' => '7'], index: 'email');
$tokens = $cipher->searchTokens('users', 'email', ['Ana@Example.com'], 'email'); // [[token, …]]

Everything else — credential assertions, attestation verification, the HPKE session to the enclave, sealing, retries — happens inside the client. Failures throw Northstar\Cipher\CipherException (getErrorCode(), getRequestId(), isRetryable()); nothing ever falls back to plaintext.

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.

Sharing sessions across PHP-FPM workers

Pass a SessionStore (Redis, APCu, Laravel cache…) so workers reuse one attested session instead of re-attesting per request. Store values encrypted; they are short-lived transport keys.

$cipher = new Client(Config::fromEnv(), new MyRedisSessionStore($redis));

Production

Pin the attestation verifier: NORTHSTAR_CIPHER_TRUST_ANCHOR=<kid>:<key> (shown in the console). nsc_live_ credentials refuse to run without it and reject anything but AMD SEV-SNP attestation.

Post-quantum session establishment

The session to the enclave is HPKE (RFC 9180, base mode, HKDF-SHA256, export-only) with the X-Wing KEM (MLKEM768-X25519, 0x647a, draft-ietf-hpke-pq): a post-quantum hybrid that stays secure unless both ML-KEM-768 and X25519 are broken, so recorded traffic is protected against harvest-now-decrypt-later attacks. The client only encapsulates to the enclave's attested 1216-byte key; it rejects anything else, including a classical 32-byte X25519 key (no downgrade).

PHP has no ML-KEM or SHAKE, so both are implemented in pure PHP (src/Internal/Keccak.php, src/Internal/MlKem768.php) on top of libsodium X25519, hash() SHA3 and HMAC-SHA256 — no extra extensions or packages. They are branch-free on secret data (see the constant-time notes in MlKem768.php), and one encapsulation takes about 0.7 ms on PHP 8.4 without JIT. With a shared SessionStore the handshake runs about once an hour per credential, not per request.

Tests

composer install
vendor/bin/phpunit tests

The tests reproduce tests/vectors/protocol-v1.json, tests/vectors/object-v1.json, all 24 tests/vectors/xwing-v1.json cases (and reject its invalid keys) and the draft-ietf-hpke-pq reference vector byte-for-byte, and run the client against an in-process fake of the Cipher API, so they need no network access or credentials.

Independent data authorization

use Northstar\Cipher\Authorization\ExchangeGrantProvider;

$scoped = $cipher->withAuthorizationProvider(new ExchangeGrantProvider(
    getenv('NORTHSTAR_CIPHER_AUTHORIZATION_EXCHANGE_URL'),
    credentialId: $cipher->credentialId(),
    authority: 'customer-auth',
    identityToken: fn () => $identityJwtFromYourIdp,
));
$worker = $cipher->withAuthorizationFile('/run/cipher/billing-worker.grant'); // nsc identity-agent

// Partitioned data: grants can be scoped to the tenant instead of record ids.
$cipher->encrypt($value, ['collection' => 'invoices', 'field' => 'total', 'record_id' => $id, 'partition' => "org_{$orgId}"]);

Grant files are re-read for every operation and fail closed (AUTHORIZATION_UNAVAILABLE) when missing, empty, malformed or expired; a refused identity is PERMISSION_DENIED.

Encrypting files and large values

Fields carry at most 1 MiB and travel to the enclave. For files, images, exports and backups use object encryption (protocol §13): the client seals the bytes in this process with a random 32-byte object key (NSOB v1, AES-256-GCM in 64 KiB blocks) and Cipher encrypts only that key, bound to the context. The object never reaches Cipher, so its size is limited only by memory. Each call makes one request.

$ctx = ['collection' => 'documents', 'field' => 'file_key', 'record_id' => '42'];

$enc = $cipher->encryptObject(file_get_contents($path), $ctx); // EncryptedObject {wrappedKey, object, keyId, keyVersion}
$storage->put('documents/42', $enc->object);                   // object storage
$document->file_key = $enc->wrappedKey;                        // "nsc:v1:…", stored with the record

$bytes = $cipher->decryptObject($storage->get('documents/42'), $document->file_key, $ctx, purpose: 'support_case');
  • The context names where the wrapped key is stored (table, column, record). A wrong context, another object's key, or a truncated, reordered or altered object throws CipherException with INVALID_CIPHERTEXT.
  • Rotation: rewrapMany() on the stored wrapped keys; objects are not touched. Deletion: delete the object and its wrapped key. Destroying a key version makes every object whose key it wraps unreadable.
  • The object key is random per object, zeroed after use and never cached. ObjectEnvelope::seal() / open() are the pure functions (no network), for streaming and the vectors (tests/ObjectVectorsTest.php).

Northstar Search

Encrypted full-text search, executed inside the enclave.

$customers = $cipher->index('customers');
$customers->ensure(['language' => 'spanish', 'fields' => [
    'name' => ['type' => 'text', 'boost' => 2], 'email' => 'keyword', 'city' => 'keyword', 'balance' => 'number',
]]);

// Plain values, or ciphertext the enclave decrypts to index:
$customers->upsert([
    '42' => ['name' => 'Ana García', 'city' => 'CDMX', 'balance' => 1500],
    '43' => ['name' => SearchIndex::ciphertext($row->name, ['collection' => 'customers', 'field' => 'name', 'record_id' => '43'])],
], wait: true);

$r = $customers->search(['q' => 'ana garsia', 'filter' => ['city' => 'cdmx', 'balance' => ['gte' => 1000]], 'facets' => ['city']]);
// $r['hits'] = [['id' => '42', 'score' => 3.1]], $r['total'], $r['facets']['city'] = [['value' => 'CDMX', 'count' => 1]]

$customers->delete(['43']);
$customers->ensure($newSchema, rebuild: true);   // new version next to the live one…
$customers->activate();                           // …made live after backfilling
$cipher->searchIndexes();                         // indexes this credential may use

Versioning

The package follows Semantic Versioning; northstar/cipher-php and northstar/cipher-laravel 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.