northstar / cipher-php
Northstar Cipher PHP SDK: field-level encryption backed by attested enclaves.
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- ext-sodium: *
- guzzlehttp/guzzle: ^7.8 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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
CipherExceptionwithINVALID_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.