knowless 0.1.10 → 0.2.1
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 +148 -5
- package/GUIDE.md +148 -13
- package/OPS.md +8 -7
- package/README.md +155 -103
- package/knowless.context.md +173 -6
- package/package.json +2 -3
- package/src/handlers.js +46 -3
- package/src/index.js +98 -1
- package/src/mailer.js +21 -0
- package/src/store.js +50 -9
package/README.md
CHANGED
|
@@ -7,35 +7,7 @@ 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.1
|
|
11
|
-
|
|
12
|
-
## What this is
|
|
13
|
-
|
|
14
|
-
Magic-link auth + session cookie + nothing else. Six lines of
|
|
15
|
-
operator code:
|
|
16
|
-
|
|
17
|
-
```js
|
|
18
|
-
import express from 'express';
|
|
19
|
-
import { knowless } from 'knowless';
|
|
20
|
-
|
|
21
|
-
const app = express();
|
|
22
|
-
const auth = knowless({
|
|
23
|
-
secret: process.env.KNOWLESS_SECRET, // 64-char hex (32 bytes)
|
|
24
|
-
baseUrl: 'https://app.example.com',
|
|
25
|
-
from: 'auth@app.example.com',
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
app.use(express.urlencoded({ extended: false }));
|
|
29
|
-
app.get('/login', auth.loginForm);
|
|
30
|
-
app.post('/login', auth.login);
|
|
31
|
-
app.get('/auth/callback', auth.callback);
|
|
32
|
-
app.get('/verify', auth.verify);
|
|
33
|
-
app.post('/logout', auth.logout);
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
That's the entire integration. Users hit `/login`, type their email,
|
|
37
|
-
click the magic link in their inbox, and are logged in for 30 days
|
|
38
|
-
via a signed session cookie.
|
|
10
|
+
> v0.2.1 | Node.js >= 22.5 | **1 production dep (nodemailer)** | Apache-2.0
|
|
39
11
|
|
|
40
12
|
## Why this exists
|
|
41
13
|
|
|
@@ -44,114 +16,194 @@ maximum identity collection: full email stored in plaintext, profile
|
|
|
44
16
|
fields, recovery email, federation. Even nominally privacy-focused
|
|
45
17
|
options store enough that a breach is materially harmful.
|
|
46
18
|
|
|
47
|
-
|
|
48
|
-
session cookie out, nothing else stored
|
|
49
|
-
|
|
50
|
-
identifying.
|
|
19
|
+
knowless is the simpler answer that always worked: **magic link in,
|
|
20
|
+
session cookie out, nothing else stored.** Email is HMAC-hashed at the
|
|
21
|
+
boundary and discarded. The library refuses, by API shape, to send
|
|
22
|
+
anything but the sign-in link or store anything identifying.
|
|
51
23
|
|
|
52
24
|
The thesis: most services have ten layers of auth tooling where they
|
|
53
25
|
need two.
|
|
54
26
|
|
|
55
|
-
##
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
-
|
|
27
|
+
## Two modes — pick per action
|
|
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.
|
|
31
|
+
|
|
32
|
+
### Mode B — register-first (the form)
|
|
33
|
+
|
|
34
|
+
User must log in before performing the action. Standard "sign in to
|
|
35
|
+
continue" flow.
|
|
36
|
+
|
|
37
|
+
- User hits `/login`, types email
|
|
38
|
+
- Magic link arrives, click → session cookie
|
|
39
|
+
- Your protected endpoints call `auth.handleFromRequest(req)` to gate
|
|
40
|
+
access
|
|
41
|
+
|
|
42
|
+
Use for: account settings, paid features, anything that requires an
|
|
43
|
+
identified user at the moment of the action.
|
|
44
|
+
|
|
45
|
+
### Mode A — use-first, claim-later (programmatic)
|
|
46
|
+
|
|
47
|
+
User performs the action *without* being logged in. You capture their
|
|
48
|
+
email along with the action, fire a magic link via
|
|
49
|
+
`auth.startLogin({email, nextUrl, ...})`, and clicking it opens a
|
|
50
|
+
session and "promotes" the deferred resource.
|
|
51
|
+
|
|
52
|
+
Use for: drop-a-pin / submit-a-paste / share-a-link / disposable
|
|
53
|
+
resources / anywhere logging in first kills the UX.
|
|
54
|
+
|
|
55
|
+
The same 12-step sham-work flow runs underneath either mode, so
|
|
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.
|
|
59
|
+
|
|
60
|
+
Worked code for both modes is in [`GUIDE.md`](GUIDE.md). The dense
|
|
61
|
+
API reference is [`knowless.context.md`](knowless.context.md).
|
|
62
|
+
|
|
63
|
+
## What's opinionated (locked by design)
|
|
64
|
+
|
|
65
|
+
These are deliberate trade-offs, documented as `NO-GO` in
|
|
66
|
+
[`docs/01-product/PRD.md`](docs/01-product/PRD.md) §14.
|
|
67
|
+
The library refuses, by API shape, to grow into them.
|
|
68
|
+
|
|
69
|
+
- **Localhost SMTP only.** No Mailgun/Postmark/SES/Resend. The
|
|
70
|
+
operator runs Postfix (or another MTA) on the same host, in
|
|
71
|
+
outbound-only mode.
|
|
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.
|
|
75
|
+
- **Plain-text 7-bit email.** No HTML, no tracking pixels, no
|
|
76
|
+
click-rewriting, no read-receipts.
|
|
77
|
+
- **No OAuth / OIDC / SAML.** Different audience.
|
|
78
|
+
- **No 2FA / WebAuthn / TOTP / passkeys.** Compose with a separate
|
|
79
|
+
library if you need them.
|
|
80
|
+
- **No admin UI.** `sqlite3 knowless.db` is the admin UI.
|
|
81
|
+
- **Hardcoded login form.** No template overrides; fork or live
|
|
82
|
+
with it.
|
|
83
|
+
- **No telemetry, analytics, or error reporting.** Self-hostable end
|
|
84
|
+
to end. No phone-home of any kind.
|
|
85
|
+
- **Walks away at v1.0.0.** Maintenance mode after that — only
|
|
86
|
+
security fixes.
|
|
87
|
+
|
|
88
|
+
## What's swappable
|
|
89
|
+
|
|
90
|
+
Everything that *isn't* identity-shape or threat-model essential is
|
|
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."
|
|
81
115
|
|
|
82
116
|
## Two deployment shapes (one codebase)
|
|
83
117
|
|
|
84
118
|
| Mode | Status | When |
|
|
85
119
|
|---|---|---|
|
|
86
120
|
| **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
|
|
87
|
-
| **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc.
|
|
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).
|
|
126
|
+
|
|
127
|
+
## First customer: addypin
|
|
128
|
+
|
|
129
|
+
[`addypin`](https://github.com/hamr0/addypin) — location-sharing
|
|
130
|
+
service in the same hermit-architecture lineage — adopted knowless
|
|
131
|
+
as its auth+mail layer. The integration delta:
|
|
132
|
+
|
|
133
|
+
- **~1,150 lines of bespoke auth/mail code removed** (custom mailer,
|
|
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)
|
|
88
141
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
142
|
+
The integration round produced the audit findings AF-7 through AF-17
|
|
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.
|
|
92
145
|
|
|
93
146
|
## Operator commitments
|
|
94
147
|
|
|
95
148
|
By choosing knowless, you commit to:
|
|
96
149
|
|
|
97
|
-
- Running your own server with **Postfix
|
|
98
|
-
for outbound-only mail
|
|
99
|
-
- Setting up **SPF, DKIM, and PTR
|
|
100
|
-
(one-time DNS setup)
|
|
150
|
+
- Running your own server with **Postfix** (or another MTA) installed
|
|
151
|
+
for outbound-only mail
|
|
152
|
+
- Setting up **SPF, DKIM, and PTR** for your sending domain
|
|
101
153
|
- Verifying **outbound port 25** is open (some clouds block it)
|
|
102
|
-
- A **null-route entry**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
106
158
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
forward-auth examples, Tailscale pattern, reverse-proxy rate
|
|
110
|
-
|
|
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.
|
|
111
164
|
|
|
112
165
|
## Documentation
|
|
113
166
|
|
|
114
|
-
- [
|
|
115
|
-
-
|
|
116
|
-
|
|
117
|
-
- [
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
121
176
|
- [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
|
|
122
|
-
requirements
|
|
177
|
+
requirements, threat model, decisions log, NO-GO table, audit
|
|
178
|
+
findings backlog.
|
|
123
179
|
- [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) — wire formats,
|
|
124
|
-
byte layouts
|
|
125
|
-
- [`docs/03-tasks/TASKS.md`](docs/03-tasks/TASKS.md) — implementation
|
|
126
|
-
task list and phase plan
|
|
127
|
-
- [`CHANGELOG.md`](CHANGELOG.md) — version history
|
|
180
|
+
algorithms, byte layouts (reimplementation-grade).
|
|
128
181
|
|
|
129
|
-
## Threat model
|
|
182
|
+
## Threat model (one-paragraph)
|
|
130
183
|
|
|
131
|
-
Honest version (full detail in [
|
|
184
|
+
Honest version (full detail in [PRD §12](docs/01-product/PRD.md)):
|
|
132
185
|
|
|
133
|
-
**Defends well:**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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).
|
|
138
191
|
|
|
139
192
|
**Defends partially:** HMAC-secret-only leak (allows targeted
|
|
140
|
-
existence
|
|
141
|
-
|
|
142
|
-
links).
|
|
193
|
+
existence checks but not session forgery), phishing (no password to
|
|
194
|
+
type into a fake site, but a phished mailbox still receives links).
|
|
143
195
|
|
|
144
196
|
**Does NOT defend against:** sophisticated bots that bypass the
|
|
145
197
|
honeypot, distributed floods from many IPs, full server compromise,
|
|
146
198
|
compromised email accounts, social engineering, insider threat at
|
|
147
199
|
the operator. Layer-2 defences (Cloudflare, fail2ban, reverse-proxy
|
|
148
|
-
rate-limits) belong above the library; OPS.md
|
|
149
|
-
|
|
200
|
+
rate-limits) belong above the library; [`OPS.md`](OPS.md) §9–§10
|
|
201
|
+
covers the patterns.
|
|
150
202
|
|
|
151
203
|
## Sibling projects
|
|
152
204
|
|
|
153
|
-
- [`addypin`](https://github.com/hamr0/addypin) — location sharing
|
|
154
|
-
|
|
205
|
+
- [`addypin`](https://github.com/hamr0/addypin) — location sharing,
|
|
206
|
+
first knowless adopter
|
|
155
207
|
- [`gitdone`](https://github.com/hamr0/gitdone) — verified email
|
|
156
208
|
actions via DKIM/SPF inbound
|
|
157
209
|
|
|
@@ -160,8 +212,8 @@ common patterns.
|
|
|
160
212
|
Issues and PRs welcome at <https://github.com/hamr0/knowless>.
|
|
161
213
|
|
|
162
214
|
Per the v1.0.0 walk-away framing in PRD §6.3: feature requests after
|
|
163
|
-
v1.0.0 ships will be deflected to the §14 NO-GO table
|
|
164
|
-
projects. The library being "done" is a feature.
|
|
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.
|
|
165
217
|
|
|
166
218
|
## License
|
|
167
219
|
|
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.1
|
|
4
|
+
> v0.2.1 | 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
|
|
@@ -152,6 +163,7 @@ const auth = knowless({
|
|
|
152
163
|
| `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. |
|
|
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
|
|
|
@@ -173,6 +185,91 @@ import {
|
|
|
173
185
|
} from 'knowless';
|
|
174
186
|
```
|
|
175
187
|
|
|
188
|
+
## Operator visibility (v0.2.1)
|
|
189
|
+
|
|
190
|
+
Three event hooks + one opt-in method, shipped in v0.2.1. Future
|
|
191
|
+
contributors reading this section before extending the surface: do not
|
|
192
|
+
add a per-event `onShamHit`, do not add a per-handle `onRateLimitHit`,
|
|
193
|
+
do not add an auto-on-boot probe, do not add a `lookupMessageId()`
|
|
194
|
+
endpoint. Each was considered and deliberately rejected during the
|
|
195
|
+
forum + addypin negotiation that produced this surface (PRD §17.3,
|
|
196
|
+
v0.2.1) — see "What's NOT in knowless" below for the reasoning.
|
|
197
|
+
|
|
198
|
+
### Three hooks (factory options)
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
const auth = knowless({
|
|
202
|
+
// ...required + existing options...
|
|
203
|
+
|
|
204
|
+
// Per-event, safe to log per-call.
|
|
205
|
+
onMailerSubmit: ({messageId, handle, timestamp}) => { /* */ },
|
|
206
|
+
onTransportFailure: ({error, timestamp}) => { /* */ },
|
|
207
|
+
|
|
208
|
+
// Batched aggregate. Fires every windowMs regardless of count
|
|
209
|
+
// (heartbeat). Default cadence 60s.
|
|
210
|
+
onSuppressionWindow: ({sham, rateLimited, windowMs}) => { /* */ },
|
|
211
|
+
suppressionWindowMs: 60_000,
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Field types:
|
|
216
|
+
- `messageId`: string — SMTP `Message-ID` returned by nodemailer
|
|
217
|
+
- `handle`: string — 64-char hex; only emitted on real (non-sham) submits
|
|
218
|
+
- `timestamp`: number — epoch ms
|
|
219
|
+
- `error`: Error
|
|
220
|
+
- `sham`, `rateLimited`: integer counters, count within the window
|
|
221
|
+
- `windowMs`: integer — the configured window length, echoed in the payload
|
|
222
|
+
|
|
223
|
+
Errors thrown from hooks are caught and swallowed (matches the existing
|
|
224
|
+
`onSweepError` contract); knowless does not depend on hook delivery for
|
|
225
|
+
correctness.
|
|
226
|
+
|
|
227
|
+
### Method
|
|
228
|
+
|
|
229
|
+
`auth.verifyTransport()` — wraps `transport.verify()` on the configured
|
|
230
|
+
SMTP transport. Returns `Promise<true>` on success, rejects with the
|
|
231
|
+
underlying error. Adopters call this explicitly when they want fail-fast
|
|
232
|
+
on misconfigured SMTP at boot. **No auto-on-boot variant** by design:
|
|
233
|
+
deployments where knowless starts before Postfix (docker-compose
|
|
234
|
+
ordering, k8s readiness probes) would fail boot for the wrong reason.
|
|
235
|
+
|
|
236
|
+
### Threat-model justification (the durable part)
|
|
237
|
+
|
|
238
|
+
The two silent-202 branches — sham (handle does not exist) and rate-limit
|
|
239
|
+
(any of the three caps) — are aggregated rather than per-event because
|
|
240
|
+
**NFR-10 timing equivalence applies at the log layer too**, not just the
|
|
241
|
+
HTTP response. A per-event `onShamHit({handle})` lets a careless adopter
|
|
242
|
+
log "sham detected for X" and the log file becomes an enumeration oracle
|
|
243
|
+
— the exact thing sham-work was designed to prevent. The response is
|
|
244
|
+
silent; the log must be silent too.
|
|
245
|
+
|
|
246
|
+
Knowless has three rate limits, and one of them is identity-tied:
|
|
247
|
+
- `maxLoginRequestsPerIpPerHour` — IP-keyed
|
|
248
|
+
- `maxNewHandlesPerIpPerHour` — IP-keyed
|
|
249
|
+
- `maxActiveTokensPerHandle` — **handle-keyed; per-event hits leak
|
|
250
|
+
"this handle exists and has hit a token cap"**
|
|
251
|
+
|
|
252
|
+
Splitting per-event-IP from per-event-handle works in theory and fails
|
|
253
|
+
in practice — future contributor sees the asymmetry and adds the missing
|
|
254
|
+
handle variant for symmetry. Bundling all three into the windowed
|
|
255
|
+
aggregate forecloses that drift.
|
|
256
|
+
|
|
257
|
+
`onMailerSubmit` carries `handle` per-event because it fires *only on
|
|
258
|
+
real submissions*, where the handle was already disclosed to knowless
|
|
259
|
+
by the form input. Emitting it back to the adopter is not a new leak.
|
|
260
|
+
`onTransportFailure` carries no identity data, per-event safe.
|
|
261
|
+
|
|
262
|
+
### Why no `lookupMessageId()` endpoint
|
|
263
|
+
|
|
264
|
+
An earlier proposal added an authenticated `auth.lookupMessageId(id)`
|
|
265
|
+
behind an operator secret so operators could correlate maillog entries
|
|
266
|
+
to handles. Rejected: the same capability is achievable by the adopter
|
|
267
|
+
maintaining their own `(messageId → handle)` map, populated from
|
|
268
|
+
`onMailerSubmit`. Knowless never stores the mapping, never exposes a
|
|
269
|
+
new authenticated surface, never carries operator-secret rotation
|
|
270
|
+
burden. The hook is the mechanism; the correlation map is adopter
|
|
271
|
+
choice.
|
|
272
|
+
|
|
176
273
|
## Handle / token / session lifecycles
|
|
177
274
|
|
|
178
275
|
```
|
|
@@ -333,7 +430,7 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
|
|
|
333
430
|
-> handle.js (normalize ASCII-only, HMAC-SHA256)
|
|
334
431
|
-> abuse.js (per-IP rate limit, per-handle token cap, honeypot)
|
|
335
432
|
-> token.js (32 random bytes, base64url; SHA-256 at rest)
|
|
336
|
-
-> store.js (
|
|
433
|
+
-> store.js (node:sqlite, transactional, prepared statements)
|
|
337
434
|
-> mailer.js (raw RFC822 7bit; nodemailer for SMTP submission only)
|
|
338
435
|
-> session.js (HMAC-signed cookie with "sess\\0" domain tag)
|
|
339
436
|
-> form.js (hardcoded HTML5; no JS, no external resources)
|
|
@@ -344,7 +441,7 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
|
|
|
344
441
|
|---|---|---|
|
|
345
442
|
| `src/index.js` | ~140 | Public factory, sweeper, re-exports |
|
|
346
443
|
| `src/handlers.js` | ~310 | login (sham), callback, verify, logout, loginForm, validateNextUrl |
|
|
347
|
-
| `src/store.js` | ~
|
|
444
|
+
| `src/store.js` | ~240 | node:sqlite store + transaction adapter; SPEC §13 interface |
|
|
348
445
|
| `src/mailer.js` | ~120 | RFC822 raw composition + nodemailer SMTP submission |
|
|
349
446
|
| `src/abuse.js` | ~95 | Source-IP determination, rate limits |
|
|
350
447
|
| `src/handle.js` | ~50 | Email normalization, handle derivation |
|
|
@@ -352,6 +449,66 @@ URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
|
|
|
352
449
|
| `src/session.js` | ~80 | Cookie signing/verification with constant-time compare |
|
|
353
450
|
| `src/form.js` | ~110 | Hardcoded login HTML |
|
|
354
451
|
|
|
452
|
+
## What's NOT in knowless, and why
|
|
453
|
+
|
|
454
|
+
Three capabilities that look like they belong here but don't, listed
|
|
455
|
+
because the "why not" needs to outlast walk-away-at-v1.0.0. When future
|
|
456
|
+
contributors propose adding any of these back, point them here.
|
|
457
|
+
|
|
458
|
+
### Disposable-domain blocking — adopter / form handler
|
|
459
|
+
|
|
460
|
+
Reject `mailinator.com` etc. before knowless sees the submission.
|
|
461
|
+
Mechanism + list + override + weekly cron all live in the adopter's
|
|
462
|
+
form handler.
|
|
463
|
+
|
|
464
|
+
The argument for putting this in knowless was timing equivalence: if
|
|
465
|
+
the adopter rejects fast, an attacker times the response and learns
|
|
466
|
+
"this domain is on a public blocklist." Counter: the blocklist is a
|
|
467
|
+
public GitHub repo (`disposable-email-domains/disposable-email-domains`).
|
|
468
|
+
Anyone can fetch it directly. Timing-equivalence here protects information
|
|
469
|
+
that isn't secret. Knowless's sham-work protects against email
|
|
470
|
+
*enumeration* (is `alice@x.com` registered?), not domain *classification*
|
|
471
|
+
(is `x.com` on a public list?). Different threat, different defense.
|
|
472
|
+
|
|
473
|
+
Splitting mechanism (knowless) from policy + list curation (adopter) is
|
|
474
|
+
the wrong seam. Both stay in the adopter's form handler.
|
|
475
|
+
|
|
476
|
+
### App-tenure / account-age — adopter / first-seen tracking
|
|
477
|
+
|
|
478
|
+
Knowless's "handle creation date" is when this email first hit knowless.
|
|
479
|
+
The adopter's interesting question is "how long has this user been
|
|
480
|
+
participating in *my app*" — a different number, and the adopter's
|
|
481
|
+
number is the one that should drive trust decisions.
|
|
482
|
+
|
|
483
|
+
Concrete failure mode: a handle registered with knowless six months ago
|
|
484
|
+
but never posted has zero app-tenure. If the adopter reads knowless's
|
|
485
|
+
age, a brand-new spammer with an old handle gets unearned credibility.
|
|
486
|
+
|
|
487
|
+
Pattern: adopter stores `(handle, first_seen_at)` the first time it sees
|
|
488
|
+
a handle perform a meaningful action. App-tenure is app-derived. Knowless
|
|
489
|
+
doesn't expose age data — and wouldn't even if it could, because
|
|
490
|
+
returning `Date | null` keyed by handle is itself an enumeration leak.
|
|
491
|
+
|
|
492
|
+
### Per-IP hashcash / proof-of-work — Caddy / perimeter layer
|
|
493
|
+
|
|
494
|
+
`maxNewHandlesPerIpPerHour: 3` already covers the ground hashcash would
|
|
495
|
+
cover. A botnet that can't get past three signups per IP per hour needs
|
|
496
|
+
IP rotation regardless; once rotated, a 2s hashcash is rounding error
|
|
497
|
+
at botnet economics. Costs are real: breaks Lynx/w3m (gotcha #10),
|
|
498
|
+
requires JS in the login form (the only zero-JS exception we'd carry),
|
|
499
|
+
~2s UX delay for legit users on weak devices. If a deployment observes
|
|
500
|
+
per-IP signup actually saturating the cap, Caddy (or another perimeter
|
|
501
|
+
layer) can run hashcash off-the-shelf without making knowless carry it.
|
|
502
|
+
|
|
503
|
+
### The deciding lens
|
|
504
|
+
|
|
505
|
+
knowless walks away at v1.0.0 (PRD §6.3). Every config option carried
|
|
506
|
+
into v1.0.0 is something v1.x has to keep stable through the
|
|
507
|
+
maintenance window. The test for any proposed addition: does this
|
|
508
|
+
belong in the **identity layer** (who they are) or the **behavior
|
|
509
|
+
layer** (what they did)? Identity layer is in scope. Behavior layer is
|
|
510
|
+
out. When unsure, default out — less surface, less carrying cost.
|
|
511
|
+
|
|
355
512
|
## Threat model summary
|
|
356
513
|
|
|
357
514
|
**Defends well:** DB-only leaks (handles are HMAC-salted),
|
|
@@ -433,7 +590,7 @@ rate-limits) belongs above the library.
|
|
|
433
590
|
sweeper and closes the SQLite handle. Without it, your
|
|
434
591
|
process won't exit cleanly. The sweeper timer is `unref()`d
|
|
435
592
|
so it won't *prevent* exit, but the SQLite handle held by
|
|
436
|
-
`
|
|
593
|
+
`node:sqlite` will leave a finalizer warning.
|
|
437
594
|
|
|
438
595
|
12. **CSRF defense is the Origin/Referer whitelist, not a token.**
|
|
439
596
|
Modern browsers always emit `Origin` on cross-origin POSTs;
|
|
@@ -482,13 +639,23 @@ rate-limits) belongs above the library.
|
|
|
482
639
|
factory startup; fails fast. Goes after RFC 3676 `"-- "`
|
|
483
640
|
delimiter so mail clients strip it from quoted replies.
|
|
484
641
|
|
|
642
|
+
19. **`startLogin` is silent at every layer (FR-6).** Returns
|
|
643
|
+
`{handle, submitted: true}` for *every* branch — real send, sham,
|
|
644
|
+
rate-limited, missing-handle-with-`openRegistration:false`. Adopters
|
|
645
|
+
cannot derive the branch from the return value, by design.
|
|
646
|
+
Operator visibility comes from the v0.2.1 hooks (`onMailerSubmit`
|
|
647
|
+
per-event, `onSuppressionWindow` aggregated) — *not* from the
|
|
648
|
+
return shape. Don't wrap `startLogin` in something that surfaces
|
|
649
|
+
the branch to the caller; that re-opens the enumeration oracle.
|
|
650
|
+
|
|
485
651
|
## Constraints
|
|
486
652
|
|
|
487
653
|
- **Node 20+** -- targeting LTS; tested on Node 22
|
|
488
654
|
- **Plain ES modules** -- no TypeScript source, no build step;
|
|
489
655
|
ships JSDoc + (eventual) `.d.ts`
|
|
490
|
-
- **
|
|
491
|
-
`
|
|
656
|
+
- **One production dep** -- `nodemailer` (SMTP submission). Storage
|
|
657
|
+
uses `node:sqlite` (stdlib, no native compile). No second runtime
|
|
658
|
+
dep without revisiting
|
|
492
659
|
AGENT_RULES External Dependency Checklist.
|
|
493
660
|
- **Localhost MTA only** -- no remote SMTP, no vendor SDKs.
|
|
494
661
|
Operators run their own Postfix / OpenSMTPD / Exim.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -23,14 +23,13 @@
|
|
|
23
23
|
"knowless.context.md"
|
|
24
24
|
],
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=22.5.0"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"test": "node --test 'test/**/*.test.js'",
|
|
30
30
|
"lint": "find src bin -type f \\( -name '*.js' -o -name 'knowless-server' \\) -exec node --check {} \\;"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"better-sqlite3": "^11.0.0",
|
|
34
33
|
"nodemailer": "^8.0.7"
|
|
35
34
|
},
|
|
36
35
|
"license": "Apache-2.0",
|