knowless 0.1.5 → 0.1.6
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 +56 -0
- package/README.md +1 -1
- package/knowless.context.md +22 -1
- package/package.json +1 -1
- package/src/handle.js +24 -5
- package/src/handlers.js +1 -0
- package/src/index.js +9 -5
- package/src/mailer.js +45 -1
- package/src/session.js +2 -4
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,62 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
- Caddy forward-auth Docker integration test (TASKS.md 6.8).
|
|
11
|
+
|
|
12
|
+
## [0.1.6] — 2026-04-28
|
|
13
|
+
|
|
14
|
+
addypin integration round 2 — one correctness fix (HMAC key handling)
|
|
15
|
+
and one common-want feature (operator footer on auth mail).
|
|
16
|
+
|
|
17
|
+
### Breaking
|
|
18
|
+
|
|
19
|
+
- **The `secret` is now hex-decoded before being used as the HMAC
|
|
20
|
+
key.** Prior versions passed the 64-char hex string to
|
|
21
|
+
`crypto.createHmac` as ASCII bytes — same 256 bits of entropy, but
|
|
22
|
+
a different HMAC output than systems that hex-decode first. The
|
|
23
|
+
PRD already implied 32 bytes ("≥64 hex chars (32 bytes)"); the
|
|
24
|
+
implementation matched the spec on key length but used the wrong
|
|
25
|
+
key bytes. **Effect:** every handle and session signature changes
|
|
26
|
+
on upgrade. Existing sessions invalidate (users re-login); existing
|
|
27
|
+
pre-seeded handles must be re-derived. There are no production
|
|
28
|
+
knowless deployments yet (addypin and webrevival are both pre-prod),
|
|
29
|
+
so we lock the correct semantics in before v1.0 freezes. Closes
|
|
30
|
+
AF-8.1.
|
|
31
|
+
- The startup secret check now also validates that the secret is
|
|
32
|
+
64-char lowercase hex (`/^[a-f0-9]{64,}$/i`). Mixed-case secrets
|
|
33
|
+
must be lowercased.
|
|
34
|
+
- `deriveHandle()` and `signSession()` accept `Buffer` directly for
|
|
35
|
+
adopters who already hold raw 32-byte keys.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`bodyFooter: string`** config option — append a constant operator
|
|
40
|
+
footer to every magic-link email after the standard `"-- "` (RFC
|
|
41
|
+
3676) signature delimiter. Constraints (deliberately strict to
|
|
42
|
+
preserve the URL-line invariant and 7bit body encoding):
|
|
43
|
+
- ASCII only (no unicode middle-dot — use `|` or `-`)
|
|
44
|
+
- ≤ 240 chars, ≤ 4 lines
|
|
45
|
+
- No CR
|
|
46
|
+
- No `http://` / `https://` substrings (would conflict with the
|
|
47
|
+
magic-link line and trigger MTA URL-rewriting heuristics)
|
|
48
|
+
Validated at factory startup; fails fast on misconfiguration.
|
|
49
|
+
Closes AF-8.2.
|
|
50
|
+
- `validateBodyFooter()` exported alongside `composeBody` for adopters
|
|
51
|
+
who want to validate operator-supplied footers themselves.
|
|
52
|
+
- `secretBytes()` exported from `./handle.js` (and via the package
|
|
53
|
+
root) for adopters who want to coerce a hex string to raw 32-byte
|
|
54
|
+
HMAC key on their own boundaries.
|
|
55
|
+
|
|
56
|
+
### Migration from 0.1.5
|
|
57
|
+
|
|
58
|
+
- **Pre-seeded handles must be re-derived.** `deriveHandle('alice@x',
|
|
59
|
+
SECRET)` returns a different value in 0.1.6. If you stored handles
|
|
60
|
+
in any external system, recompute them. Closed-registration users
|
|
61
|
+
must re-seed.
|
|
62
|
+
- **Active sessions invalidate.** Users will need to log in again
|
|
63
|
+
after the upgrade. Plan for a single-shot user-visible logout.
|
|
64
|
+
- **Magic links in flight at upgrade time** become invalid (token
|
|
65
|
+
hashes are stored as HMAC outputs and the key changes). 15 min
|
|
66
|
+
TTL by default; a brief read-only window during deploy is enough.
|
|
11
67
|
- `knowless-server --check-null-route`: CLI probe that submits a
|
|
12
68
|
test message to `shamRecipient` and confirms the local MTA
|
|
13
69
|
discarded it. Honest answer to "does the operator's null-route
|
package/README.md
CHANGED
|
@@ -7,7 +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.
|
|
10
|
+
> v0.1.6 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
|
|
11
11
|
|
|
12
12
|
## What this is
|
|
13
13
|
|
package/knowless.context.md
CHANGED
|
@@ -102,6 +102,12 @@ const auth = knowless({
|
|
|
102
102
|
// ^ NOTE: the message is HTML-escaped before render (AF-6.5).
|
|
103
103
|
// {email} placeholder still works. For HTML, pre-render upstream.
|
|
104
104
|
|
|
105
|
+
// Operator footer on magic-link mail (AF-8.2). ASCII-only, ≤240
|
|
106
|
+
// chars, ≤4 lines, NO URLs (would conflict with the magic-link line).
|
|
107
|
+
// Validated at factory startup. Use | or - as separators (NOT · which
|
|
108
|
+
// is non-ASCII).
|
|
109
|
+
bodyFooter: 'feedback@example.com | privacy first',
|
|
110
|
+
|
|
105
111
|
// --- Abuse defenses (FR-38..41) ---
|
|
106
112
|
maxActiveTokensPerHandle: 5, // 0 to disable
|
|
107
113
|
maxLoginRequestsPerIpPerHour: 30, // 0 to disable
|
|
@@ -157,9 +163,11 @@ import {
|
|
|
157
163
|
createHandlers, // bring your own factory wiring
|
|
158
164
|
composeBody, // pure: build the mail body
|
|
159
165
|
validateSubject, // pure: validate operator-supplied subject
|
|
166
|
+
validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
|
|
160
167
|
renderLoginForm, // pure: HTML5 page rendering
|
|
161
168
|
normalize, // pure: email normalization
|
|
162
|
-
deriveHandle, // pure: HMAC-SHA256(secret, email)
|
|
169
|
+
deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
|
|
170
|
+
secretBytes, // pure: coerce hex string → 32-byte HMAC key
|
|
163
171
|
} from 'knowless';
|
|
164
172
|
```
|
|
165
173
|
|
|
@@ -459,6 +467,19 @@ rate-limits) belongs above the library.
|
|
|
459
467
|
identical 12-step sham-work flow; same FR-6 guarantee. Pick
|
|
460
468
|
per-action, not per-app.
|
|
461
469
|
|
|
470
|
+
17. **Secret is hex-decoded (AF-8.1, since v0.1.6).** Pass a
|
|
471
|
+
64-char lowercase hex string; knowless decodes to 32 raw bytes
|
|
472
|
+
before HMAC. If you're upgrading from 0.1.5 or earlier, all
|
|
473
|
+
handles and session signatures change — re-seed handles, expect
|
|
474
|
+
one user-visible logout. `Buffer` accepted directly for adopters
|
|
475
|
+
who hold raw 32-byte keys.
|
|
476
|
+
|
|
477
|
+
18. **`bodyFooter` constraints (AF-8.2).** ASCII only — `·` is NOT
|
|
478
|
+
ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines, no `http(s)://`
|
|
479
|
+
URLs (would conflict with the magic-link line). Validated at
|
|
480
|
+
factory startup; fails fast. Goes after RFC 3676 `"-- "`
|
|
481
|
+
delimiter so mail clients strip it from quoted replies.
|
|
482
|
+
|
|
462
483
|
## Constraints
|
|
463
484
|
|
|
464
485
|
- **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.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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/handle.js
CHANGED
|
@@ -26,6 +26,28 @@ export function normalize(input) {
|
|
|
26
26
|
return lowered;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Coerce an operator-supplied secret to the raw bytes used as HMAC key.
|
|
31
|
+
*
|
|
32
|
+
* AF-8.1: knowless requires `secret` to be a 64-char lowercase hex
|
|
33
|
+
* string (32 bytes). Prior versions passed it to `createHmac` as an
|
|
34
|
+
* ASCII string — same 256 bits of entropy, but a different HMAC
|
|
35
|
+
* output than systems that hex-decode first. That meant adopters
|
|
36
|
+
* with existing HMAC-keyed identifiers couldn't interoperate. The
|
|
37
|
+
* fix is to hex-decode at the boundary so HMAC uses 32 raw bytes.
|
|
38
|
+
*
|
|
39
|
+
* @param {Buffer|string} secret
|
|
40
|
+
* @returns {Buffer} 32 raw bytes
|
|
41
|
+
*/
|
|
42
|
+
export function secretBytes(secret) {
|
|
43
|
+
if (Buffer.isBuffer(secret)) return secret;
|
|
44
|
+
if (typeof secret !== 'string') throw new Error('secret required');
|
|
45
|
+
if (!/^[a-f0-9]{64,}$/i.test(secret)) {
|
|
46
|
+
throw new Error('secret must be ≥64 hex chars (lowercase a-f, 0-9)');
|
|
47
|
+
}
|
|
48
|
+
return Buffer.from(secret, 'hex');
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/**
|
|
30
52
|
* Derive the opaque handle for a normalized email using the operator secret.
|
|
31
53
|
* HMAC-SHA256, lowercase hex output, 64 chars. See SPEC §3.
|
|
@@ -34,18 +56,15 @@ export function normalize(input) {
|
|
|
34
56
|
* HMAC use of `secret` MUST add a tag prefix (see SPEC §3.4).
|
|
35
57
|
*
|
|
36
58
|
* @param {string} emailNormalized output of normalize()
|
|
37
|
-
* @param {Buffer|string} secret operator HMAC secret
|
|
59
|
+
* @param {Buffer|string} secret operator HMAC secret (32+ raw bytes or ≥64 hex chars)
|
|
38
60
|
* @returns {string} 64-char lowercase hex handle
|
|
39
61
|
*/
|
|
40
62
|
export function deriveHandle(emailNormalized, secret) {
|
|
41
63
|
if (typeof emailNormalized !== 'string' || emailNormalized.length === 0) {
|
|
42
64
|
throw new Error('emailNormalized required');
|
|
43
65
|
}
|
|
44
|
-
if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
|
|
45
|
-
throw new Error('secret required');
|
|
46
|
-
}
|
|
47
66
|
return crypto
|
|
48
|
-
.createHmac('sha256', secret)
|
|
67
|
+
.createHmac('sha256', secretBytes(secret))
|
|
49
68
|
.update(emailNormalized, 'utf8')
|
|
50
69
|
.digest('hex');
|
|
51
70
|
}
|
package/src/handlers.js
CHANGED
package/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createStore } from './store.js';
|
|
2
|
-
import { createMailer } from './mailer.js';
|
|
2
|
+
import { createMailer, validateBodyFooter } from './mailer.js';
|
|
3
3
|
import { createHandlers } from './handlers.js';
|
|
4
4
|
import { deriveHandle as deriveHandleRaw } from './handle.js';
|
|
5
5
|
|
|
@@ -73,8 +73,12 @@ export function knowless(options = {}) {
|
|
|
73
73
|
for (const f of REQUIRED_FIELDS) {
|
|
74
74
|
if (!options[f]) throw new Error(`knowless: ${f} is required`);
|
|
75
75
|
}
|
|
76
|
-
if (typeof options.secret !== 'string' || options.secret
|
|
77
|
-
throw new Error('knowless: secret must be at least 64 hex chars (32 bytes)');
|
|
76
|
+
if (typeof options.secret !== 'string' || !/^[a-f0-9]{64,}$/i.test(options.secret)) {
|
|
77
|
+
throw new Error('knowless: secret must be at least 64 hex chars (32 bytes, lowercase a-f, 0-9)');
|
|
78
|
+
}
|
|
79
|
+
// Validate operator-supplied body footer at startup (AF-8.2).
|
|
80
|
+
if (options.bodyFooter !== undefined && options.bodyFooter !== null) {
|
|
81
|
+
validateBodyFooter(options.bodyFooter);
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
// SPEC §5.4: cookieSecure: false is allowed only for localhost dev.
|
|
@@ -169,7 +173,7 @@ export function knowless(options = {}) {
|
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
export { createStore } from './store.js';
|
|
172
|
-
export { createMailer, composeBody, validateSubject } from './mailer.js';
|
|
176
|
+
export { createMailer, composeBody, validateSubject, validateBodyFooter } from './mailer.js';
|
|
173
177
|
export { createHandlers } from './handlers.js';
|
|
174
178
|
export { renderLoginForm } from './form.js';
|
|
175
|
-
export { normalize, deriveHandle } from './handle.js';
|
|
179
|
+
export { normalize, deriveHandle, secretBytes } from './handle.js';
|
package/src/mailer.js
CHANGED
|
@@ -52,6 +52,39 @@ function composeRaw({ from, to, subject, body }) {
|
|
|
52
52
|
return `${headers}\r\n\r\n${normalized}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Validate an operator-supplied body footer per AF-8.2.
|
|
57
|
+
*
|
|
58
|
+
* Constraints (deliberately strict to preserve the URL-line invariant
|
|
59
|
+
* and 7bit body encoding from the v0.11 POC finding):
|
|
60
|
+
* - ASCII only
|
|
61
|
+
* - ≤ 240 chars
|
|
62
|
+
* - No CR (LF allowed; line count ≤ 4)
|
|
63
|
+
* - No `http://` / `https://` substring (avoids URL-line confusion
|
|
64
|
+
* and avoids triggering MTA URL-rewriting heuristics)
|
|
65
|
+
*
|
|
66
|
+
* Throws on any violation. Returns the (already-trimmed) footer.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} footer
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
export function validateBodyFooter(footer) {
|
|
72
|
+
if (footer == null || footer === '') return null;
|
|
73
|
+
if (typeof footer !== 'string') {
|
|
74
|
+
throw new Error('bodyFooter must be a string');
|
|
75
|
+
}
|
|
76
|
+
if (footer.length > 240) throw new Error('bodyFooter must be ≤ 240 chars');
|
|
77
|
+
if (!ASCII_RE.test(footer)) throw new Error('bodyFooter must be ASCII');
|
|
78
|
+
if (footer.includes('\r')) throw new Error('bodyFooter must not contain CR');
|
|
79
|
+
if (footer.split('\n').length > 4) {
|
|
80
|
+
throw new Error('bodyFooter must be ≤ 4 lines');
|
|
81
|
+
}
|
|
82
|
+
if (/https?:\/\//i.test(footer)) {
|
|
83
|
+
throw new Error('bodyFooter must not contain URLs (would conflict with the magic-link line)');
|
|
84
|
+
}
|
|
85
|
+
return footer;
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
/**
|
|
56
89
|
* Compose the plain-text body of the magic-link email per SPEC §12.2.
|
|
57
90
|
*
|
|
@@ -68,6 +101,11 @@ function composeRaw({ from, to, subject, body }) {
|
|
|
68
101
|
* Last sign-in: <ISO 8601 UTC timestamp>.
|
|
69
102
|
* If that wasn't you, do not click the link above.
|
|
70
103
|
*
|
|
104
|
+
* Plus, when bodyFooter is provided (AF-8.2):
|
|
105
|
+
*
|
|
106
|
+
* --
|
|
107
|
+
* <footer text>
|
|
108
|
+
*
|
|
71
109
|
* The URL appears on its own line. Body is ASCII-only.
|
|
72
110
|
*
|
|
73
111
|
* @param {object} args
|
|
@@ -75,9 +113,10 @@ function composeRaw({ from, to, subject, body }) {
|
|
|
75
113
|
* @param {string} args.baseUrl e.g. 'https://app.example.com'
|
|
76
114
|
* @param {string} args.linkPath e.g. '/auth/callback'
|
|
77
115
|
* @param {number|null} [args.lastLoginAt] Unix ms; null/undefined to omit
|
|
116
|
+
* @param {string|null} [args.bodyFooter] operator footer; pre-validated
|
|
78
117
|
* @returns {string} the body text (ASCII)
|
|
79
118
|
*/
|
|
80
|
-
export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt }) {
|
|
119
|
+
export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt, bodyFooter }) {
|
|
81
120
|
const url = `${baseUrl}${linkPath}?t=${tokenRaw}`;
|
|
82
121
|
let body =
|
|
83
122
|
'Click to sign in:\n\n' +
|
|
@@ -89,6 +128,11 @@ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt }) {
|
|
|
89
128
|
body +=
|
|
90
129
|
`\nLast sign-in: ${iso}.\n` + 'If that wasn\'t you, do not click the link above.\n';
|
|
91
130
|
}
|
|
131
|
+
if (bodyFooter) {
|
|
132
|
+
// Standard email signature delimiter: "-- " (dash-dash-space) on
|
|
133
|
+
// its own line. Mail clients strip this section from quoted replies.
|
|
134
|
+
body += `\n-- \n${bodyFooter}\n`;
|
|
135
|
+
}
|
|
92
136
|
if (!ASCII_RE.test(body)) {
|
|
93
137
|
throw new Error('mail body contains non-ASCII');
|
|
94
138
|
}
|
package/src/session.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import { secretBytes } from './handle.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Domain-separation tag for session signatures. See SPEC §3.4 / §5.2.
|
|
@@ -22,7 +23,7 @@ export function newSid() {
|
|
|
22
23
|
*/
|
|
23
24
|
function signature(sidB64u, secret) {
|
|
24
25
|
return crypto
|
|
25
|
-
.createHmac('sha256', secret)
|
|
26
|
+
.createHmac('sha256', secretBytes(secret))
|
|
26
27
|
.update(SESS_TAG)
|
|
27
28
|
.update(sidB64u, 'utf8')
|
|
28
29
|
.digest('hex');
|
|
@@ -40,9 +41,6 @@ export function signSession(sidB64u, secret) {
|
|
|
40
41
|
if (typeof sidB64u !== 'string' || !/^[A-Za-z0-9_-]+$/.test(sidB64u)) {
|
|
41
42
|
throw new Error('invalid sid');
|
|
42
43
|
}
|
|
43
|
-
if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
|
|
44
|
-
throw new Error('secret required');
|
|
45
|
-
}
|
|
46
44
|
return `${sidB64u}.${signature(sidB64u, secret)}`;
|
|
47
45
|
}
|
|
48
46
|
|