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 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.4 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
10
+ > v0.1.6 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
11
11
 
12
12
  ## What this is
13
13
 
@@ -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.5",
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
@@ -319,6 +319,7 @@ export function createHandlers({ store, mailer, config }) {
319
319
  baseUrl: cfg.baseUrl,
320
320
  linkPath: cfg.linkPath,
321
321
  lastLoginAt,
322
+ bodyFooter: cfg.bodyFooter,
322
323
  });
323
324
 
324
325
  try {
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.length < 64) {
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