@northstartech/cipher 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +541 -2
- package/dist/client.d.ts +237 -0
- package/dist/client.js +555 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto.d.ts +72 -0
- package/dist/crypto.js +244 -0
- package/dist/crypto.js.map +1 -0
- package/dist/encoding.d.ts +17 -0
- package/dist/encoding.js +79 -0
- package/dist/encoding.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/drizzle.d.ts +96 -0
- package/dist/integrations/drizzle.js +285 -0
- package/dist/integrations/drizzle.js.map +1 -0
- package/dist/integrations/mongoose.d.ts +72 -0
- package/dist/integrations/mongoose.js +582 -0
- package/dist/integrations/mongoose.js.map +1 -0
- package/dist/integrations/prisma.d.ts +65 -0
- package/dist/integrations/prisma.js +588 -0
- package/dist/integrations/prisma.js.map +1 -0
- package/dist/integrations/sequelize.d.ts +62 -0
- package/dist/integrations/sequelize.js +677 -0
- package/dist/integrations/sequelize.js.map +1 -0
- package/dist/integrations/shared.d.ts +268 -0
- package/dist/integrations/shared.js +513 -0
- package/dist/integrations/shared.js.map +1 -0
- package/dist/integrations/typeorm.d.ts +109 -0
- package/dist/integrations/typeorm.js +457 -0
- package/dist/integrations/typeorm.js.map +1 -0
- package/dist/object.d.ts +53 -0
- package/dist/object.js +226 -0
- package/dist/object.js.map +1 -0
- package/dist/search.d.ts +250 -0
- package/dist/search.js +258 -0
- package/dist/search.js.map +1 -0
- package/package.json +101 -4
package/README.md
CHANGED
|
@@ -1,3 +1,542 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @northstartech/cipher
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TypeScript SDK for **Northstar Cipher**, field-level encryption as a service. Keys
|
|
4
|
+
never leave attested enclaves. Your database stores only `nsc:v1:…` ciphertext,
|
|
5
|
+
plus optional blind-index tokens for exact-match search.
|
|
6
|
+
|
|
7
|
+
* Node.js ≥ 20 (ESM). The core client also runs in browsers.
|
|
8
|
+
* ORM integrations for **Prisma, Sequelize, Mongoose, Drizzle and TypeORM** ship
|
|
9
|
+
as subpath exports. The ORMs are optional peer dependencies.
|
|
10
|
+
* **Northstar Search**: typo-tolerant full-text search over encrypted data, run
|
|
11
|
+
inside the enclave (see [Northstar Search](#northstar-search)).
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @northstartech/cipher
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
| Variable | Default | Meaning |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `NORTHSTAR_CIPHER_ENDPOINT` | `https://api.northstar.mx` | Gateway URL (`http://localhost:7080` locally) |
|
|
22
|
+
| `NORTHSTAR_CIPHER_CREDENTIAL` | (required) | Workload credential `nsc_dev_…` / `nsc_live_…` |
|
|
23
|
+
| `NORTHSTAR_CIPHER_KEY` | `default` | Key alias |
|
|
24
|
+
| `NORTHSTAR_CIPHER_ATTESTATION` | `auto` | `auto`, `development`, `production` |
|
|
25
|
+
| `NORTHSTAR_CIPHER_TRUST_ANCHOR` | none | Pinned verifier key(s) `<kid>:<b64u>`. Required in production. |
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { CipherError, NorthstarCipher } from '@northstartech/cipher'
|
|
31
|
+
|
|
32
|
+
// One client per process. Constructing it performs no network I/O.
|
|
33
|
+
const cipher = NorthstarCipher.fromEnv()
|
|
34
|
+
|
|
35
|
+
// The context names the protected value. Decryption fails if any part differs.
|
|
36
|
+
const context = { collection: 'citizens', field: 'passport', recordId: '123' }
|
|
37
|
+
const { ciphertext } = await cipher.encrypt({ plaintext: 'G12345678', context })
|
|
38
|
+
const { plaintext } = await cipher.decrypt({ ciphertext, context, purpose: 'kyc_review' })
|
|
39
|
+
|
|
40
|
+
// Batches: one request per 1 000 items. Concurrent encrypt()/decrypt() calls are coalesced automatically.
|
|
41
|
+
const encrypted = await cipher.encryptMany(rows.map((r) => ({ plaintext: r.passport, context: { ...context, recordId: r.id } })))
|
|
42
|
+
const settled = await cipher.decryptManySettled(items) // [{ ok: true, value } | { ok: false, error }]
|
|
43
|
+
|
|
44
|
+
// Exact-match search: store the blind index next to the ciphertext, then match tokens.
|
|
45
|
+
const { index } = await cipher.encrypt({ plaintext: 'ana@example.com', context: { collection: 'users', field: 'email', recordId: '7' }, index: { normalization: 'email' } })
|
|
46
|
+
const [tokens] = await cipher.searchTokens({ collection: 'users', field: 'email', values: ['ANA@example.com'], normalization: 'email' })
|
|
47
|
+
// SELECT … WHERE email_bidx IN (…tokens)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Every failure throws `CipherError` (`code`, `message`, `requestId`, `status`,
|
|
51
|
+
`retryable`). Nothing ever falls back to plaintext. Messages never contain
|
|
52
|
+
plaintext, ciphertext or secrets. To watch the sealed traffic (counts,
|
|
53
|
+
latency), pass `debug: { onWire(e) { … } }`.
|
|
54
|
+
|
|
55
|
+
Before the first request, the client verifies the enclave's attestation and
|
|
56
|
+
opens a session to the attested key with HPKE (RFC 9180) using the **X-Wing**
|
|
57
|
+
KEM (ML-KEM-768 + X25519, a post-quantum hybrid). Recorded traffic stays
|
|
58
|
+
confidential unless both ML-KEM and X25519 are broken, which protects against
|
|
59
|
+
harvest-now-decrypt-later attacks. There is no fallback to a classical key
|
|
60
|
+
exchange: an enclave offering anything but an X-Wing key fails with
|
|
61
|
+
`ATTESTATION_FAILED`.
|
|
62
|
+
|
|
63
|
+
## Encrypting files and large values
|
|
64
|
+
|
|
65
|
+
`encrypt()` takes values up to 1 MiB and sends them to the enclave. For files,
|
|
66
|
+
images, exports and backups, use `encryptObject()`. The SDK seals the bytes in
|
|
67
|
+
your process with a random 32-byte object key (format NSOB v1, AES-256-GCM in
|
|
68
|
+
64 KiB blocks), and Cipher encrypts only that key. The object never reaches
|
|
69
|
+
Cipher, so the 1 MiB limit does not apply.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// The context names where the wrapped key is stored: table, column, record.
|
|
73
|
+
const context = { collection: 'documents', field: 'file_key', recordId: doc.id }
|
|
74
|
+
const { wrappedKey, object } = await cipher.encryptObject({ plaintext: pdfBytes, context })
|
|
75
|
+
await db.documents.update(doc.id, { file_key: wrappedKey }) // nsc:v1:… with the record
|
|
76
|
+
await storage.put(`documents/${doc.id}`, object) // the encrypted bytes
|
|
77
|
+
|
|
78
|
+
const pdf = await cipher.decryptObject({ object, wrappedKey, context, purpose: 'support_case' }) // Uint8Array
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
* Each call makes one request (`/v1/encrypt` or `/v1/decrypt` for the key), so
|
|
82
|
+
policy, purposes and audit apply as for fields. `keyId` and `blockLog2`
|
|
83
|
+
(12 … 24, default 16) are optional.
|
|
84
|
+
* Rotation: `rewrapMany()` on the stored wrapped keys. Objects are not touched.
|
|
85
|
+
* Deletion: delete the object and its wrapped key. Destroying a key version
|
|
86
|
+
makes every object whose key it wraps unreadable (crypto-shredding).
|
|
87
|
+
* Any failure to open an object (another record's context, another object's
|
|
88
|
+
wrapped key, truncation, reordered blocks, a flipped bit) throws
|
|
89
|
+
`INVALID_CIPHERTEXT` with one message. Plaintext is returned only after every
|
|
90
|
+
block verifies.
|
|
91
|
+
* `sealObject()` and `openObject()` are the format functions without a request,
|
|
92
|
+
for streaming and the vectors (`docs/vectors/object-v1.json`). The object key
|
|
93
|
+
is zeroed after use and never logged or cached.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## ORM integrations
|
|
98
|
+
|
|
99
|
+
| ORM | Import | Entry point |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| Prisma 7 | `@northstartech/cipher/prisma` | `withNorthstarCipher(cipher, { models })` client extension |
|
|
102
|
+
| Sequelize 6 | `@northstartech/cipher/sequelize` | `northstarCipher(Model, cipher, { fields })` |
|
|
103
|
+
| Mongoose 9 | `@northstartech/cipher/mongoose` | `schema.plugin(northstarCipherPlugin, { cipher, fields })` |
|
|
104
|
+
| Drizzle | `@northstartech/cipher/drizzle` | `encrypted(table, fields)`, `northstarDrizzle(cipher)`, `cipherEq()` |
|
|
105
|
+
| TypeORM 1 | `@northstartech/cipher/typeorm` | `@Encrypted()` + `northstarCipherSubscriber(cipher)` |
|
|
106
|
+
|
|
107
|
+
### Common behaviour
|
|
108
|
+
|
|
109
|
+
All five integrations follow the same conventions, so data written by one SDK
|
|
110
|
+
(any language) can be read by another.
|
|
111
|
+
|
|
112
|
+
* **Context.** `collection` is the table (or Mongo collection) name, `field` is
|
|
113
|
+
the column name, and `record_id` is the primary key as a string. UUIDs are
|
|
114
|
+
lowercased and ObjectIds use their hex form. Changing a primary key would
|
|
115
|
+
invalidate its ciphertext, so the integrations refuse to do it.
|
|
116
|
+
* **Columns.** Encrypted columns are `text`. Ciphertext is about 180
|
|
117
|
+
characters plus 4/3 × the plaintext length. A searchable field `email` stores
|
|
118
|
+
its blind index in `email_bidx` (`varchar(64)`, indexed), written in the same
|
|
119
|
+
statement. Encrypted values are strings; serialize other types first.
|
|
120
|
+
* **Add a CHECK constraint** to every encrypted column so raw SQL can never
|
|
121
|
+
store plaintext:
|
|
122
|
+
|
|
123
|
+
```sql
|
|
124
|
+
ALTER TABLE todos ADD CONSTRAINT todos_title_nsc CHECK (title IS NULL OR title LIKE 'nsc:%');
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
* **Writes.** Values are encrypted before the row is written: all encrypted
|
|
128
|
+
fields of a record go in one batch request, and bulk operations send one
|
|
129
|
+
batch for all their records. The record id must be known before the write:
|
|
130
|
+
|
|
131
|
+
| Primary key | Strategy |
|
|
132
|
+
|---|---|
|
|
133
|
+
| Explicit or client-generated (UUID, cuid, ULID, ObjectId, `$defaultFn`) | used directly |
|
|
134
|
+
| PostgreSQL serial/identity | reserved first with `nextval(pg_get_serial_sequence(…))` (Prisma, Sequelize, Drizzle) |
|
|
135
|
+
| Auto-increment elsewhere (SQLite, MySQL), and TypeORM generated integer keys | two-phase write: insert the `nsc:pending:v1` sentinel, then replace it with ciphertext bound to the generated id, in the same transaction when one is available |
|
|
136
|
+
|
|
137
|
+
If encryption fails, the write is aborted. A failed second phase deletes the
|
|
138
|
+
pending row. The sentinel can never be decrypted, so a crash leaves no
|
|
139
|
+
plaintext behind.
|
|
140
|
+
* **Reads.** Every encrypted value of every row a query returns (including
|
|
141
|
+
eagerly loaded relations) is decrypted in one batch, chunked at 1 000 values.
|
|
142
|
+
Listing 100 rows with two encrypted fields costs one request, not 200.
|
|
143
|
+
* **Fail closed.** A stored value that is not `nsc:v1:` ciphertext (plaintext,
|
|
144
|
+
the pending sentinel, a foreign value) throws `INVALID_CIPHERTEXT`. So does
|
|
145
|
+
ciphertext that was tampered with or moved to another record. Reading legacy
|
|
146
|
+
plaintext requires an explicit `readLegacyPlaintext: true`, intended for
|
|
147
|
+
migrations only.
|
|
148
|
+
* **Search.** Only exact match is supported, through the blind index, using the
|
|
149
|
+
field's normalization (`exact`, `case_insensitive`, `email`, `alphanumeric`,
|
|
150
|
+
`digits`). **`LIKE`, ranges, ordering and aggregates do not work on encrypted
|
|
151
|
+
fields.** Where an integration can see such a query, it throws instead of
|
|
152
|
+
silently returning wrong results. Blind indexes reveal equality (rows with the
|
|
153
|
+
same value share a token), so do not index low-cardinality fields such as
|
|
154
|
+
status, booleans or country.
|
|
155
|
+
* **Options** shared by every integration:
|
|
156
|
+
* `keyId` sets the key alias.
|
|
157
|
+
* `purpose` is the audited decrypt purpose.
|
|
158
|
+
* `readLegacyPlaintext` is for migrations only.
|
|
159
|
+
|
|
160
|
+
| Error code | When |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `CONFIGURATION_ERROR` | Misconfiguration: unknown field, non-text column, no blind-index column, unsupported key |
|
|
163
|
+
| `INVALID_REQUEST` | An operation that cannot be encrypted correctly: bulk update of encrypted fields, filter on ciphertext, primary-key change |
|
|
164
|
+
| `INVALID_CIPHERTEXT` | Stored value tampered, moved, plaintext or pending |
|
|
165
|
+
| service codes | `KEY_NOT_FOUND`, `PERMISSION_DENIED`, `RATE_LIMITED`, … as returned by the service |
|
|
166
|
+
|
|
167
|
+
> **What bypasses the integrations.** Raw SQL and driver calls do, as do
|
|
168
|
+
> `$executeRaw`, `Model.collection.*`, Sequelize `hooks: false` and TypeORM
|
|
169
|
+
> `listeners: false`. The CHECK constraint is the backstop for all of them.
|
|
170
|
+
> Sequelize refuses `hooks: false` writes that would store plaintext.
|
|
171
|
+
|
|
172
|
+
### Prisma
|
|
173
|
+
|
|
174
|
+
Requires Prisma 7 (the `prisma-client` generator with a driver adapter).
|
|
175
|
+
|
|
176
|
+
```prisma
|
|
177
|
+
model Todo {
|
|
178
|
+
id Int @id @default(autoincrement())
|
|
179
|
+
title String? // encrypted
|
|
180
|
+
email String? // encrypted + searchable
|
|
181
|
+
email_bidx String? @db.VarChar(64)
|
|
182
|
+
@@index([email_bidx])
|
|
183
|
+
@@map("todos")
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { PrismaPg } from '@prisma/adapter-pg'
|
|
189
|
+
import { withNorthstarCipher } from '@northstartech/cipher/prisma'
|
|
190
|
+
import { PrismaClient } from './generated/prisma/client.js'
|
|
191
|
+
|
|
192
|
+
const nsc = withNorthstarCipher(cipher, {
|
|
193
|
+
models: {
|
|
194
|
+
Todo: { title: true, email: { searchable: 'email' } },
|
|
195
|
+
Note: { $id: 'cuid', body: true }, // match the id format of @default(cuid())
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }).$extends(nsc)
|
|
199
|
+
|
|
200
|
+
await prisma.todo.create({ data: { title: 'Buy milk', email: 'Ana@example.com' } })
|
|
201
|
+
await prisma.todo.update({ where: { id }, data: { email: 'new@example.com' } })
|
|
202
|
+
const todos = await prisma.todo.findMany({ include: { owner: true } }) // one decrypt request
|
|
203
|
+
|
|
204
|
+
const mine = await prisma.todo.findMany({ where: await prisma.todo.cipherWhere('email', 'ana@example.com') })
|
|
205
|
+
// or: where: await nsc.cipherWhere('Todo', 'email', ['a@x.com', 'b@x.com']) → { email_bidx: { in: [...] } }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Ids.** Configure them with `$id: '<strategy>'` or `$id: { field, strategy }`.
|
|
209
|
+
* `String` ids are generated client-side. The default is `'uuid'`; also
|
|
210
|
+
available are `'uuidv7'`, `'cuid'`, `'cuid2'`, `'ulid'`, `'nanoid'`, or a
|
|
211
|
+
function.
|
|
212
|
+
* `Int`/`BigInt` ids on PostgreSQL/CockroachDB are reserved from the sequence
|
|
213
|
+
(`'sequence'`).
|
|
214
|
+
* Integer auto-increment ids on other databases are a configuration error.
|
|
215
|
+
Use a client-generated id, or `$id: 'explicit'` and always pass ids.
|
|
216
|
+
* Composite primary keys are not supported.
|
|
217
|
+
|
|
218
|
+
The extension handles `create`, `createMany`, `createManyAndReturn`, `update`,
|
|
219
|
+
`upsert`, `delete` and every `find*`, including `select`, `omit`, `include`,
|
|
220
|
+
nested relations and the fluent API. When a selection includes encrypted
|
|
221
|
+
fields but omits the id, the extension fetches the id and strips it from the
|
|
222
|
+
result.
|
|
223
|
+
|
|
224
|
+
**Limitations.**
|
|
225
|
+
* `updateMany`/`updateManyAndReturn` cannot set encrypted fields, because each
|
|
226
|
+
row needs its own ciphertext. Update records individually.
|
|
227
|
+
* Nested writes into an encrypted model (`owner: { create: { ssn } }`) are
|
|
228
|
+
refused. Write that model through its own delegate.
|
|
229
|
+
* With a reserved integer id, Prisma's relation inputs (`owner: { connect }`)
|
|
230
|
+
cannot carry the id. Set the foreign-key scalar (`ownerId`) instead.
|
|
231
|
+
* `update`/`upsert` addressed by a non-id unique field look the id up first
|
|
232
|
+
through the base client, outside any interactive transaction. Inside
|
|
233
|
+
`$transaction(async (tx) => …)`, address rows by id.
|
|
234
|
+
* Reserved sequence values are not rolled back, so gaps are normal.
|
|
235
|
+
* `where`, `orderBy`, `distinct`, `groupBy`, `_min` and `_max` on encrypted
|
|
236
|
+
fields are refused. Null checks are allowed.
|
|
237
|
+
* `$queryRaw` returns ciphertext. Decrypt it with the core client.
|
|
238
|
+
|
|
239
|
+
### Sequelize
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { northstarCipher } from '@northstartech/cipher/sequelize'
|
|
243
|
+
|
|
244
|
+
class Todo extends Model {}
|
|
245
|
+
Todo.init({
|
|
246
|
+
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
|
|
247
|
+
title: { type: DataTypes.TEXT, validate: { len: [1, 60] } }, // validators see plaintext
|
|
248
|
+
email: { type: DataTypes.TEXT, validate: { isEmail: true } },
|
|
249
|
+
email_bidx: DataTypes.STRING(64),
|
|
250
|
+
}, { sequelize, tableName: 'todos' })
|
|
251
|
+
|
|
252
|
+
const T = northstarCipher(Todo, cipher, { fields: ['title', { name: 'email', searchable: 'email' }] })
|
|
253
|
+
|
|
254
|
+
await Todo.create({ title: 'Buy milk', email: 'ana@example.com' })
|
|
255
|
+
await Todo.bulkCreate(rows, { validate: true }) // one encrypt request
|
|
256
|
+
const all = await Todo.findAll({ include: [Owner] }) // one decrypt request, includes too
|
|
257
|
+
const mine = await Todo.findAll({ where: await T.cipherWhere('email', 'ANA@example.com') })
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Ids.** Explicit ids and defaults such as `UUIDV4` are used directly.
|
|
261
|
+
`autoIncrement` keys are reserved from the sequence on PostgreSQL. Other
|
|
262
|
+
dialects use the two-phase write: `afterCreate`/`afterBulkCreate` replace the
|
|
263
|
+
sentinel inside `options.transaction` when given, and delete the row if that
|
|
264
|
+
fails.
|
|
265
|
+
|
|
266
|
+
**Writes and reads.**
|
|
267
|
+
* Instances hold plaintext after `create`/`save`, and `changed()` is clean.
|
|
268
|
+
* Validators always run on plaintext, including bulk validation and
|
|
269
|
+
Sequelize's re-validation of hook-changed attributes.
|
|
270
|
+
* A static `Model.update()` that sets encrypted attributes switches to
|
|
271
|
+
per-instance hooks, so every row gets its own ciphertext.
|
|
272
|
+
* `afterFind` decrypts all returned instances, their eager-loaded includes and
|
|
273
|
+
`raw: true` rows in one batch. Select the primary key together with encrypted
|
|
274
|
+
attributes.
|
|
275
|
+
|
|
276
|
+
**Refused.**
|
|
277
|
+
* `Model.upsert()` with encrypted attributes. Sequelize builds the statement
|
|
278
|
+
before hooks run, so use `findOrCreate`/`save` instead.
|
|
279
|
+
* `updateOnDuplicate` on encrypted attributes.
|
|
280
|
+
* `ignoreDuplicates` combined with the two-phase write.
|
|
281
|
+
* `where` or `order` on encrypted attributes.
|
|
282
|
+
* Primary-key changes.
|
|
283
|
+
* `hooks: false` writes that would store plaintext.
|
|
284
|
+
|
|
285
|
+
### Mongoose
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { northstarCipherPlugin, type CipherModelStatics, type CipherQueryHelpers } from '@northstartech/cipher/mongoose'
|
|
289
|
+
|
|
290
|
+
const schema = new Schema({
|
|
291
|
+
title: { type: String, maxlength: 60 },
|
|
292
|
+
email: { type: String, lowercase: true, trim: true }, // setters and validators apply to plaintext
|
|
293
|
+
profile: { ssn: String },
|
|
294
|
+
})
|
|
295
|
+
schema.plugin(northstarCipherPlugin, { cipher, fields: ['title', { path: 'email', searchable: 'email' }, { path: 'profile.ssn', searchable: 'alphanumeric' }] })
|
|
296
|
+
const Todo = model('Todo', schema)
|
|
297
|
+
|
|
298
|
+
await Todo.create({ title: 'Buy milk', email: 'Ana@example.com' })
|
|
299
|
+
await Todo.insertMany(docs) // one encrypt request
|
|
300
|
+
const todos = await Todo.find().populate('owner') // one decrypt request per model
|
|
301
|
+
const mine = await Todo.find().cipherWhere('email', 'ana@example.com')
|
|
302
|
+
const one = await Todo.findOne(await Todo.cipherFilter('profile.ssn', 'G-1234'))
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**Paths and ids.** `_id` is the record id: ObjectIds exist before the insert,
|
|
306
|
+
so no extra round trip is needed. Encrypted paths are top-level or
|
|
307
|
+
nested-object `String` paths; arrays and subdocuments are not supported. The
|
|
308
|
+
plugin adds `<path>_bidx` (indexed) for searchable paths.
|
|
309
|
+
|
|
310
|
+
**Writes.**
|
|
311
|
+
* `save()`, `create()` and `insertMany()` encrypt. `insertMany()` uses one
|
|
312
|
+
batch for all documents.
|
|
313
|
+
* `updateOne`, `findOneAndUpdate`, `replaceOne` and `findOneAndReplace` encrypt
|
|
314
|
+
`$set`, `$setOnInsert`, implicit sets and replacements. The update is bound
|
|
315
|
+
to the single matched document: its `_id` is resolved with the same filter
|
|
316
|
+
and sort, and the filter is pinned to it. Upserts insert under a fresh
|
|
317
|
+
`_id`. `$unset` also clears the blind index.
|
|
318
|
+
|
|
319
|
+
**Refused.**
|
|
320
|
+
* `updateMany` and `bulkWrite` that write encrypted paths.
|
|
321
|
+
* Other update operators on encrypted paths.
|
|
322
|
+
* Filters, `sort` and `distinct` on encrypted paths. Null and `$exists`
|
|
323
|
+
checks are allowed.
|
|
324
|
+
* Aggregation-pipeline updates that touch encrypted paths.
|
|
325
|
+
|
|
326
|
+
**Reads.** `find`, `findOne` and the `findOneAnd*` methods decrypt both
|
|
327
|
+
documents and `lean()` results. `aggregate()`, cursors and change streams
|
|
328
|
+
return ciphertext; pass the results to `Model.cipherDecrypt(docs)`.
|
|
329
|
+
|
|
330
|
+
**TypeScript.** Use `Model<T, CipherQueryHelpers> & CipherModelStatics`.
|
|
331
|
+
|
|
332
|
+
### Drizzle
|
|
333
|
+
|
|
334
|
+
Drizzle has no async hooks, so encryption is an explicit, typed step around
|
|
335
|
+
your queries.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
import { cipherEq, encrypted, northstarDrizzle, pendingRow, PENDING_SENTINEL } from '@northstartech/cipher/drizzle'
|
|
339
|
+
|
|
340
|
+
export const todos = encrypted(
|
|
341
|
+
pgTable('todos', { id: serial('id').primaryKey(), title: text('title'), email: text('email'), emailBidx: varchar('email_bidx', { length: 64 }) }),
|
|
342
|
+
{ title: true, email: { searchable: 'email' } }, // `email_bidx` is found by column name (or set indexField)
|
|
343
|
+
)
|
|
344
|
+
const nsc = northstarDrizzle(cipher, { db }) // db reserves PostgreSQL serial ids
|
|
345
|
+
|
|
346
|
+
await db.insert(todos).values(await nsc.encryptRows(todos, [{ title: 'Buy milk', email: 'ana@example.com' }]))
|
|
347
|
+
const rows = await nsc.decryptRows(todos, await db.select().from(todos)) // one decrypt request
|
|
348
|
+
const tokens = await nsc.searchTokens(todos.email, 'ANA@example.com')
|
|
349
|
+
const mine = await nsc.decryptRows(todos, await db.select().from(todos).where(cipherEq(todos.email, tokens)))
|
|
350
|
+
await db.update(todos).set(await nsc.encryptRow(todos, { email: 'new@example.com' }, { recordId: id })).where(eq(todos.id, id))
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Ids.**
|
|
354
|
+
* An id already in the row is used.
|
|
355
|
+
* Otherwise the primary key's `$defaultFn` (for example
|
|
356
|
+
`uuid('id').$defaultFn(() => crypto.randomUUID())`) is called.
|
|
357
|
+
* Otherwise a PostgreSQL `serial`/identity key is reserved with one `nextval`
|
|
358
|
+
query. Pass `{ db: tx }` inside transactions. `generatedAlwaysAsIdentity`
|
|
359
|
+
rejects explicit ids; use `generatedByDefaultAsIdentity`.
|
|
360
|
+
* For databases where the id only exists after the insert, use the two-phase
|
|
361
|
+
write:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const [{ id }] = await db.insert(tickets).values(pendingRow(tickets, input)).returning({ id: tickets.id })
|
|
365
|
+
await db.update(tickets).set(await nsc.encryptRow(tickets, input, { recordId: id }))
|
|
366
|
+
.where(and(eq(tickets.id, id), eq(tickets.subject, PENDING_SENTINEL)))
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Reads.** `decryptRows` expects rows keyed like the table, including partial
|
|
370
|
+
selects that keep the primary key. For joins, pass each table's sub-object.
|
|
371
|
+
Because writes are explicit, CHECK constraints matter most here.
|
|
372
|
+
|
|
373
|
+
### TypeORM
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import { Encrypted, cipherWhere, northstarCipherSubscriber } from '@northstartech/cipher/typeorm'
|
|
377
|
+
|
|
378
|
+
@Entity('todos')
|
|
379
|
+
export class Todo {
|
|
380
|
+
@PrimaryGeneratedColumn('uuid') id!: string
|
|
381
|
+
@Encrypted() @Column({ type: 'text', nullable: true }) title!: string | null
|
|
382
|
+
@Encrypted({ searchable: 'email' }) @Column({ type: 'text', nullable: true }) email!: string | null
|
|
383
|
+
@Index() @Column({ type: 'varchar', length: 64, nullable: true }) email_bidx!: string | null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const ds = new DataSource({ /* … */ entities: [Todo], subscribers: [northstarCipherSubscriber(cipher)] })
|
|
387
|
+
await ds.getRepository(Todo).save(todos) // encryption coalesced into one request
|
|
388
|
+
const all = await ds.getRepository(Todo).find({ relations: { owner: true } })
|
|
389
|
+
const mine = await ds.getRepository(Todo).findBy(await cipherWhere(ds, Todo, 'email', 'ANA@example.com'))
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
`@Encrypted()` uses TypeORM's legacy decorators (`experimentalDecorators`).
|
|
393
|
+
Declare an explicit `text` column type. You can also register an instance with
|
|
394
|
+
`dataSource.subscribers.push(new NorthstarCipherSubscriber(cipher))`.
|
|
395
|
+
|
|
396
|
+
**Ids.**
|
|
397
|
+
* `uuid` primary keys are generated client-side in `beforeInsert`.
|
|
398
|
+
* Generated integer keys (`increment`, `identity`, `rowid`) use the two-phase
|
|
399
|
+
write in `afterInsert`, inside `save()`'s transaction. TypeORM never sends
|
|
400
|
+
explicit values for them on PostgreSQL. On failure, the row is removed and
|
|
401
|
+
the transaction rolls back.
|
|
402
|
+
* `@PrimaryColumn()` values must be set before saving.
|
|
403
|
+
|
|
404
|
+
**Writes and reads.**
|
|
405
|
+
* `save()` re-encrypts only properties that differ from the loaded entity.
|
|
406
|
+
* `afterLoad` decrypts each value through the SDK's auto-batching, so a query
|
|
407
|
+
costs one request per 1 000 values. Keep the client's `batching` enabled.
|
|
408
|
+
|
|
409
|
+
**Refused and not intercepted.**
|
|
410
|
+
* `repository.update()` or `QueryBuilder.update()` setting encrypted columns
|
|
411
|
+
is refused. Load the entity and `save()` it.
|
|
412
|
+
* TypeORM has no pre-find hook, so `where` and `order` on encrypted columns
|
|
413
|
+
are not intercepted. Use `cipherWhere()`.
|
|
414
|
+
* `upsert()` must conflict on the primary key.
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## Northstar Search
|
|
419
|
+
|
|
420
|
+
Encrypted full-text search that runs inside the attested enclave. Documents and
|
|
421
|
+
queries travel sealed to the enclave session; index files are encrypted at rest
|
|
422
|
+
under a key only the enclave can unwrap. Indexing needs `cipher.index`, queries
|
|
423
|
+
need `cipher.search`, and returning values (`select`, `highlight`) or indexing
|
|
424
|
+
ciphertext needs `cipher.decrypt`.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
const customers = cipher.index('customers') // no request until a method is called
|
|
428
|
+
|
|
429
|
+
// Create the index, or confirm it exists with this schema (outcome: created | unchanged).
|
|
430
|
+
await customers.ensure({
|
|
431
|
+
language: 'spanish', // english, spanish, portuguese, french, german, italian, dutch, none
|
|
432
|
+
fields: { name: 'text', email: 'keyword', city: 'keyword', balance: 'number', joined: 'date', vip: 'bool' },
|
|
433
|
+
}) // or fields: [{ name, type, boost?, language? }]
|
|
434
|
+
|
|
435
|
+
// Add or replace documents ({ [id]: fields } or [{ id, fields }]); chunked by 1 000.
|
|
436
|
+
await customers.upsert({
|
|
437
|
+
42: { name: 'Ana García', city: 'CDMX', balance: 1500, joined: new Date() },
|
|
438
|
+
// Ciphertext is decrypted inside the enclave. A bare `nsc:v1:` string uses the
|
|
439
|
+
// context { collection: <index or `collection` option>, field: <field>, record_id: <id> } …
|
|
440
|
+
43: { name: storedNameCiphertext, city: 'GDL' },
|
|
441
|
+
// … or give the context explicitly:
|
|
442
|
+
44: { name: SearchIndex.ciphertext(row.name, { collection: 'customers', field: 'name', recordId: 44 }) },
|
|
443
|
+
}, { wait: true }) // wait: return once searchable
|
|
444
|
+
await customers.upsert({ 42: { city: 'MTY' } }, { mode: 'merge' }) // only these fields change (null removes one)
|
|
445
|
+
await customers.delete([43])
|
|
446
|
+
|
|
447
|
+
const r = await customers.search({
|
|
448
|
+
q: 'ana garsia', // typo tolerant; fuzzy: auto | off | one | two
|
|
449
|
+
filter: { city: ['CDMX', 'MTY'], balance: { gte: 1000 }, vip: { exists: false } }, // value | [values] | {gt,gte,lt,lte} | {exists} | {not}
|
|
450
|
+
sort: [{ field: 'balance', desc: true }],
|
|
451
|
+
limit: 20, offset: 0, facets: ['city'],
|
|
452
|
+
select: ['name', 'city'], highlight: ['name'], purpose: 'support', // values are returned decrypted
|
|
453
|
+
})
|
|
454
|
+
// r.hits = [{ id: '42', score: 3.1, fields: { name: 'Ana García', city: 'MTY' }, highlights: { name: '<mark>Ana</mark> <mark>García</mark>' } }]
|
|
455
|
+
// r.total, r.facets.city = [{ value: 'MTY', count: 1 }], r.tookMs
|
|
456
|
+
|
|
457
|
+
await customers.describe() // { index, keyId, active: { version, documents, diskBytes, … }, building? }
|
|
458
|
+
await cipher.searchIndexes() // every index this credential may use
|
|
459
|
+
await customers.drop() // true, or false when there was none
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**Changing the schema without downtime.** `ensure(newSchema)` fails with
|
|
463
|
+
`CONFLICT` when the schema changed. `ensure(newSchema, { rebuild: true })` builds
|
|
464
|
+
a new version next to the live one: writes reach both, queries keep using the
|
|
465
|
+
live one. Reindex every record, then `flush({ optimize: true })` and
|
|
466
|
+
`activate()` to switch over (the old version is deleted).
|
|
467
|
+
|
|
468
|
+
A missing index raises `NOT_FOUND`. Documents the enclave could not index (e.g.
|
|
469
|
+
a field missing from the schema, a ciphertext bound to another context) are
|
|
470
|
+
listed in `errors`; the rest of the request is indexed.
|
|
471
|
+
|
|
472
|
+
### Search with the ORM integrations
|
|
473
|
+
|
|
474
|
+
Each integration builds documents whose encrypted fields are the **stored
|
|
475
|
+
ciphertext with the exact context the integration encrypted it with**
|
|
476
|
+
(collection = table, field = column, record id = primary key), so indexing
|
|
477
|
+
never decrypts in your process. Plain attributes are sent as values (dates as
|
|
478
|
+
RFC 3339). Integrations with lifecycle hooks keep an index in sync; sync writes
|
|
479
|
+
use `merge` mode, so partially loaded records do not erase indexed fields.
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
const index = cipher.index('todos')
|
|
483
|
+
await index.ensure({ fields: { title: 'text', email: 'keyword', done: 'bool' } })
|
|
484
|
+
|
|
485
|
+
// Sequelize: create/save/destroy, bulkCreate, Model.update/destroy (after commit inside a transaction)
|
|
486
|
+
northstarCipher(Todo, cipher, { fields: ['title', { name: 'email', searchable: 'email' }], search: { index, fields: ['title', 'email', 'done'] } })
|
|
487
|
+
await index.upsert(Todo.cipherSearchDocuments(await Todo.findAll({ hooks: false } as FindOptions))) // reindex
|
|
488
|
+
|
|
489
|
+
// TypeORM: save/remove/softRemove, applied when the transaction commits (dropped on rollback)
|
|
490
|
+
new DataSource({ …, subscribers: [northstarCipherSubscriber(cipher, { search: [{ entity: Todo, index, fields: ['title', 'email', 'done'] }] })] })
|
|
491
|
+
await index.upsert(searchDocuments(ds, Todo, await ds.query('SELECT * FROM todos'), ['title', 'email', 'done']))
|
|
492
|
+
|
|
493
|
+
// Mongoose: save, insertMany, doc.deleteOne(), findOneAndDelete()
|
|
494
|
+
todoSchema.plugin(northstarCipherPlugin, { cipher, fields: ['title'], search: { index, fields: ['title', 'done'] } })
|
|
495
|
+
await index.upsert(Todo.cipherSearchDocuments(await Todo.collection.find().toArray()))
|
|
496
|
+
|
|
497
|
+
// Prisma and Drizzle (no lifecycle hooks): build documents from stored rows
|
|
498
|
+
await index.upsert(nsc.searchDocuments('Todo', await base.todo.findMany(), ['title', 'email', 'done'])) // base = client without the extension
|
|
499
|
+
await index.upsert(searchDocuments(todos, await db.select().from(todos), ['title', 'email', 'done'])) // @northstartech/cipher/drizzle
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Sync options: `{ index, fields, wait?, onError? }`. Without `onError`, an index
|
|
503
|
+
failure rejects the ORM call, or is logged when it happens after a commit.
|
|
504
|
+
Writes the hooks cannot see (raw SQL, `QueryBuilder`/query updates,
|
|
505
|
+
`deleteMany`, Sequelize `upsert()`) are not tracked: reindex after them. Load
|
|
506
|
+
hits back with your ORM (`where: { id: hits.map((h) => h.id) }`) and keep the
|
|
507
|
+
hit order.
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## Running the tests
|
|
512
|
+
|
|
513
|
+
`npx vitest run` runs three groups:
|
|
514
|
+
|
|
515
|
+
* The protocol vector tests (`docs/vectors/protocol-v1.json`, plus the X-Wing
|
|
516
|
+
vectors `xwing-v1.json` and the draft-ietf-hpke-pq reference `xwing-reference.json`)
|
|
517
|
+
and the object vectors (`object-v1.json`, `test/object-vectors.test.ts`).
|
|
518
|
+
* The object encryption tests (`test/object.test.ts`): mocked transport, and a
|
|
519
|
+
live flow with a 3 MiB object (one small `/v1/encrypt`, tampering, rewrap).
|
|
520
|
+
* The Northstar Search unit tests (`test/search.test.ts`, mocked transport) and
|
|
521
|
+
its live flow (index `sdk_ts_<random>`: ensure, ciphertext indexing, typos,
|
|
522
|
+
filters, facets, highlights, merge, delete, rebuild + activate, drop).
|
|
523
|
+
* The live ORM integration tests, including search sync. They need the local stack
|
|
524
|
+
(`curl localhost:7080/health/ready`) and a credential:
|
|
525
|
+
|
|
526
|
+
```sh
|
|
527
|
+
node ../../scripts/dev-provision.mjs --email node-orm@northstar.mx --project "Node ORMs" --out .test-credential.env
|
|
528
|
+
npx vitest run
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
* PostgreSQL defaults to `postgres://postgres@127.0.0.1:5432`; override it
|
|
532
|
+
with `NSC_TEST_PG_URL`. The databases `nsc_sdk_node_{prisma,sequelize,sequelize_search,drizzle,typeorm}`
|
|
533
|
+
are dropped and recreated on each run; search indexes `sdk_ts_*` are dropped at the end.
|
|
534
|
+
* MongoDB runs in `mongodb-memory-server`, and SQLite runs in memory.
|
|
535
|
+
* The Prisma test client is generated into `test/prisma/generated/`.
|
|
536
|
+
* Without a credential, the live suites are skipped with a notice.
|
|
537
|
+
|
|
538
|
+
Each suite proves four things:
|
|
539
|
+
* Only ciphertext and tokens reach the database (checked with raw queries).
|
|
540
|
+
* Listing 100 rows takes at most 2 decrypt requests (counted with `debug.onWire`).
|
|
541
|
+
* Exact-match search works.
|
|
542
|
+
* Tampered, swapped, pending and plaintext values throw.
|