@zerotal/arch 1.7.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Encryption & Hashing
|
|
3
|
+
description: Encrypt values symmetrically with APP_KEY, hash passwords one-way, and sign tamper-proof URLs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Encryption & Hashing
|
|
7
|
+
|
|
8
|
+
Three security primitives cover the ways an application protects a value, and
|
|
9
|
+
which one you want follows from a single question: do you need to read the value
|
|
10
|
+
back?
|
|
11
|
+
|
|
12
|
+
| Reach for | When | Reversible |
|
|
13
|
+
| ----------- | ---------------------------------------- | -------------------------------- |
|
|
14
|
+
| **`Crypt`** | Cookies, stored secrets, signed payloads | Yes — you decrypt it |
|
|
15
|
+
| **`Hash`** | Passwords | No — you verify a guess |
|
|
16
|
+
| **`Url`** | Verification, invite and one-time links | Not encrypted, only tamper-proof |
|
|
17
|
+
|
|
18
|
+
A password is never encrypted, only hashed: there is no legitimate reason to
|
|
19
|
+
recover the original, and a store you _can_ decrypt is a store an attacker can
|
|
20
|
+
decrypt too. A signed URL is the opposite case — its payload stays readable in the
|
|
21
|
+
query string, and the signature only proves nobody altered it.
|
|
22
|
+
|
|
23
|
+
All three are keyed by `APP_KEY`, which `bun create zerotal` generates. Rotate it
|
|
24
|
+
with `bun zt key:generate`.
|
|
25
|
+
|
|
26
|
+
## Getting Started
|
|
27
|
+
|
|
28
|
+
`Crypt` and the core `Hash` primitive live in `zerotal/security`, with
|
|
29
|
+
nothing to install and no provider to register:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { Crypt, Hash } from "zerotal/security";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`Url` is a core service exported from `zerotal/http`:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { Url } from "zerotal/http";
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> **Note** — Two different `Hash` symbols ship, and they are not interchangeable.
|
|
42
|
+
> `zerotal/security` exports the zero-config primitive documented here,
|
|
43
|
+
> usable anywhere including in a test that never boots the app.
|
|
44
|
+
> `@zerotal/auth` exports a facade of the same name that resolves the `hash`
|
|
45
|
+
> container binding, reads its algorithm from `config/auth.ts`, and is therefore
|
|
46
|
+
> only usable **after `app.boot()`**. Application code that already depends on
|
|
47
|
+
> auth should import from `@zerotal/auth` so the configured algorithm applies;
|
|
48
|
+
> anything lower-level should use the core primitive.
|
|
49
|
+
|
|
50
|
+
## Crypt — symmetric encryption
|
|
51
|
+
|
|
52
|
+
`Crypt` uses **AES-256-GCM** (authenticated encryption) keyed by `APP_KEY`.
|
|
53
|
+
Tampered or truncated payloads fail with a `DecryptionError` instead of returning
|
|
54
|
+
garbage. Generate a key with `zerotal key:generate` (sets `APP_KEY`).
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// in a controller or service
|
|
58
|
+
import { Crypt } from "zerotal/security";
|
|
59
|
+
|
|
60
|
+
const token = Crypt.encryptString("secret"); // opaque base64 (iv + tag + ciphertext)
|
|
61
|
+
Crypt.decryptString(token); // 'secret'
|
|
62
|
+
|
|
63
|
+
const blob = Crypt.encrypt({ userId: 7 }); // any JSON-serializable value
|
|
64
|
+
Crypt.decrypt<{ userId: number }>(blob).userId; // 7
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- `Crypt.setKey(key)` overrides the `APP_KEY`-derived key (accepts raw or
|
|
68
|
+
`base64:`-prefixed; any length — it's hashed to 32 bytes via SHA-256).
|
|
69
|
+
- Without a key, encryption throws `CryptKeyMissingError`.
|
|
70
|
+
- A new IV is generated per call, so encrypting the same value twice yields
|
|
71
|
+
different ciphertexts.
|
|
72
|
+
|
|
73
|
+
> **Danger** — `APP_KEY` is the root of all `Crypt` security. Rotating it makes
|
|
74
|
+
> every existing ciphertext undecryptable, and leaking it exposes every encrypted
|
|
75
|
+
> payload. Keep it out of version control and set it per environment — see
|
|
76
|
+
> [Config system](/docs/config-system).
|
|
77
|
+
|
|
78
|
+
## Hash — password hashing
|
|
79
|
+
|
|
80
|
+
`Hash` wraps `Bun.password` — **argon2id** by default, bcrypt optional. The
|
|
81
|
+
algorithm is auto-detected on verify, so changing the default never breaks
|
|
82
|
+
existing hashes.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// in a controller or service
|
|
86
|
+
import { Hash } from "zerotal/security";
|
|
87
|
+
|
|
88
|
+
const hash = await Hash.make("secret123"); // argon2id
|
|
89
|
+
const valid = await Hash.verify("secret123", hash); // true (alias: Hash.check)
|
|
90
|
+
|
|
91
|
+
await Hash.make("secret123", { algorithm: "bcrypt" }); // opt into bcrypt
|
|
92
|
+
Hash.setDefault("bcrypt"); // change the default
|
|
93
|
+
Hash.needsRehash(oldHash); // true if not the default algorithm
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`verify` returns `false` (never throws) for a malformed or non-matching hash.
|
|
97
|
+
Never store or compare plain-text passwords — `make` always returns a new,
|
|
98
|
+
salted hash, so two calls on the same password produce different strings.
|
|
99
|
+
|
|
100
|
+
> **Tip** — Call `Hash.needsRehash(hash)` right after a successful `verify` at
|
|
101
|
+
> login. If it returns `true`, re-hash the plaintext the user just supplied and
|
|
102
|
+
> store the new hash to transparently migrate them to the current algorithm.
|
|
103
|
+
|
|
104
|
+
### Which algorithm should I use?
|
|
105
|
+
|
|
106
|
+
`Hash.make` accepts `argon2id`, `argon2i`, `argon2d`, or `bcrypt`.
|
|
107
|
+
|
|
108
|
+
- **`argon2id` (default)** — the recommended choice for new applications; resists
|
|
109
|
+
both GPU and side-channel attacks. Keep it unless you have a specific reason not to.
|
|
110
|
+
- **`bcrypt`** — choose it for interop with an existing bcrypt-based password
|
|
111
|
+
store, or to match another system's hashes.
|
|
112
|
+
- **`argon2i` / `argon2d`** — niche variants; prefer `argon2id` unless a threat
|
|
113
|
+
model specifically calls for one.
|
|
114
|
+
|
|
115
|
+
Both algorithms verify correctly against their own stored hashes regardless of the
|
|
116
|
+
configured default, because the algorithm is encoded in the hash string itself.
|
|
117
|
+
That is what makes switching safe, and what makes a migration a matter of
|
|
118
|
+
rehashing on login rather than a mass update.
|
|
119
|
+
|
|
120
|
+
> **Danger** — bcrypt truncates at 72 bytes. If you must use bcrypt, it silently
|
|
121
|
+
> ignores input past 72 bytes, weakening long passwords — a non-issue for
|
|
122
|
+
> Argon2id, which is the default.
|
|
123
|
+
|
|
124
|
+
When authentication is in play the algorithm comes from `config/auth.ts` instead
|
|
125
|
+
of `setDefault`:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// config/auth.ts
|
|
129
|
+
import { AuthConfig } from "@zerotal/auth";
|
|
130
|
+
|
|
131
|
+
export default AuthConfig({
|
|
132
|
+
algorithm: "argon2id", // 'argon2id' (default) | 'bcrypt'
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Signed URLs
|
|
137
|
+
|
|
138
|
+
The `Url` facade generates and verifies HMAC-SHA256 signed URLs. Use it for:
|
|
139
|
+
|
|
140
|
+
- Email verification links
|
|
141
|
+
- Password reset links (if you roll your own instead of `PasswordBroker`)
|
|
142
|
+
- One-time invite links
|
|
143
|
+
- Any time-limited, tamper-proof URL
|
|
144
|
+
|
|
145
|
+
`Url.sign` / `Url.verify` sign with `APP_KEY` automatically, so you never have to
|
|
146
|
+
thread a signing key through your code.
|
|
147
|
+
|
|
148
|
+
### Generating a signed URL
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
function sign(
|
|
152
|
+
base: string,
|
|
153
|
+
params?: Record<string, string>,
|
|
154
|
+
expiresInMinutes?: number,
|
|
155
|
+
secret?: string,
|
|
156
|
+
): string;
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// in a controller
|
|
161
|
+
import { Url } from "zerotal/http";
|
|
162
|
+
|
|
163
|
+
const url = Url.sign(
|
|
164
|
+
"https://myapp.com/auth/verify",
|
|
165
|
+
{ email: "user@example.com", id: "42" }, // extra query params
|
|
166
|
+
60, // expires in 60 minutes (default)
|
|
167
|
+
);
|
|
168
|
+
// https://myapp.com/auth/verify?email=user%40example.com&id=42&expires=1750000000&signature=abc123…
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Pass a fourth argument to sign with a key other than `APP_KEY` (e.g. a
|
|
172
|
+
per-feature invite secret): `Url.sign(base, params, minutes, inviteSecret)`.
|
|
173
|
+
|
|
174
|
+
### Verifying a signed URL
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// in a controller
|
|
178
|
+
import { Url } from "zerotal/http";
|
|
179
|
+
|
|
180
|
+
const ok = Url.verify(url);
|
|
181
|
+
// false if tampered, expired, or missing signature/expires params
|
|
182
|
+
|
|
183
|
+
// Verify against an explicit secret instead of APP_KEY:
|
|
184
|
+
Url.verify(url, inviteSecret);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`Url.verify()` never throws — a malformed URL, bad signature, or expired link all
|
|
188
|
+
return `false`, so you can branch on the boolean directly.
|
|
189
|
+
|
|
190
|
+
### How the signature works
|
|
191
|
+
|
|
192
|
+
- The signature is `HMAC-SHA256(secret, payload)` where the payload is the full
|
|
193
|
+
URL (minus the `signature` param) with its query parameters **sorted by key** —
|
|
194
|
+
so the order of params in the link doesn't affect verification.
|
|
195
|
+
- `expires` is a Unix timestamp **in seconds**; `verify()` checks it before doing
|
|
196
|
+
any crypto, so expired links are rejected cheaply.
|
|
197
|
+
- Comparison is **constant-time**, preventing timing attacks against the
|
|
198
|
+
signature.
|
|
199
|
+
- `Url.sign` throws `UrlKeyMissingError` if no `APP_KEY` is set and no explicit
|
|
200
|
+
secret is passed — links are never signed with a blank or guessable key.
|
|
201
|
+
|
|
202
|
+
> **Danger** — Generate a strong `APP_KEY` with `zerotal key:generate` before
|
|
203
|
+
> signing anything in production. A weak or shared key lets anyone forge valid
|
|
204
|
+
> links.
|
|
205
|
+
|
|
206
|
+
### ValidateSignatureMiddleware
|
|
207
|
+
|
|
208
|
+
Apply `ValidateSignatureMiddleware` to any route that receives signed URLs — it
|
|
209
|
+
rejects invalid or expired links with a 403 JSON response, so your controller
|
|
210
|
+
only runs when the signature is good:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// routes/web.ts
|
|
214
|
+
import { ValidateSignatureMiddleware } from "@zerotal/auth";
|
|
215
|
+
|
|
216
|
+
// Reads the signing secret from config('app.key') (sourced from APP_KEY)
|
|
217
|
+
Router.get("/auth/verify", VerificationController, "verify", [ValidateSignatureMiddleware]);
|
|
218
|
+
|
|
219
|
+
// Override the secret for a specific route
|
|
220
|
+
Router.get("/invite/:token", InviteController, "accept", [
|
|
221
|
+
ValidateSignatureMiddleware.with({ secret: env("INVITE_SECRET", "") }),
|
|
222
|
+
]);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
> **Warning** — If neither `config('app.key')` nor an explicit `secret` resolves,
|
|
226
|
+
> the middleware throws a `ConfigError` rather than silently accepting forged
|
|
227
|
+
> links. Set `APP_KEY` or pass `.with({ secret })`.
|
|
228
|
+
|
|
229
|
+
Override `onInvalid()` to return a custom `Response` instead of the default 403
|
|
230
|
+
JSON:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// app/middleware/HtmlSignatureMiddleware.ts
|
|
234
|
+
import { ValidateSignatureMiddleware } from "@zerotal/auth";
|
|
235
|
+
import type { HttpContext } from "zerotal";
|
|
236
|
+
|
|
237
|
+
class HtmlSignatureMiddleware extends ValidateSignatureMiddleware {
|
|
238
|
+
protected onInvalid(_ctx: HttpContext): Response {
|
|
239
|
+
return new Response("This link is invalid or has expired.", {
|
|
240
|
+
status: 403,
|
|
241
|
+
headers: { "Content-Type": "text/html" },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Testing
|
|
248
|
+
|
|
249
|
+
Set your suite up once as described in [Testing](/docs/testing). `Crypt` needs an
|
|
250
|
+
`APP_KEY` and nothing else, so its tests are ordinary function tests.
|
|
251
|
+
|
|
252
|
+
**Assert the round trip, not the ciphertext.** Encryption is randomised — the
|
|
253
|
+
same input produces a different payload every time — so a test pinned to a
|
|
254
|
+
literal string fails on the next run:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
// tests/security/Crypt.test.ts
|
|
258
|
+
import { test, expect } from "bun:test";
|
|
259
|
+
import { Crypt } from "zerotal/security";
|
|
260
|
+
|
|
261
|
+
test("a value survives the round trip", () => {
|
|
262
|
+
const token = Crypt.encryptString("card-4242");
|
|
263
|
+
|
|
264
|
+
expect(token).not.toBe("card-4242"); // it is actually encrypted
|
|
265
|
+
expect(Crypt.decryptString(token)).toBe("card-4242");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("encrypting twice produces different payloads", () => {
|
|
269
|
+
expect(Crypt.encryptString("same")).not.toBe(Crypt.encryptString("same"));
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
That second test is the one worth having. If it ever fails, the cipher has lost
|
|
274
|
+
its randomness and identical plaintexts have become linkable — a real weakness
|
|
275
|
+
that a round-trip test alone would never notice.
|
|
276
|
+
|
|
277
|
+
**Tampering must throw**, and proving it is what tells you the payload is
|
|
278
|
+
authenticated rather than merely scrambled:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// tests/security/Crypt.test.ts
|
|
282
|
+
import { DecryptionError } from "zerotal/security";
|
|
283
|
+
|
|
284
|
+
test("a modified payload is rejected", () => {
|
|
285
|
+
const token = Crypt.encryptString("card-4242");
|
|
286
|
+
const tampered = token.slice(0, -2) + (token.endsWith("A") ? "B" : "A");
|
|
287
|
+
|
|
288
|
+
expect(() => Crypt.decryptString(tampered)).toThrow(DecryptionError);
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**`encrypt()` round-trips any JSON value**, so a test covering an object confirms
|
|
293
|
+
the serialisation as well as the cipher:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// tests/security/Crypt.test.ts
|
|
297
|
+
const payload = Crypt.encrypt({ userId: 7, scopes: ["read"] });
|
|
298
|
+
|
|
299
|
+
expect(Crypt.decrypt<{ userId: number }>(payload).userId).toBe(7);
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Hashing has its own trap: every `Hash` method except `needsRehash` is async, and a
|
|
303
|
+
forgotten `await` yields a `Promise`, which is always truthy — the one mistake in
|
|
304
|
+
this API that silently passes.
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
// tests/security/Hash.test.ts
|
|
308
|
+
import { Hash } from "zerotal/security";
|
|
309
|
+
|
|
310
|
+
test("a password verifies against its own hash", async () => {
|
|
311
|
+
const hash = await Hash.make("correct-horse");
|
|
312
|
+
|
|
313
|
+
expect(await Hash.check("correct-horse", hash)).toBe(true);
|
|
314
|
+
expect(await Hash.check("wrong-horse", hash)).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("the same password hashes differently each time", async () => {
|
|
318
|
+
expect(await Hash.make("same")).not.toBe(await Hash.make("same"));
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Assert the false case.** `expect(await Hash.check(...)).toBe(true)` passes
|
|
323
|
+
against a verifier that returns `true` for everything; the wrong-password
|
|
324
|
+
assertion is what proves it actually checks. And because hashes are salted, never
|
|
325
|
+
compare two of them — the same password hashes differently every time, which is
|
|
326
|
+
precisely the property that makes a stolen table unusable.
|
|
327
|
+
|
|
328
|
+
> **Warning** — Hashing is deliberately slow. A test that hashes in a loop, or a
|
|
329
|
+
> factory that hashes a password for every seeded user, will dominate your suite's
|
|
330
|
+
> runtime. Hash once and reuse the result, or seed users with a pre-computed hash.
|
|
331
|
+
|
|
332
|
+
One piece of environment setup applies to all of the above:
|
|
333
|
+
|
|
334
|
+
> **Warning** — Give the test suite its own `APP_KEY`. Sharing the production key
|
|
335
|
+
> with a test environment means a leaked test fixture decrypts real data, and
|
|
336
|
+
> rotating the key breaks the suite for reasons nobody will connect to the change.
|
|
337
|
+
|
|
338
|
+
## References
|
|
339
|
+
|
|
340
|
+
### `Crypt`
|
|
341
|
+
|
|
342
|
+
| Method | Signature | Description |
|
|
343
|
+
| --------------- | ----------------------------------- | --------------------------------------------------------------------------- |
|
|
344
|
+
| `encryptString` | `(plain: string): string` | Encrypt a UTF-8 string to an opaque base64 payload (iv + tag + ciphertext). |
|
|
345
|
+
| `decryptString` | `(payload: string): string` | Decrypt an `encryptString` payload; throws `DecryptionError` on failure. |
|
|
346
|
+
| `encrypt` | `(value: unknown): string` | JSON-serialize then encrypt any value. |
|
|
347
|
+
| `decrypt` | `<T = unknown>(payload: string): T` | Decrypt and JSON-parse a value produced by `encrypt`. |
|
|
348
|
+
| `setKey` | `(key: string): void` | Override the `APP_KEY`-derived key (raw or `base64:` prefixed). |
|
|
349
|
+
|
|
350
|
+
`CryptKeyMissingError` and `DecryptionError` are exported from `zerotal/security`.
|
|
351
|
+
|
|
352
|
+
### `Hash`
|
|
353
|
+
|
|
354
|
+
| Method | Signature | Description |
|
|
355
|
+
| ------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
356
|
+
| `make` | `(value: string, options?: { algorithm?: HashAlgorithm }): Promise<string>` | Hash a value; defaults to the current default algorithm. |
|
|
357
|
+
| `verify` | `(value: string, hash: string): Promise<boolean>` | Verify a value against a stored hash; returns `false` on mismatch. |
|
|
358
|
+
| `check` | `(value: string, hash: string): Promise<boolean>` | Alias of `verify`. |
|
|
359
|
+
| `setDefault` | `(algorithm: HashAlgorithm): void` | Set the default hashing algorithm. |
|
|
360
|
+
| `needsRehash` | `(hash: string): boolean` | `true` when the hash was made with a non-default algorithm. |
|
|
361
|
+
|
|
362
|
+
`HashAlgorithm` is `"argon2id" | "argon2i" | "argon2d" | "bcrypt"`.
|
|
363
|
+
|
|
364
|
+
The `@zerotal/auth` facade of the same name adds `selfTest()` — hashes and
|
|
365
|
+
verifies a sentinel value, for health checks — and resolves from the container, so
|
|
366
|
+
it works only after the app has booted. In a unit test that never boots, construct
|
|
367
|
+
`new HashService("argon2id")` directly or use the core primitive above.
|
|
368
|
+
|
|
369
|
+
### `Url`
|
|
370
|
+
|
|
371
|
+
| Method | Signature | Description |
|
|
372
|
+
| ---------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------- |
|
|
373
|
+
| `Url.sign(base, params?, min?, secret?)` | `(base: string, params?, expiresInMinutes?, secret?) => string` | Build an HMAC-signed, time-limited URL. |
|
|
374
|
+
| `Url.verify(url, secret?)` | `(signedUrl: string, secret?: string) => boolean` | Verify a signed URL; `false` if tampered or expired. |
|
|
375
|
+
|
|
376
|
+
## Next steps
|
|
377
|
+
|
|
378
|
+
- [Authentication](/docs/authentication) — where hashed passwords are verified at login.
|
|
379
|
+
- [Password Reset](/docs/password-reset) — the `Url` facade and `PasswordBroker` in a full flow.
|
|
380
|
+
- [Config system](/docs/config-system) — manage `APP_KEY` and other secrets per environment.
|