knowless 1.0.0 → 1.1.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 +89 -0
- package/GUIDE.md +185 -14
- package/knowless.context.md +114 -23
- package/package.json +1 -1
- package/src/handlers.js +5 -7
- package/src/index.js +18 -0
- package/src/mailer.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -22,10 +22,99 @@ v1.0.0 are:
|
|
|
22
22
|
user-visible impact)
|
|
23
23
|
- Bug fixes that don't change the API surface
|
|
24
24
|
- Documentation corrections
|
|
25
|
+
- Helper exports that pull existing mechanism back into the library
|
|
26
|
+
|
|
27
|
+
## [1.1.0] — 2026-05-02
|
|
28
|
+
|
|
29
|
+
Mailer-contract clarification release. Pulls FR-6 sham-routing
|
|
30
|
+
mechanism back into the library, makes the custom-mailer contract
|
|
31
|
+
explicit, and codifies walk-away scope in the docs.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `dropShamRecipient(envelope, shamRecipient?)` — exported pure
|
|
36
|
+
helper. Custom mailers call it to no-op sham sends without external
|
|
37
|
+
delivery. Exact-match predicate against the configured
|
|
38
|
+
`shamRecipient` (default `null@knowless.invalid`). No behaviour
|
|
39
|
+
change inside the library; ~10 LOC.
|
|
40
|
+
|
|
41
|
+
### Documented
|
|
42
|
+
|
|
43
|
+
- `knowless.context.md` § "What knowless is and is not" — scope
|
|
44
|
+
doctrine: knowless is the substrate for session-bearing logins, not
|
|
45
|
+
generic confirmation tokens. Adopters with one-shot non-login flows
|
|
46
|
+
keep their own token system.
|
|
47
|
+
- `knowless.context.md` § "Custom mailer contract" — five explicit
|
|
48
|
+
obligations for injected mailers: sham-recipient handling, FR-6
|
|
49
|
+
timing equivalence ownership (≤1ms, mailer's responsibility),
|
|
50
|
+
RFC822 fidelity, `verify()` semantics, `close()` lifecycle.
|
|
51
|
+
- `knowless.context.md` Public API — "Post-callback handle resolution"
|
|
52
|
+
block: no callback hook, `nextUrl` route is the seam.
|
|
53
|
+
- `GUIDE.md` § "Stability commitments under walk-away" — what v1.x
|
|
54
|
+
will and will not accept.
|
|
55
|
+
- `GUIDE.md` § "Custom mailer adapter" — appendix with sendmail-pipe
|
|
56
|
+
skeleton, FR-6 CI smoke-test pattern, and Postfix fallback note.
|
|
57
|
+
- `GUIDE.md` FAQ — custom login form contract, rate-limit defaults
|
|
58
|
+
and response shape, Origin/Referer CSRF failure mode (accurate;
|
|
59
|
+
replaces stale v0.2-era note), SQLite single-process envelope
|
|
60
|
+
(qualitative; no numeric guarantee carried forward).
|
|
61
|
+
|
|
62
|
+
No breaking changes. No behaviour changes inside the library.
|
|
25
63
|
|
|
26
64
|
Feature requests are deflected to PRD §14 NO-GO, to sibling projects,
|
|
27
65
|
or to forking. The library being "done" is a feature.
|
|
28
66
|
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **XFF/X-Real-IP never honored through handler path (AF-28).**
|
|
70
|
+
`createHandlers` pre-built `trustedProxies` into a `{ has }` object
|
|
71
|
+
and passed it to `determineSourceIp`, which re-called
|
|
72
|
+
`buildTrustedPeers` on it. The pre-built object is not a `BlockList`,
|
|
73
|
+
array, or `Set`, so the peer list fell through to `[]`. Net effect:
|
|
74
|
+
trusted-proxy matching was silently empty in the handler code path —
|
|
75
|
+
rate limiting hit the reverse-proxy IP instead of the real client IP,
|
|
76
|
+
and XFF/X-Real-IP headers were ignored regardless of the
|
|
77
|
+
`trustedProxies` config. Abuse unit tests were passing because they
|
|
78
|
+
call `determineSourceIp` directly with raw arrays, bypassing the
|
|
79
|
+
handler path. Fix: removed the pre-build in `createHandlers`;
|
|
80
|
+
`determineSourceIp` now receives `cfg.trustedProxies` directly. Closes
|
|
81
|
+
AF-28.
|
|
82
|
+
|
|
83
|
+
- **`validateSubject` allowed CR/LF, enabling header injection in
|
|
84
|
+
standalone callers (AF-29).** The ASCII regex `/^[\x00-\x7f]*$/`
|
|
85
|
+
matched CR (0x0D) and LF (0x0A). `validateSubject` is re-exported as a
|
|
86
|
+
public validator (AF-9.1 / v0.1.7) so callers using it as a standalone
|
|
87
|
+
gate were unprotected. `composeRaw` caught the injection downstream, but
|
|
88
|
+
the validator is the authoritative public guard. Fix: added an explicit
|
|
89
|
+
`/[\r\n]/` check to `validateSubject`, consistent with the guards already
|
|
90
|
+
present in `validateFromName` and `validateBodyOverride`. Closes AF-29.
|
|
91
|
+
|
|
92
|
+
- **Factory `subject` not validated at startup (AF-30).** The factory
|
|
93
|
+
`subject` option was never passed to `validateSubject` during
|
|
94
|
+
`createHandlers` startup, breaking the fail-fast pattern that
|
|
95
|
+
`bodyFooter` (`validateBodyFooter` in `index.js`) and `fromName`
|
|
96
|
+
(`validateFromName` in `createMailer`) already followed. A non-ASCII
|
|
97
|
+
subject or empty string silently passed config time and would only fail
|
|
98
|
+
at first `mailer.submit()` — potentially hours into production. Fix:
|
|
99
|
+
added `validateSubject(cfg.subject)` to the config-validation block in
|
|
100
|
+
`createHandlers`. Closes AF-30.
|
|
101
|
+
|
|
102
|
+
- **`validateBodyFooter` rejected 4-line footers with a trailing newline
|
|
103
|
+
(AF-31).** `footer.split('\n').length > 4` counted 5 split parts for
|
|
104
|
+
`"a\nb\nc\nd\n"` (4 logical lines, trailing newline as is conventional
|
|
105
|
+
for multi-line strings). Fix: strip a single trailing newline before
|
|
106
|
+
counting: `footer.replace(/\n$/, '').split('\n').length > 4`. Closes
|
|
107
|
+
AF-31.
|
|
108
|
+
|
|
109
|
+
### Documentation
|
|
110
|
+
|
|
111
|
+
- **`runSendLink` JSDoc corrected: `handle` is null for both malformed
|
|
112
|
+
email and per-IP rate-limit short-circuit (AF-32).** The JSDoc stated
|
|
113
|
+
`handle` is null "only when the email failed to normalize." The per-IP
|
|
114
|
+
rate-limit early return also returns `handle: null` because `deriveHandle`
|
|
115
|
+
has not run at that point. No behavior change — documentation only. Closes
|
|
116
|
+
AF-32.
|
|
117
|
+
|
|
29
118
|
## [1.0.0] — 2026-04-29
|
|
30
119
|
|
|
31
120
|
**Walk-away release.** No new API surface vs v0.2.3 — v1.0.0 is the
|
package/GUIDE.md
CHANGED
|
@@ -106,6 +106,27 @@ nothing else*.
|
|
|
106
106
|
- **Walks away at v1.0.0.** Maintenance mode (security patches +
|
|
107
107
|
bug fixes) after that, by design.
|
|
108
108
|
|
|
109
|
+
### Stability commitments under walk-away
|
|
110
|
+
|
|
111
|
+
v1.x will accept:
|
|
112
|
+
- Documentation corrections and clarifications.
|
|
113
|
+
- Helper exports that pull existing mechanism back into the library
|
|
114
|
+
(where adopters were re-implementing it inline).
|
|
115
|
+
- Bug fixes that don't change the API surface.
|
|
116
|
+
- Security fixes (CVEs in `nodemailer` or `node:sqlite` with
|
|
117
|
+
user-visible impact).
|
|
118
|
+
|
|
119
|
+
v1.x will NOT accept:
|
|
120
|
+
- New flows, new methods on the auth instance, new constructor options.
|
|
121
|
+
- Generalisations toward token-issuance frameworks (see
|
|
122
|
+
[`knowless.context.md`](knowless.context.md) § "What knowless is and
|
|
123
|
+
is not").
|
|
124
|
+
- Per-adopter ergonomic shortcuts that the host can implement in ≤30
|
|
125
|
+
LOC against the existing API.
|
|
126
|
+
|
|
127
|
+
The library being closed is a feature. Forks are encouraged for
|
|
128
|
+
requirements that don't fit.
|
|
129
|
+
|
|
109
130
|
## Walkthrough: library mode
|
|
110
131
|
|
|
111
132
|
The shape: import `knowless`, configure it, mount the handlers on
|
|
@@ -574,6 +595,114 @@ boot for the wrong reason. Adopters who want fail-fast call
|
|
|
574
595
|
`verifyTransport()` explicitly; everyone else gets eventually-consistent
|
|
575
596
|
SMTP startup.
|
|
576
597
|
|
|
598
|
+
## Custom mailer adapter (hosts with their own outbound stack)
|
|
599
|
+
|
|
600
|
+
The default mailer submits via nodemailer to localhost:25 (your Postfix).
|
|
601
|
+
If you already run outbound infrastructure with DKIM signing, a
|
|
602
|
+
transactional API, or a sendmail pipe, you can inject a mailer object:
|
|
603
|
+
|
|
604
|
+
```js
|
|
605
|
+
import { knowless, dropShamRecipient } from 'knowless';
|
|
606
|
+
|
|
607
|
+
// Timing-equivalence self-equalisation for subprocess-based mailers.
|
|
608
|
+
// Pre-measure your P95 delivery time, pad real sends to match.
|
|
609
|
+
const TRANSPORT_FLOOR_MS = 8; // example — calibrate for your stack
|
|
610
|
+
|
|
611
|
+
async function padToTransportFloor(startMs) {
|
|
612
|
+
const elapsed = performance.now() - startMs;
|
|
613
|
+
if (elapsed < TRANSPORT_FLOOR_MS) {
|
|
614
|
+
await new Promise(r => setTimeout(r, TRANSPORT_FLOOR_MS - elapsed));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const mailer = {
|
|
619
|
+
async send(envelope, raw) {
|
|
620
|
+
const t0 = performance.now();
|
|
621
|
+
|
|
622
|
+
if (dropShamRecipient(envelope)) {
|
|
623
|
+
// FR-6: no wire bytes, but burn the same wall time real delivery
|
|
624
|
+
// would take so real-vs-sham timing stays within ≤1ms.
|
|
625
|
+
await padToTransportFloor(t0);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Example: pipe to sendmail (local MTA handles DKIM via opendkim milter)
|
|
630
|
+
await new Promise((resolve, reject) => {
|
|
631
|
+
const proc = spawn('sendmail', ['-t', '-oi'], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
632
|
+
let stderr = '';
|
|
633
|
+
proc.stderr.on('data', d => { stderr += d; });
|
|
634
|
+
proc.stdin.write(raw);
|
|
635
|
+
proc.stdin.end();
|
|
636
|
+
proc.on('close', code => {
|
|
637
|
+
if (code === 0) resolve();
|
|
638
|
+
else reject(new Error(`sendmail exited ${code}: ${stderr}`));
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
await padToTransportFloor(t0);
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
async verify() {
|
|
645
|
+
// Optional — called once at factory construction.
|
|
646
|
+
// Throw here to abort startup on misconfigured transport.
|
|
647
|
+
// Example: probe that sendmail is available.
|
|
648
|
+
await execFile('sendmail', ['-bv', 'root']).catch(err => {
|
|
649
|
+
throw new Error(`sendmail probe failed: ${err.message}`);
|
|
650
|
+
});
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
// close() is optional — call yourself on shutdown if you allocated resources.
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const auth = knowless({ secret, baseUrl, from, mailer });
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
### Timing-equivalence CI smoke test
|
|
660
|
+
|
|
661
|
+
Add this to your integration tests to verify your adapter meets the
|
|
662
|
+
≤1ms FR-6 bar:
|
|
663
|
+
|
|
664
|
+
```js
|
|
665
|
+
// Run 200 warmup pairs, then 500 timed pairs.
|
|
666
|
+
// Assert |mean(real) - mean(sham)| < 1.0ms.
|
|
667
|
+
const WARMUP = 200, SAMPLES = 500;
|
|
668
|
+
const realTimes = [], shamTimes = [];
|
|
669
|
+
|
|
670
|
+
for (let i = 0; i < WARMUP + SAMPLES; i++) {
|
|
671
|
+
const t0 = performance.now();
|
|
672
|
+
await mailer.send({ to: 'alice@example.com' }, RAW_FIXTURE);
|
|
673
|
+
const tReal = performance.now() - t0;
|
|
674
|
+
|
|
675
|
+
const t1 = performance.now();
|
|
676
|
+
await mailer.send({ to: 'null@knowless.invalid' }, RAW_FIXTURE);
|
|
677
|
+
const tSham = performance.now() - t1;
|
|
678
|
+
|
|
679
|
+
if (i >= WARMUP) { realTimes.push(tReal); shamTimes.push(tSham); }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const mean = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
683
|
+
const delta = Math.abs(mean(realTimes) - mean(shamTimes));
|
|
684
|
+
assert(delta < 1.0, `timing delta ${delta.toFixed(3)}ms exceeds 1ms FR-6 bar`);
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
### Postfix transport_maps fallback
|
|
688
|
+
|
|
689
|
+
If you don't want to write a custom mailer, the default nodemailer
|
|
690
|
+
path still works even with opendkim: configure Postfix to sign on the
|
|
691
|
+
way out (MTA-level DKIM via `milter_default_action`). You only need a
|
|
692
|
+
custom mailer if you want to bypass localhost submission entirely.
|
|
693
|
+
|
|
694
|
+
Whichever path you take, ensure the `transport_maps` null-route is in
|
|
695
|
+
place:
|
|
696
|
+
|
|
697
|
+
```
|
|
698
|
+
# /etc/postfix/transport
|
|
699
|
+
knowless.invalid discard:silently dropped by knowless null-route
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
```
|
|
703
|
+
postmap /etc/postfix/transport && systemctl reload postfix
|
|
704
|
+
```
|
|
705
|
+
|
|
577
706
|
## Walkthrough: standalone server mode
|
|
578
707
|
|
|
579
708
|
Run `npx knowless-server`, point Caddy / nginx / Traefik at it for
|
|
@@ -761,11 +890,22 @@ once, back it up safely, never expose it.
|
|
|
761
890
|
|
|
762
891
|
### Can I customise the login HTML?
|
|
763
892
|
|
|
764
|
-
|
|
893
|
+
The built-in form is hardcoded. Operators wanting branding fork the
|
|
765
894
|
project. Rationale in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.12: templating
|
|
766
895
|
is a slope ("let me put my logo" → "let me theme the page" →
|
|
767
|
-
"let me embed a JS framework"). The hardcoded form refuses to
|
|
768
|
-
|
|
896
|
+
"let me embed a JS framework"). The hardcoded form refuses to drift.
|
|
897
|
+
|
|
898
|
+
However, you can **skip mounting `loginForm`** entirely and serve your
|
|
899
|
+
own form, provided it satisfies the handler contract:
|
|
900
|
+
|
|
901
|
+
- POST to `loginPath` (default `/login`).
|
|
902
|
+
- Include an `email` field in the `application/x-www-form-urlencoded`
|
|
903
|
+
body.
|
|
904
|
+
- Do **not** pre-parse the body upstream — knowless reads the request
|
|
905
|
+
stream itself. A body-parser middleware mounted before `auth.login`
|
|
906
|
+
will silently steal the data (see gotcha #15 in
|
|
907
|
+
[`knowless.context.md`](knowless.context.md)).
|
|
908
|
+
- Optional: include a `next` field for the post-callback redirect URL.
|
|
769
909
|
|
|
770
910
|
### How do I add 2FA / WebAuthn / TOTP?
|
|
771
911
|
|
|
@@ -774,20 +914,51 @@ stop. WebAuthn after login is a different layer.
|
|
|
774
914
|
|
|
775
915
|
### What about CSRF on POST /login?
|
|
776
916
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
917
|
+
Knowless validates the `Origin` (or `Referer`) header against
|
|
918
|
+
`cookieDomain` on both `POST /login` and `POST /logout`.
|
|
919
|
+
|
|
920
|
+
- **Failure mode:** 403 with a plain-text body `"Forbidden"`. No
|
|
921
|
+
redirect, no JSON envelope.
|
|
922
|
+
- **Browser-absent callers** (curl, server-to-server): if neither
|
|
923
|
+
`Origin` nor `Referer` is present, the check passes — the
|
|
924
|
+
browser is the CSRF threat model, not API callers. Programmatic
|
|
925
|
+
callers that do send an `Origin` header must ensure it matches
|
|
926
|
+
`cookieDomain`.
|
|
927
|
+
- **The check is not disable-able.** Do not add an upstream
|
|
928
|
+
exception; the Origin check is the CSRF defence (SPEC §7.3 Step
|
|
929
|
+
0). Do NOT add a separate CSRF token — it would duplicate a
|
|
930
|
+
defence already in place and complicate custom form integration.
|
|
931
|
+
|
|
932
|
+
### What are the rate-limit defaults, and what does a rate-limited response look like?
|
|
933
|
+
|
|
934
|
+
Defaults (all configurable at construction, no live tuning):
|
|
935
|
+
|
|
936
|
+
| Option | Default | Scope |
|
|
937
|
+
|---|---|---|
|
|
938
|
+
| `maxLoginRequestsPerIpPerHour` | 30 | Per source IP |
|
|
939
|
+
| `maxNewHandlesPerIpPerHour` | 3 | Per source IP (open-registration only) |
|
|
940
|
+
| `maxActiveTokensPerHandle` | 5 | Per handle (unexpired, unused) |
|
|
941
|
+
|
|
942
|
+
A rate-limited submission returns **202** with the same HTML body as a
|
|
943
|
+
successful send — identical to the sham and real-send responses. This
|
|
944
|
+
is intentional: distinct status or body would let an attacker enumerate
|
|
945
|
+
which IP is being throttled. Aggregate counts are surfaced via the
|
|
946
|
+
`onSuppressionWindow` hook (v0.2.1); individual rate-limit hits are
|
|
947
|
+
not exposed per-event by design (see [`knowless.context.md`](knowless.context.md)
|
|
948
|
+
§ "Why three hooks, not four").
|
|
783
949
|
|
|
784
950
|
### Can I run multiple instances behind a load balancer?
|
|
785
951
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
952
|
+
The default SQLite store is a single-process embedded engine. Knowless
|
|
953
|
+
is designed for single-process deployments. Contention shows up as
|
|
954
|
+
`SQLITE_BUSY` errors in logs; if you see them, your write rate exceeds
|
|
955
|
+
the single-process envelope. Benchmark in your own environment — the
|
|
956
|
+
answer depends on kernel, filesystem, SQLite version, and Node version,
|
|
957
|
+
and v1.x carries no numeric guarantee across upgrades.
|
|
958
|
+
|
|
959
|
+
For multi-process or multi-node deployments, implement the store
|
|
960
|
+
interface against Postgres, Redis, or any other backend. The interface
|
|
961
|
+
is documented in [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) §13.
|
|
791
962
|
|
|
792
963
|
### How do I see what's in the store?
|
|
793
964
|
|
package/knowless.context.md
CHANGED
|
@@ -30,6 +30,23 @@ This document is the dense reference. For the why, see
|
|
|
30
30
|
`docs/01-product/PRD.md`. For the wire formats, see
|
|
31
31
|
`docs/02-design/SPEC.md`. For an adopter walkthrough, see `GUIDE.md`.
|
|
32
32
|
|
|
33
|
+
## What knowless is and is not
|
|
34
|
+
|
|
35
|
+
Knowless is the substrate for **session-bearing logins**: prove control
|
|
36
|
+
of an email, get a session cookie, do work under that session. The mint
|
|
37
|
+
path is single-use, short-TTL, and exists to bootstrap the cookie — not
|
|
38
|
+
as a generic confirmation primitive.
|
|
39
|
+
|
|
40
|
+
If you need ad-hoc one-shot confirmation tokens with caller-chosen TTLs
|
|
41
|
+
(event activation that may sit in an inbox over a weekend, email-change
|
|
42
|
+
confirmation, magic-action links that aren't logins), **keep your own
|
|
43
|
+
token system for that step** and use knowless for the session that
|
|
44
|
+
follows. The walk-away release intentionally does not grow toward generic
|
|
45
|
+
token issuance — that's a different library with a different threat
|
|
46
|
+
model.
|
|
47
|
+
|
|
48
|
+
See § "What's NOT in knowless, and why" for the full lens.
|
|
49
|
+
|
|
33
50
|
## Which mode do I need?
|
|
34
51
|
|
|
35
52
|
| Mode | What it does | When to use |
|
|
@@ -175,23 +192,41 @@ const auth = knowless({
|
|
|
175
192
|
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
176
193
|
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
|
177
194
|
|
|
195
|
+
### Post-callback handle resolution
|
|
196
|
+
|
|
197
|
+
There is no callback hook fired by knowless on confirmation. The host's
|
|
198
|
+
`nextUrl` route is the seam. Read the handle there:
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
// In your post-callback landing route:
|
|
202
|
+
const handle = auth.handleFromRequest(req);
|
|
203
|
+
if (!handle) return res.writeHead(401).end();
|
|
204
|
+
// Now do host-side work keyed by handle.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This is deliberate: the host knows what "successful login means for me";
|
|
208
|
+
knowless does not. Both `auth.login` (form) and `auth.startLogin`
|
|
209
|
+
(programmatic) land here after the link is clicked — the post-callback
|
|
210
|
+
route is the single seam for all login modes.
|
|
211
|
+
|
|
178
212
|
Re-exports for advanced consumers:
|
|
179
213
|
|
|
180
214
|
```js
|
|
181
215
|
import {
|
|
182
|
-
knowless,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
216
|
+
knowless, // factory
|
|
217
|
+
dropShamRecipient, // pure: sham-address predicate for custom mailers
|
|
218
|
+
createStore, // direct store access (admin scripts)
|
|
219
|
+
createMailer, // direct mailer access
|
|
220
|
+
createHandlers, // bring your own factory wiring
|
|
221
|
+
composeBody, // pure: build the mail body
|
|
222
|
+
validateSubject, // pure: validate operator-supplied subject
|
|
223
|
+
validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
|
|
189
224
|
validateBodyOverride, // pure: validate per-call body override (AF-26)
|
|
190
|
-
validateFromName,
|
|
191
|
-
renderLoginForm,
|
|
192
|
-
normalize,
|
|
193
|
-
deriveHandle,
|
|
194
|
-
secretBytes,
|
|
225
|
+
validateFromName, // pure: validate operator-supplied From: display name (AF-27)
|
|
226
|
+
renderLoginForm, // pure: HTML5 page rendering
|
|
227
|
+
normalize, // pure: email normalization
|
|
228
|
+
deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
|
|
229
|
+
secretBytes, // pure: coerce hex string → 32-byte HMAC key
|
|
195
230
|
} from 'knowless';
|
|
196
231
|
```
|
|
197
232
|
|
|
@@ -448,6 +483,57 @@ which requires the operator secret.
|
|
|
448
483
|
[Caddy verifies cookie via /verify, proxies to Uptime Kuma]
|
|
449
484
|
```
|
|
450
485
|
|
|
486
|
+
## Custom mailer contract
|
|
487
|
+
|
|
488
|
+
When you inject `options.mailer`, knowless hands off five obligations.
|
|
489
|
+
The default localhost-SMTP mailer satisfies them all; custom mailers
|
|
490
|
+
must satisfy them deliberately.
|
|
491
|
+
|
|
492
|
+
**1. Sham-recipient handling.** Knowless addresses sham sends (FR-6
|
|
493
|
+
timing-equivalence sends for missed handles, rate-limit hits,
|
|
494
|
+
exempt-handle short-circuits) to `shamRecipient` (default
|
|
495
|
+
`null@knowless.invalid`). Custom mailers MUST drop these without
|
|
496
|
+
external delivery. Use the exported helper:
|
|
497
|
+
|
|
498
|
+
```js
|
|
499
|
+
import { dropShamRecipient } from 'knowless';
|
|
500
|
+
|
|
501
|
+
async function send(envelope, raw) {
|
|
502
|
+
if (dropShamRecipient(envelope)) return; // no wire send, no error
|
|
503
|
+
// ...actually deliver raw
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Forgetting this lets sham mail bounce, distinguishing miss from hit on
|
|
508
|
+
the wire and reopening the enumeration oracle that FR-6 closes. If you
|
|
509
|
+
configured a custom `shamRecipient`, pass it as the second argument:
|
|
510
|
+
`dropShamRecipient(envelope, cfg.shamRecipient)`.
|
|
511
|
+
|
|
512
|
+
**2. Timing equivalence (≤1ms).** Real-vs-sham wire-time difference
|
|
513
|
+
must stay within ≤1ms, measured from the start of the mailer's
|
|
514
|
+
`send(envelope, raw)` call to its returned promise's resolution. The
|
|
515
|
+
host knows its transport's jitter; the library cannot equalise around an
|
|
516
|
+
opaque mailer. Mailers spawning a subprocess per send (e.g. `sendmail`
|
|
517
|
+
pipe) MUST self-equalise: pre-warm the subprocess,
|
|
518
|
+
parallel-spawn-then-discard, or measure-and-pad. See FR-6 above for
|
|
519
|
+
why the bar matters.
|
|
520
|
+
|
|
521
|
+
**3. RFC822 fidelity.** Ship the body knowless composes byte-for-byte.
|
|
522
|
+
No quoted-printable re-encoding, no header rewriting (other than
|
|
523
|
+
envelope routing your transport requires), no soft-break wrapping, no
|
|
524
|
+
charset transcoding. See gotcha #9.
|
|
525
|
+
|
|
526
|
+
**4. `verify()` semantics.** Optional. If present, knowless calls it
|
|
527
|
+
once at factory construction (synchronously awaited before `knowless()`
|
|
528
|
+
resolves). Throwing aborts startup; resolving with any value is success.
|
|
529
|
+
Knowless does not call `verify()` again on a per-send basis. Hosts
|
|
530
|
+
whose transports can fail-after-warm-up should monitor independently.
|
|
531
|
+
|
|
532
|
+
**5. `close()` lifecycle.** Optional. Knowless does not call it on
|
|
533
|
+
normal operation. Hosts that wire it up are responsible for calling it
|
|
534
|
+
on their own shutdown path. Knowless guarantees `close()` is safe to
|
|
535
|
+
call multiple times after the auth instance has stopped issuing sends.
|
|
536
|
+
|
|
451
537
|
## Architecture
|
|
452
538
|
|
|
453
539
|
```
|
|
@@ -568,11 +654,13 @@ rate-limits) belongs above the library.
|
|
|
568
654
|
philosophy. If you can't run Postfix, knowless isn't your
|
|
569
655
|
library.
|
|
570
656
|
|
|
571
|
-
3. **`shamRecipient` MUST be discarded
|
|
572
|
-
is `null@knowless.invalid`.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
`
|
|
657
|
+
3. **`shamRecipient` MUST be discarded without external delivery.**
|
|
658
|
+
Default is `null@knowless.invalid`. With the default
|
|
659
|
+
localhost-SMTP mailer, add the `transport_maps` entry per Postfix
|
|
660
|
+
snippet above. With a custom injected mailer, call
|
|
661
|
+
`dropShamRecipient(envelope)` and no-op the send; forgetting this
|
|
662
|
+
bounces sham mail and reopens the enumeration oracle. See §
|
|
663
|
+
"Custom mailer contract".
|
|
576
664
|
|
|
577
665
|
4. **Cookie domain defaults to baseUrl's hostname.** This is the
|
|
578
666
|
*narrow* default; for SSO across subdomains (forward-auth
|
|
@@ -603,8 +691,10 @@ rate-limits) belongs above the library.
|
|
|
603
691
|
default encoding picks base64 or QP for plain ASCII bodies,
|
|
604
692
|
breaking the magic-link URL with QP soft-breaks (the v0.11
|
|
605
693
|
POC finding). We sidestep by composing the message ourselves
|
|
606
|
-
and using nodemailer only for SMTP. If you
|
|
607
|
-
|
|
694
|
+
and using nodemailer only for SMTP. If you inject a custom
|
|
695
|
+
mailer, ship the body byte-for-byte — no QP re-encoding, no
|
|
696
|
+
soft-break wrapping, no charset transcoding. See § "Custom
|
|
697
|
+
mailer contract" obligation 3.
|
|
608
698
|
|
|
609
699
|
10. **No JavaScript in any HTML page.** The login form, the
|
|
610
700
|
confirmation page, error pages — all static HTML5. Works in
|
|
@@ -659,10 +749,11 @@ rate-limits) belongs above the library.
|
|
|
659
749
|
who hold raw 32-byte keys.
|
|
660
750
|
|
|
661
751
|
18. **`bodyFooter` constraints (AF-8.2).** ASCII only — `·` is NOT
|
|
662
|
-
ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
752
|
+
ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines (a single trailing
|
|
753
|
+
newline is allowed and not counted as an extra line), no
|
|
754
|
+
`http(s)://` URLs (would conflict with the magic-link line).
|
|
755
|
+
Validated at factory startup; fails fast. Goes after RFC 3676
|
|
756
|
+
`"-- "` delimiter so mail clients strip it from quoted replies.
|
|
666
757
|
|
|
667
758
|
19. **`startLogin` is silent at every layer (FR-6).** Returns
|
|
668
759
|
`{handle, submitted: true}` for *every* branch — real send, sham,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
package/src/handlers.js
CHANGED
|
@@ -5,7 +5,6 @@ import { newSid, signSession, verifySessionSignature } from './session.js';
|
|
|
5
5
|
import { composeBody, validateSubject, validateBodyOverride } from './mailer.js';
|
|
6
6
|
import { renderLoginForm } from './form.js';
|
|
7
7
|
import {
|
|
8
|
-
buildTrustedPeers,
|
|
9
8
|
determineSourceIp,
|
|
10
9
|
rateLimitExceeded,
|
|
11
10
|
rateLimitIncrement,
|
|
@@ -191,9 +190,7 @@ export function createHandlers({ store, mailer, config, events }) {
|
|
|
191
190
|
throw new Error('config.baseUrl invalid');
|
|
192
191
|
}
|
|
193
192
|
}
|
|
194
|
-
|
|
195
|
-
// Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
|
|
196
|
-
const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
|
|
193
|
+
validateSubject(cfg.subject);
|
|
197
194
|
|
|
198
195
|
// AF-7.1: emit at most one warning per handler instance about an
|
|
199
196
|
// upstream body parser swallowing the request body. Loud enough to
|
|
@@ -253,8 +250,9 @@ export function createHandlers({ store, mailer, config, events }) {
|
|
|
253
250
|
*
|
|
254
251
|
* @returns {Promise<{handle: string|null, isSham: boolean,
|
|
255
252
|
* emailNorm: string, nextValidated: string|null}>}
|
|
256
|
-
* handle is null
|
|
257
|
-
*
|
|
253
|
+
* handle is null when the email failed to normalize (programmer bug
|
|
254
|
+
* for startLogin) OR when per-IP rate-limit short-circuited before
|
|
255
|
+
* handle derivation; same-shape silent for /login.
|
|
258
256
|
*/
|
|
259
257
|
async function runSendLink({
|
|
260
258
|
emailRaw,
|
|
@@ -469,7 +467,7 @@ export function createHandlers({ store, mailer, config, events }) {
|
|
|
469
467
|
return;
|
|
470
468
|
}
|
|
471
469
|
|
|
472
|
-
const sourceIp = determineSourceIp(req, trustedProxies);
|
|
470
|
+
const sourceIp = determineSourceIp(req, cfg.trustedProxies);
|
|
473
471
|
const result = await runSendLink({ emailRaw, nextRaw, sourceIp });
|
|
474
472
|
sameResponse(res, result.emailNorm, result.nextValidated ?? '');
|
|
475
473
|
}
|
package/src/index.js
CHANGED
|
@@ -279,6 +279,24 @@ export function knowless(options = {}) {
|
|
|
279
279
|
};
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Returns true if `envelope.to` equals the sham null-route recipient
|
|
284
|
+
* that knowless uses for FR-6 timing-equivalence sends. Custom mailers
|
|
285
|
+
* MUST call this and no-op the wire send when it returns true. The
|
|
286
|
+
* library still records the sham token row; this helper only governs
|
|
287
|
+
* whether to put bytes on the wire.
|
|
288
|
+
*
|
|
289
|
+
* Pass the same `shamRecipient` you configured the auth instance with,
|
|
290
|
+
* or omit to use the default `null@knowless.invalid`.
|
|
291
|
+
*
|
|
292
|
+
* @param {object} envelope Object with a `to` field (the recipient address).
|
|
293
|
+
* @param {string} [shamRecipient] Defaults to `'null@knowless.invalid'`.
|
|
294
|
+
* @returns {boolean}
|
|
295
|
+
*/
|
|
296
|
+
export function dropShamRecipient(envelope, shamRecipient = 'null@knowless.invalid') {
|
|
297
|
+
return envelope?.to === shamRecipient;
|
|
298
|
+
}
|
|
299
|
+
|
|
282
300
|
export { createStore } from './store.js';
|
|
283
301
|
export {
|
|
284
302
|
createMailer,
|
package/src/mailer.js
CHANGED
|
@@ -88,7 +88,7 @@ export function validateBodyFooter(footer) {
|
|
|
88
88
|
if (footer.length > 240) throw new Error('bodyFooter must be ≤ 240 chars');
|
|
89
89
|
if (!ASCII_RE.test(footer)) throw new Error('bodyFooter must be ASCII');
|
|
90
90
|
if (footer.includes('\r')) throw new Error('bodyFooter must not contain CR');
|
|
91
|
-
if (footer.split('\n').length > 4) {
|
|
91
|
+
if (footer.replace(/\n$/, '').split('\n').length > 4) {
|
|
92
92
|
throw new Error('bodyFooter must be ≤ 4 lines');
|
|
93
93
|
}
|
|
94
94
|
if (/https?:\/\//i.test(footer)) {
|
|
@@ -269,6 +269,7 @@ export function validateSubject(subject) {
|
|
|
269
269
|
}
|
|
270
270
|
if (subject.length > 60) throw new Error('subject longer than 60 chars');
|
|
271
271
|
if (!ASCII_RE.test(subject)) throw new Error('subject contains non-ASCII');
|
|
272
|
+
if (/[\r\n]/.test(subject)) throw new Error('subject must not contain CR/LF');
|
|
272
273
|
const warnings = [];
|
|
273
274
|
const triggers = ['!!', '$$', 'FREE', 'URGENT', 'WINNER'];
|
|
274
275
|
for (const t of triggers) {
|