@arkstack/encryption 0.18.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toneflix Technologies Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # `@arkstack/encryption`
2
+
3
+ [![@arkstack/encryption](https://img.shields.io/npm/dt/@arkstack/encryption?style=flat-square&label=@arkstack/encryption&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/encryption)](https://www.npmjs.com/package/@arkstack/encryption)
4
+
5
+ A zero dependency isomorphic encryption for Arkstack. One implementation, built on the Web Crypto API, that runs unchanged in Node, Deno, Bun, browsers and workers, so **anything encrypted on the server decrypts in the browser, and anything encrypted in the browser decrypts on the server**.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Installation](#installation)
10
+ - [What's in the box](#whats-in-the-box)
11
+ - [Symmetric encryption](#symmetric-encryption)
12
+ - [Keys](#keys)
13
+ - [End-to-end encryption](#end-to-end-encryption)
14
+ - [Secure channels](#secure-channels)
15
+ - [Sealed boxes](#sealed-boxes)
16
+ - [Verifying a conversation](#verifying-a-conversation)
17
+ - [Synchronous Node API](#synchronous-node-api)
18
+ - [Wire formats](#wire-formats)
19
+ - [Runtime requirements](#runtime-requirements)
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pnpm add @arkstack/encryption
25
+ ```
26
+
27
+ Arkstack applications already have it through `@arkstack/common` re-exports in [`Encryption`](https://arkstack.toneflix.net/guide/utilities/encryption).
28
+
29
+ ## What's in the box
30
+
31
+ | Export | What it does |
32
+ | --------------- | -------------------------------------------------------------- |
33
+ | `Cipher` | AES-256-GCM symmetric encryption |
34
+ | `EncryptionKey` | A symmetric key value: generate, derive, fingerprint, compare |
35
+ | `Keys` | Key generation and constant time comparison helpers |
36
+ | `KeyPair` | ECDH P-256 identities, serialisable to base64url |
37
+ | `SecureChannel` | A shared key between two identities, and messages over it |
38
+ | `SealedBox` | Anonymous encryption to a public key |
39
+ | `Codec` | base64url / hex / utf8 conversion and constant time compare |
40
+ | `NodeCipher` | Synchronous AES-256-GCM for Node (`@arkstack/encryption/node`) |
41
+
42
+ ## Symmetric encryption
43
+
44
+ ```ts
45
+ import { Cipher, Keys } from '@arkstack/encryption';
46
+
47
+ const key = Keys.generate();
48
+
49
+ const payload = await Cipher.encrypt('my-secret-value', key);
50
+ // "abc123:def456:ghi789"
51
+
52
+ await Cipher.decrypt(payload, key);
53
+ // "my-secret-value"
54
+ ```
55
+
56
+ A cipher instance imports the key once, which is worth doing when encrypting many values:
57
+
58
+ ```ts
59
+ const cipher = await Cipher.from(process.env.APP_KEY!);
60
+
61
+ const rows = await Promise.all(values.map((value) => cipher.encrypt(value)));
62
+ ```
63
+
64
+ Any key representation works: an `EncryptionKey`, raw `Uint8Array` bytes, a `CryptoKey`, a base64url string of exactly 32 bytes, or any other string, which is hashed with SHA-256 and treated as a passphrase.
65
+
66
+ ### Additional authenticated data
67
+
68
+ `aad` is not encrypted, but it is bound to the ciphertext: decryption fails unless the same value is supplied. Use it to pin a payload to the context it belongs in, so a valid ciphertext cannot be replayed somewhere else.
69
+
70
+ ```ts
71
+ const payload = await cipher.encrypt(body, { aad: `conversation:${id}` });
72
+
73
+ await cipher.decrypt(payload, { aad: `conversation:${id}` }); // ok
74
+ await cipher.decrypt(payload, { aad: 'conversation:other' }); // throws
75
+ ```
76
+
77
+ ### Bytes
78
+
79
+ `encryptBytes` / `decryptBytes` take and return `Uint8Array` for binary payloads.
80
+
81
+ ## Keys
82
+
83
+ ```ts
84
+ import { Keys } from '@arkstack/encryption';
85
+
86
+ Keys.generate(); // EncryptionKey, 32 random bytes
87
+ Keys.generateString(); // the same, base64url encoded for storage
88
+ Keys.token(16); // a random URL-safe token (not a key)
89
+ ```
90
+
91
+ Passwords get stretched, secrets get hashed:
92
+
93
+ ```ts
94
+ const { key, salt, iterations } = await Keys.derive(password); // PBKDF2-HMAC-SHA256
95
+ const same = await Keys.derive(password, { salt, iterations });
96
+
97
+ await Keys.fromSecret(process.env.APP_KEY!); // SHA-256, matching Arkstack's app key handling
98
+ ```
99
+
100
+ ### Comparing keys
101
+
102
+ Every comparison here runs in constant time, and never throws on malformed input — it returns `false`.
103
+
104
+ ```ts
105
+ Keys.compare(left, right); // two keys already in key form
106
+ await Keys.matches(passphrase, key); // resolves both sides first
107
+ await Keys.samePublicKey(left, right); // two identities
108
+ ```
109
+
110
+ ### Fingerprints
111
+
112
+ A fingerprint is a digest of a key, safe to display or log. Two people reading the same fingerprint are holding the same key.
113
+
114
+ ```ts
115
+ await Keys.fingerprint(key);
116
+ // "3f8a1c02 9b4e7d15 c6a0ff31 2e5b8d94"
117
+ ```
118
+
119
+ ---
120
+
121
+ ## End-to-end encryption
122
+
123
+ An identity is an ECDH P-256 key pair. The public half is published, the private half never leaves its owner.
124
+
125
+ ```ts
126
+ import { Keys } from '@arkstack/encryption';
127
+
128
+ const identity = await Keys.generateSerializedPair();
129
+ // { publicKey: 'MFkwEwYH…', privateKey: 'MIGHAgEA…' }
130
+ ```
131
+
132
+ Both halves are base64url DER, so they survive JSON, headers, query strings and database columns unchanged, and import cleanly on the other runtime.
133
+
134
+ ### Secure channels
135
+
136
+ Each side combines its own private key with the other side's public key. Both arrive at the same AES-256-GCM key without it ever crossing the wire — the server can route the ciphertext without being able to read it.
137
+
138
+ ```ts
139
+ import { SecureChannel } from '@arkstack/encryption';
140
+
141
+ // In the browser, as Alice
142
+ const outbound = await SecureChannel.between(alice.privateKey, bobPublicKey);
143
+ const message = await outbound.encrypt('hey bob');
144
+
145
+ // On Bob's device
146
+ const inbound = await SecureChannel.between(bob.privateKey, alicePublicKey);
147
+ await inbound.decrypt(message); // "hey bob"
148
+ ```
149
+
150
+ Pass `info` to derive separate keys for separate purposes from the same pair of identities:
151
+
152
+ ```ts
153
+ const chat = await SecureChannel.between(alice.privateKey, bobPublicKey, {
154
+ info: `chat:${id}`,
155
+ });
156
+ const files = await SecureChannel.between(alice.privateKey, bobPublicKey, {
157
+ info: `files:${id}`,
158
+ });
159
+ ```
160
+
161
+ ### Sealed boxes
162
+
163
+ Encrypt to a public key with no identity of your own. A throwaway key pair is generated per message and its public half travels in the payload; only the recipient's private key can open the result — the sender cannot decrypt their own message afterwards.
164
+
165
+ ```ts
166
+ import { SealedBox } from '@arkstack/encryption';
167
+
168
+ const payload = await SealedBox.seal('anonymous tip', recipientPublicKey);
169
+
170
+ await SealedBox.open(payload, recipientPrivateKey); // "anonymous tip"
171
+ ```
172
+
173
+ ### Verifying a conversation
174
+
175
+ Key agreement protects against eavesdroppers, not against a server that hands each side the wrong public key. A safety number lets the participants rule that out over any channel they already trust.
176
+
177
+ ```ts
178
+ const number = await Keys.safetyNumber(alicePublicKey, bobPublicKey);
179
+ // "48213 90277 11408 63925 …"
180
+
181
+ await Keys.confirmSafetyNumber(alicePublicKey, bobPublicKey, scanned);
182
+ ```
183
+
184
+ The value is identical on both sides regardless of who initiated, and whitespace is ignored when confirming. `channel.safetyNumber()` returns the same string for an open channel.
185
+
186
+ ## Synchronous Node API
187
+
188
+ The Web Crypto API is asynchronous everywhere. When a synchronous call is genuinely needed on the server, `@arkstack/encryption/node` provides one that emits byte-identical payloads:
189
+
190
+ ```ts
191
+ import { NodeCipher } from '@arkstack/encryption/node';
192
+
193
+ const payload = NodeCipher.encrypt(
194
+ 'value',
195
+ NodeCipher.fromSecret(process.env.APP_KEY!),
196
+ );
197
+
198
+ NodeCipher.decrypt(payload, NodeCipher.fromSecret(process.env.APP_KEY!));
199
+ ```
200
+
201
+ It lives behind its own entry point so browser bundles never pull in `node:crypto`.
202
+
203
+ > `NodeCipher.resolve()` treats a base64url string of exactly 32 bytes as raw key material and anything else as a passphrase, mirroring `EncryptionKey.resolve()`. When a secret must always be hashed — as `APP_KEY` is — use `NodeCipher.fromSecret()`.
204
+
205
+ ## Wire formats
206
+
207
+ Both ciphers read and write the same payloads:
208
+
209
+ | Kind | Format |
210
+ | ---------- | ------------------------------------------------------- |
211
+ | Cipher | `<iv>:<authTag>:<ciphertext>` |
212
+ | Sealed box | `ark1:<ephemeralPublicKey>:<iv>:<authTag>:<ciphertext>` |
213
+
214
+ Every segment is unpadded base64url. The IV is 12 bytes, the GCM tag is 16 bytes.
215
+
216
+ ## Runtime requirements
217
+
218
+ A Web Crypto implementation on `globalThis.crypto`:
219
+
220
+ - **Node** 19+, or Node 18 with `globalThis.crypto` available.
221
+ - **Browsers** in a secure context (`https` or `localhost`).
222
+ - **Deno**, **Bun**, and Cloudflare/Deno-style workers out of the box.
223
+
224
+ The synchronous `/node` entry point requires Node.