knowless 0.2.0 → 0.2.2
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 +185 -8
- package/GUIDE.md +193 -14
- package/README.md +99 -159
- package/knowless.context.md +190 -8
- package/package.json +1 -1
- package/src/handlers.js +91 -13
- package/src/index.js +105 -2
- package/src/mailer.js +75 -0
- package/src/store.js +14 -1
package/README.md
CHANGED
|
@@ -7,198 +7,146 @@ that don't need to email their users for anything but the sign-in link.
|
|
|
7
7
|
npm install knowless
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
> v0.2.
|
|
10
|
+
> v0.2.2 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Where to go next
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
fields, recovery email, federation. Even nominally privacy-focused
|
|
17
|
-
options store enough that a breach is materially harmful.
|
|
14
|
+
Two docs live alongside this README. They serve different readers; pick
|
|
15
|
+
the one that matches yours.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
| You are | Read this | What's there |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| **A human integrating for the first time** | [`GUIDE.md`](GUIDE.md) | Step-by-step walkthrough — install, generate the secret, set up Postfix, mount handlers, both modes worked end-to-end. Configuration reference, FAQ, troubleshooting. |
|
|
20
|
+
| **An AI agent, or reading in a hurry** | [`knowless.context.md`](knowless.context.md) | Dense single-file reference. Public API table, every option with defaults, 19 gotchas, lifecycle diagrams, the sham-work pattern, threat model, "what's NOT in knowless and why." Designed to fit one context window. |
|
|
21
|
+
| **Deploying to a real server** | [`OPS.md`](OPS.md) | Postfix install, SPF/DKIM/PTR/DMARC, null-route, systemd, Caddy/nginx/Traefik forward-auth, MailHog dev, fail2ban, multi-process. |
|
|
22
|
+
| **Tracking what changed** | [`CHANGELOG.md`](CHANGELOG.md) | Version history. |
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
The simpler answer that always worked: **magic link in, session
|
|
27
|
+
cookie out, nothing else stored.** Email is HMAC-hashed at the
|
|
21
28
|
boundary and discarded. The library refuses, by API shape, to send
|
|
22
29
|
anything but the sign-in link or store anything identifying.
|
|
23
30
|
|
|
31
|
+
Most auth libraries default to maximum identity collection: full email
|
|
32
|
+
in plaintext, profile fields, recovery email, federation. Even
|
|
33
|
+
nominally privacy-focused options store enough that a breach is
|
|
34
|
+
materially harmful. knowless inverts the default.
|
|
35
|
+
|
|
24
36
|
The thesis: most services have ten layers of auth tooling where they
|
|
25
37
|
need two.
|
|
26
38
|
|
|
27
|
-
##
|
|
28
|
-
|
|
29
|
-
Same library, two flows. They coexist in one app; choose per-endpoint
|
|
30
|
-
based on whether forcing a login *before* the action would harm UX.
|
|
39
|
+
## How it works
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
```
|
|
42
|
+
email → HMAC-SHA256(secret, normalize(email)) → opaque handle
|
|
43
|
+
| |
|
|
44
|
+
v v
|
|
45
|
+
magic-link token (256-bit, single-use) sessions, tokens
|
|
46
|
+
| |
|
|
47
|
+
v v
|
|
48
|
+
submitted via localhost SMTP stored as SHA-256 hashes
|
|
49
|
+
|
|
|
50
|
+
v
|
|
51
|
+
user clicks → handle resolved → signed cookie set
|
|
52
|
+
```
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
- **Plaintext email is never persisted.** Only the salted hash
|
|
55
|
+
(`HMAC-SHA256(secret, normalized_email)`).
|
|
56
|
+
- **Only the magic link is ever sent.** No welcome, no digest, no
|
|
57
|
+
notification. There is no API to send anything else.
|
|
58
|
+
- **All outbound mail goes via your localhost MTA.** No vendor SDKs,
|
|
59
|
+
no API tokens.
|
|
60
|
+
- **Tokens are SHA-256 at rest, single-use, 15-min TTL.** Raw token
|
|
61
|
+
never persisted.
|
|
62
|
+
- **Session cookies are HMAC-signed.** No JWT, no algorithm confusion.
|
|
63
|
+
- **Sham work on every miss.** Unknown emails do the same work as
|
|
64
|
+
registered ones (compose, submit, log) but the SMTP recipient is a
|
|
65
|
+
null-route. Times equivalent within 1ms — measured in CI.
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
- Magic link arrives, click → session cookie
|
|
39
|
-
- Your protected endpoints call `auth.handleFromRequest(req)` to gate
|
|
40
|
-
access
|
|
67
|
+
## Two modes
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
identified user at the moment of the action.
|
|
69
|
+
Same library, two flows. They coexist in one app — pick per action.
|
|
44
70
|
|
|
45
|
-
|
|
71
|
+
- **"Sign in, then do the thing"** — a normal login.
|
|
72
|
+
- **"Do the thing, confirm by email"** — drop a pin, post a comment,
|
|
73
|
+
share a link without an account, and the email confirmation creates
|
|
74
|
+
the account in the background.
|
|
46
75
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
session and "promotes" the deferred resource.
|
|
76
|
+
The same sham-work flow runs underneath either mode, so unknown
|
|
77
|
+
emails, rate-limit hits, and real sends look identical to an external
|
|
78
|
+
observer.
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
resources / anywhere logging in first kills the UX.
|
|
80
|
+
Worked code for both in [`GUIDE.md`](GUIDE.md).
|
|
54
81
|
|
|
55
|
-
|
|
56
|
-
unknown emails, rate-limit hits, and real sends look identical to an
|
|
57
|
-
external observer (the FR-6 timing-equivalence guarantee). Pick per
|
|
58
|
-
action; the two coexist.
|
|
82
|
+
## Two deployment shapes
|
|
59
83
|
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
| Shape | When |
|
|
85
|
+
|---|---|
|
|
86
|
+
| **Library mode** | Mount the five handlers (`login`, `callback`, `verify`, `logout`, `loginForm`) in your existing Node app. |
|
|
87
|
+
| **Standalone server** (`npx knowless-server`) | Forward-auth gateway behind Caddy / nginx / Traefik for self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / Jellyfin / etc. One auth subdomain, SSO across services via the parent-domain cookie. |
|
|
62
88
|
|
|
63
89
|
## What's opinionated (locked by design)
|
|
64
90
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
The library refuses, by API shape, to grow into them.
|
|
91
|
+
Deliberate trade-offs. The library refuses, by API shape, to grow
|
|
92
|
+
into them.
|
|
68
93
|
|
|
69
|
-
- **Localhost SMTP only.** No Mailgun/Postmark/SES/Resend.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
- **One mail purpose: the sign-in link.** No welcome message, no
|
|
73
|
-
digest, no notification. There is no `sendNotification()` to be
|
|
74
|
-
tempted by.
|
|
94
|
+
- **Localhost SMTP only.** No Mailgun / Postmark / SES / Resend.
|
|
95
|
+
- **One mail purpose: the sign-in link.** No `sendNotification()` to
|
|
96
|
+
be tempted by.
|
|
75
97
|
- **Plain-text 7-bit email.** No HTML, no tracking pixels, no
|
|
76
98
|
click-rewriting, no read-receipts.
|
|
77
99
|
- **No OAuth / OIDC / SAML.** Different audience.
|
|
78
100
|
- **No 2FA / WebAuthn / TOTP / passkeys.** Compose with a separate
|
|
79
101
|
library if you need them.
|
|
80
102
|
- **No admin UI.** `sqlite3 knowless.db` is the admin UI.
|
|
81
|
-
- **Hardcoded login form.** No template overrides; fork or live
|
|
82
|
-
|
|
83
|
-
- **No telemetry, analytics, or error reporting.**
|
|
84
|
-
|
|
103
|
+
- **Hardcoded login form.** No template overrides; fork or live with
|
|
104
|
+
it.
|
|
105
|
+
- **No telemetry, analytics, or error reporting.** No phone-home of
|
|
106
|
+
any kind.
|
|
85
107
|
- **Walks away at v1.0.0.** Maintenance mode after that — only
|
|
86
108
|
security fixes.
|
|
87
109
|
|
|
88
|
-
|
|
110
|
+
If any of those break your case, knowless isn't the right tool. Look
|
|
111
|
+
at [Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
|
|
112
|
+
or commercial offerings.
|
|
89
113
|
|
|
90
|
-
|
|
91
|
-
config or injection.
|
|
92
|
-
|
|
93
|
-
| Knob | Default | Common reasons to change |
|
|
94
|
-
|---|---|---|
|
|
95
|
-
| `dbPath` | `./knowless.db` | Move to `/var/lib/knowless/...` for systemd; share across processes |
|
|
96
|
-
| `smtpHost`, `smtpPort` | `localhost`, `25` | Point at MailHog (`localhost:1025`) for dev mail inspection |
|
|
97
|
-
| `cookieDomain` | hostname of `baseUrl` | Set to your eTLD+1 for SSO across subdomains |
|
|
98
|
-
| `cookieSecure` | `true` | `false` only for `http://localhost` dev (logs a warning) |
|
|
99
|
-
| `tokenTtlSeconds`, `sessionTtlSeconds` | `900`, `2592000` | Tighten for high-security uses; loosen at your peril |
|
|
100
|
-
| `openRegistration` | `false` | `true` to let any new email auto-register on first link |
|
|
101
|
-
| `subject` | `Sign in` | Match your brand; per-call override on `startLogin` (`subjectOverride`) |
|
|
102
|
-
| `bodyFooter` | none | Append a constant brand/legal/feedback line to every magic-link mail |
|
|
103
|
-
| `confirmationMessage` | (default copy) | Replace the post-submit "we'll email you" text |
|
|
104
|
-
| `maxLoginRequestsPerIpPerHour`, `maxNewHandlesPerIpPerHour` | `30`, `3` | Raise for genuinely shared NATs; `0` to disable in dev |
|
|
105
|
-
| `trustedProxies` | `[127.0.0.1, ::1]` | Plain IPs **and** CIDRs (`10.0.0.0/8`) for k8s/docker/cgnat |
|
|
106
|
-
| `bypassRateLimit` (per-call) | `false` | Trusted CLI/cron callers via `auth.startLogin` |
|
|
107
|
-
| `store` | built-in `node:sqlite` | Inject your own store (Postgres, etc.) |
|
|
108
|
-
| `mailer` | built-in nodemailer | Inject your own mailer |
|
|
109
|
-
| `transportOverride` | none | Pass a custom `nodemailer.createTransport` |
|
|
110
|
-
| `onSweepError(err)` | none | Operator alerting hook for sweeper failures |
|
|
111
|
-
| `devLogMagicLinks` | `false` | `true` in dev: print magic-link URLs (or silent-miss hints) to stderr when SMTP fails |
|
|
112
|
-
|
|
113
|
-
Full table with defaults, types, and validation rules:
|
|
114
|
-
[`GUIDE.md`](GUIDE.md) → "Configuration reference."
|
|
115
|
-
|
|
116
|
-
## Two deployment shapes (one codebase)
|
|
117
|
-
|
|
118
|
-
| Mode | Status | When |
|
|
119
|
-
|---|---|---|
|
|
120
|
-
| **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
|
|
121
|
-
| **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. behind Caddy / nginx / Traefik |
|
|
122
|
-
|
|
123
|
-
Library mode is the six-line example in [`GUIDE.md`](GUIDE.md).
|
|
124
|
-
Standalone server is `npx knowless-server` — full Postfix + DNS +
|
|
125
|
-
reverse-proxy walkthrough in [`OPS.md`](OPS.md).
|
|
114
|
+
## Operator commitments
|
|
126
115
|
|
|
127
|
-
|
|
116
|
+
By choosing knowless, you commit to running:
|
|
128
117
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
118
|
+
- **Postfix** (or another MTA) on the same host, outbound-only
|
|
119
|
+
- **SPF, DKIM, PTR** records for your sending domain
|
|
120
|
+
- **Outbound port 25** open (some clouds block it)
|
|
121
|
+
- A **null-route** for the configured `shamRecipient` so silent-miss
|
|
122
|
+
sham mail drops, not bounces
|
|
132
123
|
|
|
133
|
-
-
|
|
134
|
-
inbound CLI, login plumbing, pin-confirmation state machine, email
|
|
135
|
-
fingerprinting helpers, the matching test files)
|
|
136
|
-
- **~35 lines of knowless wiring added**
|
|
137
|
-
- **~33× reduction** on the auth/mail surface
|
|
138
|
-
- **One production dep** (`nodemailer` only; v0.2.0 dropped
|
|
139
|
-
`better-sqlite3` for `node:sqlite`, the stdlib SQLite driver — no
|
|
140
|
-
C++ toolchain, no native compile, ~40 transitive packages → 2)
|
|
124
|
+
Step-by-step in [`OPS.md`](OPS.md).
|
|
141
125
|
|
|
142
|
-
|
|
143
|
-
that drove v0.1.5 → v0.1.10. See [`docs/01-product/PRD.md`](docs/01-product/PRD.md)
|
|
144
|
-
§17 for the full backlog.
|
|
126
|
+
## Threat model — one paragraph
|
|
145
127
|
|
|
146
|
-
|
|
128
|
+
**Defends well:** DB-only leaks (handles are HMAC-salted),
|
|
129
|
+
plaintext-email exfiltration (none persisted), password reuse (no
|
|
130
|
+
passwords), silent email enumeration via the login form (timing-
|
|
131
|
+
equivalent + same response shape), email-bombing a target (per-handle
|
|
132
|
+
token cap), naive bots (honeypot), account-creation spam (per-IP
|
|
133
|
+
caps), replay attacks (atomic mark-token-used), open redirects
|
|
134
|
+
(`next_url` whitelist), CSRF on POST endpoints (Origin/Referer
|
|
135
|
+
whitelist).
|
|
147
136
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
for outbound-only mail
|
|
152
|
-
- Setting up **SPF, DKIM, and PTR** for your sending domain
|
|
153
|
-
- Verifying **outbound port 25** is open (some clouds block it)
|
|
154
|
-
- A **null-route entry** for the configured `shamRecipient` so
|
|
155
|
-
silent-miss sham mail is dropped, not bounced
|
|
156
|
-
- Accepting that the magic link is the **only email** your service
|
|
157
|
-
ever sends
|
|
158
|
-
|
|
159
|
-
Step-by-step in [`OPS.md`](OPS.md): Postfix install, null-route,
|
|
160
|
-
SPF/DKIM/PTR/DMARC, systemd unit, Caddy / nginx / Traefik
|
|
161
|
-
forward-auth examples, Tailscale pattern, reverse-proxy rate
|
|
162
|
-
limiting, fail2ban / Turnstile, multi-process deployments, MailHog
|
|
163
|
-
dev workflow, backups.
|
|
164
|
-
|
|
165
|
-
## Documentation
|
|
166
|
-
|
|
167
|
-
- [**`GUIDE.md`**](GUIDE.md) — start here. Adopter walkthrough,
|
|
168
|
-
install, six-line example, both modes worked end-to-end,
|
|
169
|
-
configuration reference, FAQ, troubleshooting.
|
|
170
|
-
- [**`knowless.context.md`**](knowless.context.md) — dense reference
|
|
171
|
-
for AI agents and humans-in-a-hurry. Public API table, all options,
|
|
172
|
-
18 gotchas, lifecycles, the sham-work pattern, threat model
|
|
173
|
-
summary.
|
|
174
|
-
- [`OPS.md`](OPS.md) — operator setup, fresh VPS to working forward-auth.
|
|
175
|
-
- [`CHANGELOG.md`](CHANGELOG.md) — version history.
|
|
176
|
-
- [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
|
|
177
|
-
requirements, threat model, decisions log, NO-GO table, audit
|
|
178
|
-
findings backlog.
|
|
179
|
-
- [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) — wire formats,
|
|
180
|
-
algorithms, byte layouts (reimplementation-grade).
|
|
181
|
-
|
|
182
|
-
## Threat model (one-paragraph)
|
|
183
|
-
|
|
184
|
-
Honest version (full detail in [PRD §12](docs/01-product/PRD.md)):
|
|
185
|
-
|
|
186
|
-
**Defends well:** DB-only leaks, plaintext-email exfiltration, password
|
|
187
|
-
reuse / credential stuffing, silent email enumeration (timing-
|
|
188
|
-
equivalent within 1ms locally), email-bombing a target, naive bot
|
|
189
|
-
traffic, account-creation spam, replay attacks, open redirects, CSRF
|
|
190
|
-
on `POST /login` / `POST /logout` (Origin/Referer whitelist).
|
|
191
|
-
|
|
192
|
-
**Defends partially:** HMAC-secret-only leak (allows targeted
|
|
193
|
-
existence checks but not session forgery), phishing (no password to
|
|
194
|
-
type into a fake site, but a phished mailbox still receives links).
|
|
137
|
+
**Partially:** HMAC-secret-only leak (allows targeted existence
|
|
138
|
+
checks but not session forgery), phishing (no password to type into a
|
|
139
|
+
fake site, but a phished mailbox still receives links).
|
|
195
140
|
|
|
196
141
|
**Does NOT defend against:** sophisticated bots that bypass the
|
|
197
142
|
honeypot, distributed floods from many IPs, full server compromise,
|
|
198
|
-
compromised email accounts, social engineering, insider threat at
|
|
199
|
-
|
|
200
|
-
rate-limits) belong above the library
|
|
201
|
-
|
|
143
|
+
compromised email accounts, social engineering, insider threat at the
|
|
144
|
+
operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
|
|
145
|
+
rate-limits) belong above the library — patterns in
|
|
146
|
+
[`OPS.md`](OPS.md).
|
|
147
|
+
|
|
148
|
+
Full detail in [`knowless.context.md`](knowless.context.md) §
|
|
149
|
+
"Threat model summary."
|
|
202
150
|
|
|
203
151
|
## Sibling projects
|
|
204
152
|
|
|
@@ -207,14 +155,6 @@ covers the patterns.
|
|
|
207
155
|
- [`gitdone`](https://github.com/hamr0/gitdone) — verified email
|
|
208
156
|
actions via DKIM/SPF inbound
|
|
209
157
|
|
|
210
|
-
## Contributing
|
|
211
|
-
|
|
212
|
-
Issues and PRs welcome at <https://github.com/hamr0/knowless>.
|
|
213
|
-
|
|
214
|
-
Per the v1.0.0 walk-away framing in PRD §6.3: feature requests after
|
|
215
|
-
v1.0.0 ships will be deflected to the [§14 NO-GO table](docs/01-product/PRD.md)
|
|
216
|
-
or to sibling projects. The library being "done" is a feature.
|
|
217
|
-
|
|
218
158
|
## License
|
|
219
159
|
|
|
220
160
|
[Apache 2.0](LICENSE) with [`NOTICE`](NOTICE) preservation. Forks
|
package/knowless.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# knowless -- Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring knowless into a project.
|
|
4
|
-
> v0.2.
|
|
4
|
+
> v0.2.2 | Node.js >= 22.5 | 1 dep (nodemailer) | Apache-2.0
|
|
5
5
|
|
|
6
6
|
## What this is
|
|
7
7
|
|
|
@@ -122,6 +122,17 @@ const auth = knowless({
|
|
|
122
122
|
sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
|
|
123
123
|
onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
|
|
124
124
|
|
|
125
|
+
// --- Operator visibility (v0.2.1, all opt-in) ---
|
|
126
|
+
// Per-event hooks. Errors swallowed (matches onSweepError contract).
|
|
127
|
+
onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
|
|
128
|
+
onTransportFailure: ({error, timestamp}) => { /* */ },
|
|
129
|
+
// Heartbeat aggregate. Default 60s; emits even when both counters
|
|
130
|
+
// are zero. See "Operator visibility" section for the threat-model
|
|
131
|
+
// reasoning behind aggregating sham + rate-limit branches here
|
|
132
|
+
// rather than emitting per-event.
|
|
133
|
+
onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
|
|
134
|
+
suppressionWindowMs: 60_000,
|
|
135
|
+
|
|
125
136
|
// --- Dev mode (AF-6.2) ---
|
|
126
137
|
// When SMTP submission fails AND this flag is true, the magic link
|
|
127
138
|
// is printed to stderr so a developer can click through. Off by
|
|
@@ -149,9 +160,10 @@ const auth = knowless({
|
|
|
149
160
|
| `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
|
|
150
161
|
| `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
|
|
151
162
|
| `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
|
|
152
|
-
| `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
|
|
163
|
+
| `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?, bodyOverride?, bypassRateLimit?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. `subjectOverride` (AF-9) replaces `cfg.subject` per call. `bodyOverride` (AF-26, v0.2.2) is a `({url}) => string` template fn that replaces the default body — knowless still composes the URL and validates the rendered output (ASCII, URL on its own line, ≤2048 chars); `bodyFooter` still appends. `bypassRateLimit: true` (AF-10) opts trusted server-side callers (CLI, cron, worker) out of IP-rate-limit accounting. SPEC §7.3a. AF-7.3. |
|
|
153
164
|
| `deriveHandle` | (email: string) | string | `HMAC-SHA256(secret, normalize(email))` using the configured secret. Normalizes input (lowercase + trim) so `Alice@X.com` and `alice@x.com` produce the same handle. Match what `startLogin` and `POST /login` compute. AF-7.4 / AF-13. |
|
|
154
165
|
| `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
|
|
166
|
+
| `verifyTransport` | -- | Promise\<true\> | Probe the configured SMTP transport (v0.2.1). Resolves true on success, rejects with the underlying error. Adopters call this explicitly when they want fail-fast on misconfigured SMTP at boot — no auto-on-boot variant by design (k8s readiness probes / docker-compose ordering would fail boot for the wrong reason). AF-20. |
|
|
155
167
|
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
156
168
|
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
|
157
169
|
|
|
@@ -166,6 +178,7 @@ import {
|
|
|
166
178
|
composeBody, // pure: build the mail body
|
|
167
179
|
validateSubject, // pure: validate operator-supplied subject
|
|
168
180
|
validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
|
|
181
|
+
validateBodyOverride, // pure: validate per-call body override (AF-26)
|
|
169
182
|
renderLoginForm, // pure: HTML5 page rendering
|
|
170
183
|
normalize, // pure: email normalization
|
|
171
184
|
deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
|
|
@@ -173,6 +186,91 @@ import {
|
|
|
173
186
|
} from 'knowless';
|
|
174
187
|
```
|
|
175
188
|
|
|
189
|
+
## Operator visibility (v0.2.1)
|
|
190
|
+
|
|
191
|
+
Three event hooks + one opt-in method, shipped in v0.2.1. Future
|
|
192
|
+
contributors reading this section before extending the surface: do not
|
|
193
|
+
add a per-event `onShamHit`, do not add a per-handle `onRateLimitHit`,
|
|
194
|
+
do not add an auto-on-boot probe, do not add a `lookupMessageId()`
|
|
195
|
+
endpoint. Each was considered and deliberately rejected during the
|
|
196
|
+
forum + addypin negotiation that produced this surface (PRD §17.3,
|
|
197
|
+
v0.2.1) — see "What's NOT in knowless" below for the reasoning.
|
|
198
|
+
|
|
199
|
+
### Three hooks (factory options)
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
const auth = knowless({
|
|
203
|
+
// ...required + existing options...
|
|
204
|
+
|
|
205
|
+
// Per-event, safe to log per-call.
|
|
206
|
+
onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
|
|
207
|
+
onTransportFailure: ({error, timestamp}) => { /* */ },
|
|
208
|
+
|
|
209
|
+
// Batched aggregate. Fires every windowMs regardless of count
|
|
210
|
+
// (heartbeat). Default cadence 60s.
|
|
211
|
+
onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
|
|
212
|
+
suppressionWindowMs: 60_000,
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Field types:
|
|
217
|
+
- `messageId`: string — SMTP `Message-ID` returned by nodemailer
|
|
218
|
+
- `handle`: string — 64-char hex; only emitted on real (non-sham) submits
|
|
219
|
+
- `timestamp`: number — epoch ms
|
|
220
|
+
- `error`: Error
|
|
221
|
+
- `sham`, `rateLimited`: integer counters, count within the window
|
|
222
|
+
- `windowMs`: integer — the configured window length, echoed in the payload
|
|
223
|
+
|
|
224
|
+
Errors thrown from hooks are caught and swallowed (matches the existing
|
|
225
|
+
`onSweepError` contract); knowless does not depend on hook delivery for
|
|
226
|
+
correctness.
|
|
227
|
+
|
|
228
|
+
### Method
|
|
229
|
+
|
|
230
|
+
`auth.verifyTransport()` — wraps `transport.verify()` on the configured
|
|
231
|
+
SMTP transport. Returns `Promise<true>` on success, rejects with the
|
|
232
|
+
underlying error. Adopters call this explicitly when they want fail-fast
|
|
233
|
+
on misconfigured SMTP at boot. **No auto-on-boot variant** by design:
|
|
234
|
+
deployments where knowless starts before Postfix (docker-compose
|
|
235
|
+
ordering, k8s readiness probes) would fail boot for the wrong reason.
|
|
236
|
+
|
|
237
|
+
### Threat-model justification (the durable part)
|
|
238
|
+
|
|
239
|
+
The two silent-202 branches — sham (handle does not exist) and rate-limit
|
|
240
|
+
(any of the three caps) — are aggregated rather than per-event because
|
|
241
|
+
**NFR-10 timing equivalence applies at the log layer too**, not just the
|
|
242
|
+
HTTP response. A per-event `onShamHit({handle})` lets a careless adopter
|
|
243
|
+
log "sham detected for X" and the log file becomes an enumeration oracle
|
|
244
|
+
— the exact thing sham-work was designed to prevent. The response is
|
|
245
|
+
silent; the log must be silent too.
|
|
246
|
+
|
|
247
|
+
Knowless has three rate limits, and one of them is identity-tied:
|
|
248
|
+
- `maxLoginRequestsPerIpPerHour` — IP-keyed
|
|
249
|
+
- `maxNewHandlesPerIpPerHour` — IP-keyed
|
|
250
|
+
- `maxActiveTokensPerHandle` — **handle-keyed; per-event hits leak
|
|
251
|
+
"this handle exists and has hit a token cap"**
|
|
252
|
+
|
|
253
|
+
Splitting per-event-IP from per-event-handle works in theory and fails
|
|
254
|
+
in practice — future contributor sees the asymmetry and adds the missing
|
|
255
|
+
handle variant for symmetry. Bundling all three into the windowed
|
|
256
|
+
aggregate forecloses that drift.
|
|
257
|
+
|
|
258
|
+
`onMailerSubmit` carries `handle` per-event because it fires *only on
|
|
259
|
+
real submissions*, where the handle was already disclosed to knowless
|
|
260
|
+
by the form input. Emitting it back to the adopter is not a new leak.
|
|
261
|
+
`onTransportFailure` carries no identity data, per-event safe.
|
|
262
|
+
|
|
263
|
+
### Why no `lookupMessageId()` endpoint
|
|
264
|
+
|
|
265
|
+
An earlier proposal added an authenticated `auth.lookupMessageId(id)`
|
|
266
|
+
behind an operator secret so operators could correlate maillog entries
|
|
267
|
+
to handles. Rejected: the same capability is achievable by the adopter
|
|
268
|
+
maintaining their own `(messageId → handle)` map, populated from
|
|
269
|
+
`onMailerSubmit`. Knowless never stores the mapping, never exposes a
|
|
270
|
+
new authenticated surface, never carries operator-secret rotation
|
|
271
|
+
burden. The hook is the mechanism; the correlation map is adopter
|
|
272
|
+
choice.
|
|
273
|
+
|
|
176
274
|
## Handle / token / session lifecycles
|
|
177
275
|
|
|
178
276
|
```
|
|
@@ -266,6 +364,21 @@ transport_maps = hash:/etc/postfix/transport
|
|
|
266
364
|
postmap /etc/postfix/transport && systemctl reload postfix
|
|
267
365
|
```
|
|
268
366
|
|
|
367
|
+
Verify the null-route is actually discarding (one-line operator
|
|
368
|
+
check, no knowless code involved):
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
swaks --to null@knowless.invalid --server localhost:25 --quit-after RCPT
|
|
372
|
+
journalctl -u postfix --since '1 minute ago' | grep 'discard'
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
A `discard:` line in the postfix log confirms the null-route caught
|
|
376
|
+
the message. If you see `relay=` or `delivered`, the
|
|
377
|
+
`transport_maps` entry isn't being applied — re-run `postmap` and
|
|
378
|
+
`systemctl reload postfix`. (No `--check-null-route` CLI in
|
|
379
|
+
knowless: an operator's MTA validation lives with the MTA, not
|
|
380
|
+
inside a walk-away library.)
|
|
381
|
+
|
|
269
382
|
## FR-6: timing equivalence (the testable property)
|
|
270
383
|
|
|
271
384
|
The library ships a CI test (`test/integration/timing.test.js`)
|
|
@@ -352,6 +465,66 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
|
|
|
352
465
|
| `src/session.js` | ~80 | Cookie signing/verification with constant-time compare |
|
|
353
466
|
| `src/form.js` | ~110 | Hardcoded login HTML |
|
|
354
467
|
|
|
468
|
+
## What's NOT in knowless, and why
|
|
469
|
+
|
|
470
|
+
Three capabilities that look like they belong here but don't, listed
|
|
471
|
+
because the "why not" needs to outlast walk-away-at-v1.0.0. When future
|
|
472
|
+
contributors propose adding any of these back, point them here.
|
|
473
|
+
|
|
474
|
+
### Disposable-domain blocking — adopter / form handler
|
|
475
|
+
|
|
476
|
+
Reject `mailinator.com` etc. before knowless sees the submission.
|
|
477
|
+
Mechanism + list + override + weekly cron all live in the adopter's
|
|
478
|
+
form handler.
|
|
479
|
+
|
|
480
|
+
The argument for putting this in knowless was timing equivalence: if
|
|
481
|
+
the adopter rejects fast, an attacker times the response and learns
|
|
482
|
+
"this domain is on a public blocklist." Counter: the blocklist is a
|
|
483
|
+
public GitHub repo (`disposable-email-domains/disposable-email-domains`).
|
|
484
|
+
Anyone can fetch it directly. Timing-equivalence here protects information
|
|
485
|
+
that isn't secret. Knowless's sham-work protects against email
|
|
486
|
+
*enumeration* (is `alice@x.com` registered?), not domain *classification*
|
|
487
|
+
(is `x.com` on a public list?). Different threat, different defense.
|
|
488
|
+
|
|
489
|
+
Splitting mechanism (knowless) from policy + list curation (adopter) is
|
|
490
|
+
the wrong seam. Both stay in the adopter's form handler.
|
|
491
|
+
|
|
492
|
+
### App-tenure / account-age — adopter / first-seen tracking
|
|
493
|
+
|
|
494
|
+
Knowless's "handle creation date" is when this email first hit knowless.
|
|
495
|
+
The adopter's interesting question is "how long has this user been
|
|
496
|
+
participating in *my app*" — a different number, and the adopter's
|
|
497
|
+
number is the one that should drive trust decisions.
|
|
498
|
+
|
|
499
|
+
Concrete failure mode: a handle registered with knowless six months ago
|
|
500
|
+
but never posted has zero app-tenure. If the adopter reads knowless's
|
|
501
|
+
age, a brand-new spammer with an old handle gets unearned credibility.
|
|
502
|
+
|
|
503
|
+
Pattern: adopter stores `(handle, first_seen_at)` the first time it sees
|
|
504
|
+
a handle perform a meaningful action. App-tenure is app-derived. Knowless
|
|
505
|
+
doesn't expose age data — and wouldn't even if it could, because
|
|
506
|
+
returning `Date | null` keyed by handle is itself an enumeration leak.
|
|
507
|
+
|
|
508
|
+
### Per-IP hashcash / proof-of-work — Caddy / perimeter layer
|
|
509
|
+
|
|
510
|
+
`maxNewHandlesPerIpPerHour: 3` already covers the ground hashcash would
|
|
511
|
+
cover. A botnet that can't get past three signups per IP per hour needs
|
|
512
|
+
IP rotation regardless; once rotated, a 2s hashcash is rounding error
|
|
513
|
+
at botnet economics. Costs are real: breaks Lynx/w3m (gotcha #10),
|
|
514
|
+
requires JS in the login form (the only zero-JS exception we'd carry),
|
|
515
|
+
~2s UX delay for legit users on weak devices. If a deployment observes
|
|
516
|
+
per-IP signup actually saturating the cap, Caddy (or another perimeter
|
|
517
|
+
layer) can run hashcash off-the-shelf without making knowless carry it.
|
|
518
|
+
|
|
519
|
+
### The deciding lens
|
|
520
|
+
|
|
521
|
+
knowless walks away at v1.0.0 (PRD §6.3). Every config option carried
|
|
522
|
+
into v1.0.0 is something v1.x has to keep stable through the
|
|
523
|
+
maintenance window. The test for any proposed addition: does this
|
|
524
|
+
belong in the **identity layer** (who they are) or the **behavior
|
|
525
|
+
layer** (what they did)? Identity layer is in scope. Behavior layer is
|
|
526
|
+
out. When unsure, default out — less surface, less carrying cost.
|
|
527
|
+
|
|
355
528
|
## Threat model summary
|
|
356
529
|
|
|
357
530
|
**Defends well:** DB-only leaks (handles are HMAC-salted),
|
|
@@ -462,12 +635,12 @@ rate-limits) belongs above the library.
|
|
|
462
635
|
`console.warn` if it sees `Content-Length > 0` with an empty
|
|
463
636
|
body. AF-7.1.
|
|
464
637
|
|
|
465
|
-
16. **Two adoption modes — Mode B
|
|
466
|
-
(
|
|
467
|
-
Mode A is `auth.startLogin({email, nextUrl,
|
|
468
|
-
"drop a pin, claim by email click" patterns.
|
|
469
|
-
identical 12-step sham-work flow; same FR-6
|
|
470
|
-
per-action, not per-app.
|
|
638
|
+
16. **Two adoption modes — "sign in, then do" (Mode B) and "do
|
|
639
|
+
then confirm by email" (Mode A).** Mode B is the form
|
|
640
|
+
(`auth.login`). Mode A is `auth.startLogin({email, nextUrl,
|
|
641
|
+
sourceIp})` for "drop a pin, claim by email click" patterns.
|
|
642
|
+
Both run the identical 12-step sham-work flow; same FR-6
|
|
643
|
+
guarantee. Pick per-action, not per-app.
|
|
471
644
|
|
|
472
645
|
17. **Secret is hex-decoded (AF-8.1, since v0.1.6).** Pass a
|
|
473
646
|
64-char lowercase hex string; knowless decodes to 32 raw bytes
|
|
@@ -482,6 +655,15 @@ rate-limits) belongs above the library.
|
|
|
482
655
|
factory startup; fails fast. Goes after RFC 3676 `"-- "`
|
|
483
656
|
delimiter so mail clients strip it from quoted replies.
|
|
484
657
|
|
|
658
|
+
19. **`startLogin` is silent at every layer (FR-6).** Returns
|
|
659
|
+
`{handle, submitted: true}` for *every* branch — real send, sham,
|
|
660
|
+
rate-limited, missing-handle-with-`openRegistration:false`. Adopters
|
|
661
|
+
cannot derive the branch from the return value, by design.
|
|
662
|
+
Operator visibility comes from the v0.2.1 hooks (`onMailerSubmit`
|
|
663
|
+
per-event, `onSuppressionWindow` aggregated) — *not* from the
|
|
664
|
+
return shape. Don't wrap `startLogin` in something that surfaces
|
|
665
|
+
the branch to the caller; that re-opens the enumeration oracle.
|
|
666
|
+
|
|
485
667
|
## Constraints
|
|
486
668
|
|
|
487
669
|
- **Node 20+** -- targeting LTS; tested on Node 22
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|