Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ New adapters can be added without affecting the application layer.

- `@arkstack/contract`: framework-agnostic driver contracts used by all kits.
- `@arkstack/common`: shared lifecycle/network helpers reused by all kits.
- `@arkstack/encryption`: isomorphic AES-256-GCM and ECDH primitives shared by server and browser.
- `@arkstack/console`: shared console runtime used by kits.

Each runtime kit (Express, H3, future Fastify/Bun) implements a framework-specific driver that conforms to the same contract.
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default defineConfig({
{ text: 'Helpers', link: '/guide/utilities/helpers' },
{ text: 'Hashing', link: '/guide/utilities/hashing' },
{ text: 'Encryption', link: '/guide/utilities/encryption' },
{ text: 'End-to-End Encryption', link: '/guide/utilities/e2e-encryption' },
{ text: 'Trait System', link: '/guide/utilities/trait-system' },
]
},
Expand Down
1 change: 1 addition & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Core shared packages:

- `@arkstack/contract`
- `@arkstack/common`
- `@arkstack/encryption`
- `@arkstack/console`
- `@arkstack/http`
- `@arkstack/auth`
Expand Down
172 changes: 172 additions & 0 deletions docs/guide/utilities/e2e-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# End-to-End Encryption

[`@arkstack/encryption`](https://www.npmjs.com/package/@arkstack/encryption) ships the primitives for content the server routes but cannot read: ECDH identities, shared-key channels between two of them, and anonymous sealed boxes.

Everything here is built on the Web Crypto API and runs unchanged in Node and the browser, which is the point — the private keys live on the clients, and the server only ever sees ciphertext.

Everything is also re-exported from `@arkstack/common`, so server code can import from either.

## Identities

An identity is an ECDH P-256 key pair. The public half is published, the private half never leaves its owner.

```ts
import { Keys } from '@arkstack/encryption';

const identity = await Keys.generateSerializedPair();
// { publicKey: 'MFkwEwYH…', privateKey: 'MIGHAgEA…' }
```

Both halves are base64url DER strings, safe to put in JSON, headers, or a database column:

```ts
await User.query().where({ id }).update({ publicKey: identity.publicKey });
```

::: warning
Store the private key on the client — a keychain, IndexedDB, or wrapped under a password with `Keys.derive()`. A private key that reaches the server ends the end-to-end guarantee.
:::

`KeyPair` gives you the full object model when you need it:

```ts
import { KeyPair } from '@arkstack/encryption';

const pair = await KeyPair.generate();
const serialized = await pair.export();

await KeyPair.fromPrivateKey(serialized.privateKey); // public key recovered from the private one
await KeyPair.fromPublicKey(peerPublicKey); // a peer, public half only
```

## Secure channels

A channel combines your private key with a peer's public key. Both sides derive the same AES-256-GCM key locally; the key itself never crosses the wire.

```ts
import { SecureChannel } from '@arkstack/encryption';

// Alice's device
const outbound = await SecureChannel.between(alice.privateKey, bobPublicKey);
const payload = await outbound.encrypt('hey bob');

// Bob's device
const inbound = await SecureChannel.between(bob.privateKey, alicePublicKey);
await inbound.decrypt(payload); // "hey bob"
```

Between the two, `payload` is just a string — persist it, queue it, broadcast it over [realtime](/guide/notifications) — the server has no key to open it with.

Use `info` to derive independent keys for independent purposes from the same pair of identities:

```ts
const chat = await SecureChannel.between(alice.privateKey, bobPublicKey, { info: `chat:${id}` });
const files = await SecureChannel.between(alice.privateKey, bobPublicKey, { info: `files:${id}` });
```

The derived key is available as `channel.key` if you want to cache it and skip the handshake later. It is exactly as sensitive as the messages themselves.

## Sealed boxes

A sealed box encrypts to a public key without a sender identity. Each message gets a throwaway key pair whose public half travels in the payload, so only the recipient's private key opens it — the sender cannot decrypt their own message afterwards.

```ts
import { SealedBox } from '@arkstack/encryption';

const payload = await SealedBox.seal('anonymous tip', recipientPublicKey);

await SealedBox.open(payload, recipientPrivateKey); // "anonymous tip"
```

Good for one-way drops: anonymous reports, invitations, or an inbox a sender should not be able to read back.

## Verifying a conversation

Key agreement stops an eavesdropper. It does not stop a server that hands each side the wrong public key — so give the participants a way to compare identities over a channel they already trust.

```ts
const number = await Keys.safetyNumber(alicePublicKey, bobPublicKey);
// "48213 90277 11408 63925 …"
```

The value is identical on both sides regardless of who initiated. Display it, or scan it, and confirm:

```ts
await Keys.confirmSafetyNumber(alicePublicKey, bobPublicKey, scanned); // constant time, whitespace ignored
```

An open channel exposes the same value directly:

```ts
await outbound.safetyNumber();
```

For a shorter check, fingerprints work on individual keys:

```ts
await Keys.fingerprintPublicKey(bobPublicKey);
// "3f8a1c02 9b4e7d15 c6a0ff31 2e5b8d94"

await outbound.fingerprint(); // digest of the derived shared key — identical on both ends
```

If a peer's fingerprint changes between sessions, their identity was replaced. Surface it.

## Comparing keys

Every comparison helper runs in constant time and returns `false` on malformed input rather than throwing:

```ts
Keys.compare(left, right); // symmetric keys
await Keys.matches(passphrase, key); // resolves both sides first
await Keys.samePublicKey(left, right); // identities, in any representation
```

## Wrapping a private key with a password

`Keys.derive()` stretches a password into a key with PBKDF2-HMAC-SHA256. Store the salt and iteration count next to the ciphertext — they are not secret.

```ts
import { Cipher, Keys } from '@arkstack/encryption';

const { key, salt, iterations } = await Keys.derive(password);
const wrapped = await Cipher.encrypt(identity.privateKey, key);

// Later, on any device
const { key: unwrapKey } = await Keys.derive(password, { salt, iterations });
const privateKey = await Cipher.decrypt(wrapped, unwrapKey);
```

## Putting it together

A minimal encrypted conversation:

```ts
// 1. Each user generates an identity once and publishes the public half.
const identity = await Keys.generateSerializedPair();

// 2. Opening a conversation, each side builds a channel to the other.
const channel = await SecureChannel.between(identity.privateKey, peer.publicKey, {
info: `conversation:${conversation.id}`,
});

// 3. Verify, once, out of band.
const safety = await channel.safetyNumber();

// 4. Send and receive ciphertext.
await api.post(`/conversations/${conversation.id}/messages`, {
body: await channel.encrypt(draft),
});

const body = await channel.decrypt(message.body);
```

The server stores `message.body` and never holds a key that opens it.

## Runtime requirements

A Web Crypto implementation on `globalThis.crypto`:

- **Node** 19+, or Node 18 with `globalThis.crypto` available.
- **Browsers** in a secure context (`https` or `localhost`).
- **Deno**, **Bun**, and workers out of the box.
73 changes: 73 additions & 0 deletions docs/guide/utilities/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

AES-256-GCM symmetric encryption for sensitive values (e.g. two-factor authentication secrets). Uses the application key, `APP_KEY` (`config('app.key')`).

`Encryption` is a thin wrapper around [`@arkstack/encryption`](https://www.npmjs.com/package/@arkstack/encryption), the framework's isomorphic encryption package. The wrapper binds the app key; the package underneath runs on the Web Crypto API, so **a value encrypted on the server can be decrypted in the browser and vice versa**.

For encrypting between two users rather than between the app and itself, see [End-to-End Encryption](/guide/utilities/e2e-encryption).

## `Encryption.encrypt(value)`

Encrypts a string. Returns a colon-delimited base64url string: `<iv>:<authTag>:<ciphertext>`.
Expand All @@ -22,6 +26,75 @@ const original = Encryption.decrypt(token);
// "my-secret-value"
```

Both methods take an optional second argument to encrypt under a key other than the application key:

```ts
Encryption.encrypt('my-secret-value', tenantKey);
```

## `Encryption.encryptAsync(value)` / `Encryption.decryptAsync(payload)`

The same operations on the Web Crypto path. They produce and consume the same payload format as the synchronous pair, so the two can be mixed freely — use these when the calling code is (or may become) shared with the browser.

```ts
const token = await Encryption.encryptAsync('my-secret-value');

await Encryption.decryptAsync(token);
```

Both accept an options object with `aad` — additional authenticated data that is not encrypted, but is bound to the ciphertext, so decryption fails unless the same value is supplied:

```ts
const token = await Encryption.encryptAsync(body, key, { aad: `conversation:${id}` });

await Encryption.decryptAsync(token, key, { aad: `conversation:${id}` });
```

## Decrypting in the browser

The cipher key is SHA-256 of `APP_KEY`. Client code reaches the same key from the same secret:

```ts
import { Cipher, EncryptionKey } from '@arkstack/encryption';

const key = await EncryptionKey.fromSecret(appKey);

await Cipher.decrypt(payloadFromServer, key);
```

::: warning
Shipping `APP_KEY` to a browser hands every client the key to everything the app encrypts. Do this only with a key scoped to that client — never the application key itself. When the goal is content the server cannot read, use [end-to-end encryption](/guide/utilities/e2e-encryption) instead.
:::

## Key utilities

```ts
Encryption.generateKey(); // random base64url key
await Encryption.compareKeys(left, right); // constant time comparison
await Encryption.deriveKey(password); // PBKDF2-HMAC-SHA256 → { key, salt, iterations }
await Encryption.fingerprint(); // displayable digest of the app key
```

`compareKeys` is constant time and returns `false` rather than throwing on malformed input.

## `Encryption.cipher(key?)`

Returns a `Cipher` bound to the application key (or an override), for encrypting many values without re-deriving the key each time, and for raw `Uint8Array` payloads via `encryptBytes` / `decryptBytes`.

```ts
const cipher = await Encryption.cipher();

const rows = await Promise.all(values.map((value) => cipher.encrypt(value)));
```

## Re-exports

The full `@arkstack/encryption` surface is available from `@arkstack/common`:

```ts
import { Cipher, Codec, EncryptionKey, KeyPair, Keys, SealedBox, SecureChannel } from '@arkstack/common';
```

**Environment variable:**

| Variable | Required | Description |
Expand Down
57 changes: 51 additions & 6 deletions packages/common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,9 +515,9 @@ Clears all registered hooks.

**`src/utils/encryption.ts`**

AES-256-GCM symmetric encryption for sensitive values (e.g. two-factor authentication secrets). Requires the `TWO_FACTOR_ENCRYPTION_KEY` environment variable.
A thin wrapper around [`@arkstack/encryption`](https://www.npmjs.com/package/@arkstack/encryption), bound to the application key. AES-256-GCM for sensitive values (e.g. two-factor authentication secrets); the package underneath runs on the Web Crypto API, so a value encrypted on the server can be decrypted in the browser and vice versa.

#### `Encryption.encrypt(value)`
#### `Encryption.encrypt(value, key?)`

Encrypts a string. Returns a colon-delimited base64url string: `<iv>:<authTag>:<ciphertext>`.

Expand All @@ -528,7 +528,9 @@ const token = Encryption.encrypt('my-secret-value');
// "abc123:def456:ghi789"
```

#### `Encryption.decrypt(payload)`
---

#### `Encryption.decrypt(payload, key?)`

Decrypts a payload produced by `encrypt`. Throws if the format is invalid or the key is wrong.

Expand All @@ -537,11 +539,54 @@ const original = Encryption.decrypt(token);
// "my-secret-value"
```

---

#### `Encryption.encryptAsync(value, key?, options?)` / `Encryption.decryptAsync(payload, key?, options?)`

The same operations on the Web Crypto path, in the same payload format, so the two can be mixed freely. Use these when the calling code is (or may become) shared with the browser. `options.aad` binds additional authenticated data to the ciphertext.

---

#### `Encryption.cipher(key?)`

A `Cipher` bound to the application key, for encrypting many values without re-deriving the key, and for raw bytes via `encryptBytes` / `decryptBytes`.

---

#### Key utilities

```ts
Encryption.generateKey(); // random base64url key
await Encryption.generateKeyPair(); // { publicKey, privateKey } ECDH identity
await Encryption.deriveKey(password); // PBKDF2 → { key, salt, iterations }
await Encryption.compareKeys(left, right); // constant time
await Encryption.fingerprint(); // displayable digest of the app key
```

---

#### End-to-end encryption

```ts
const channel = await Encryption.channel(myPrivateKey, peerPublicKey);

await channel.decrypt(await channel.encrypt('hey'));

await Encryption.seal('anonymous tip', peerPublicKey);
await Encryption.open(payload, myPrivateKey);

await Encryption.safetyNumber(myPublicKey, peerPublicKey);
```

The full `@arkstack/encryption` surface — `Cipher`, `Codec`, `EncryptionKey`, `KeyPair`, `Keys`, `SealedBox`, `SecureChannel`, `NodeCipher` — is re-exported from this package.

**Environment variable:**

| Variable | Required | Description |
| --------------------------- | -------- | ---------------------------------------------------------- |
| `TWO_FACTOR_ENCRYPTION_KEY` | Yes | Raw secret; hashed to a 256-bit key internally via SHA-256 |
| Variable | Required | Description |
| --------- | -------- | ---------------------------------------------------------- |
| `APP_KEY` | Yes | Raw secret; hashed to a 256-bit key internally via SHA-256 |

Generate one with `ark key:generate`. The legacy `TWO_FACTOR_ENCRYPTION_KEY` is still honored when `APP_KEY` is not set.

---

Expand Down
6 changes: 6 additions & 0 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"./package.json": "./package.json"
},
"dependencies": {
"@arkstack/encryption": "workspace:^",
"@pictwo/faker": "^1.1.0",
"bcryptjs": "^3.0.3",
"chalk": "^5.6.2",
Expand All @@ -63,5 +64,10 @@
"arkormx": {
"optional": true
}
},
"inlinedDependencies": {
"clear-router": "2.9.3",
"dayjs": "1.11.20",
"kanun": "1.2.0"
}
}
Loading
Loading