knowless 0.1.5 → 0.1.7

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,87 @@ 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.7] — 2026-04-28
13
+
14
+ addypin integration round 3 — one small API addition.
15
+
16
+ ### Added
17
+
18
+ - **`subjectOverride` arg on `auth.startLogin`.** Per-call subject
19
+ replaces the factory `subject` for that one mail. Validated by
20
+ the same rules (ASCII, ≤60 chars, no CR/LF) and throws on invalid
21
+ — programmer error, not silent miss. The subject is decided
22
+ before the hit/miss branch, so sham and real submissions carry
23
+ the same subject and no observer can distinguish outcomes by
24
+ subject. Spam-trigger warnings (`!!`, `FREE`, etc.) do not throw;
25
+ the caller has more context. Closes AF-9.1.
26
+
27
+ Example: addypin sends three magic-link variants with distinct
28
+ subjects:
29
+
30
+ ```js
31
+ await auth.startLogin({
32
+ email, nextUrl, sourceIp,
33
+ subjectOverride: `Confirm your pin: ${shortcode}`,
34
+ });
35
+ ```
36
+
37
+ ## [0.1.6] — 2026-04-28
38
+
39
+ addypin integration round 2 — one correctness fix (HMAC key handling)
40
+ and one common-want feature (operator footer on auth mail).
41
+
42
+ ### Breaking
43
+
44
+ - **The `secret` is now hex-decoded before being used as the HMAC
45
+ key.** Prior versions passed the 64-char hex string to
46
+ `crypto.createHmac` as ASCII bytes — same 256 bits of entropy, but
47
+ a different HMAC output than systems that hex-decode first. The
48
+ PRD already implied 32 bytes ("≥64 hex chars (32 bytes)"); the
49
+ implementation matched the spec on key length but used the wrong
50
+ key bytes. **Effect:** every handle and session signature changes
51
+ on upgrade. Existing sessions invalidate (users re-login); existing
52
+ pre-seeded handles must be re-derived. There are no production
53
+ knowless deployments yet (addypin and webrevival are both pre-prod),
54
+ so we lock the correct semantics in before v1.0 freezes. Closes
55
+ AF-8.1.
56
+ - The startup secret check now also validates that the secret is
57
+ 64-char lowercase hex (`/^[a-f0-9]{64,}$/i`). Mixed-case secrets
58
+ must be lowercased.
59
+ - `deriveHandle()` and `signSession()` accept `Buffer` directly for
60
+ adopters who already hold raw 32-byte keys.
61
+
62
+ ### Added
63
+
64
+ - **`bodyFooter: string`** config option — append a constant operator
65
+ footer to every magic-link email after the standard `"-- "` (RFC
66
+ 3676) signature delimiter. Constraints (deliberately strict to
67
+ preserve the URL-line invariant and 7bit body encoding):
68
+ - ASCII only (no unicode middle-dot — use `|` or `-`)
69
+ - ≤ 240 chars, ≤ 4 lines
70
+ - No CR
71
+ - No `http://` / `https://` substrings (would conflict with the
72
+ magic-link line and trigger MTA URL-rewriting heuristics)
73
+ Validated at factory startup; fails fast on misconfiguration.
74
+ Closes AF-8.2.
75
+ - `validateBodyFooter()` exported alongside `composeBody` for adopters
76
+ who want to validate operator-supplied footers themselves.
77
+ - `secretBytes()` exported from `./handle.js` (and via the package
78
+ root) for adopters who want to coerce a hex string to raw 32-byte
79
+ HMAC key on their own boundaries.
80
+
81
+ ### Migration from 0.1.5
82
+
83
+ - **Pre-seeded handles must be re-derived.** `deriveHandle('alice@x',
84
+ SECRET)` returns a different value in 0.1.6. If you stored handles
85
+ in any external system, recompute them. Closed-registration users
86
+ must re-seed.
87
+ - **Active sessions invalidate.** Users will need to log in again
88
+ after the upgrade. Plan for a single-shot user-visible logout.
89
+ - **Magic links in flight at upgrade time** become invalid (token
90
+ hashes are stored as HMAC outputs and the key changes). 15 min
91
+ TTL by default; a brief read-only window during deploy is enough.
11
92
  - `knowless-server --check-null-route`: CLI probe that submits a
12
93
  test message to `shamRecipient` and confirms the local MTA
13
94
  discarded it. Honest answer to "does the operator's null-route
package/GUIDE.md CHANGED
@@ -267,11 +267,14 @@ share link," "submit a paste" — patterns where forcing a login
267
267
  app.post('/api/pins', async (req, res) => {
268
268
  const { email, lat, lng } = await readJsonBody(req);
269
269
  const owner = auth.deriveHandle(email); // AF-7.4
270
- await db.insertPendingPin({ owner, lat, lng }); // your code
270
+ const shortcode = await db.insertPendingPin({ owner, lat, lng });
271
271
  await auth.startLogin({ // AF-7.3
272
272
  email,
273
273
  nextUrl: 'https://app.example.com/manage',
274
274
  sourceIp: req.socket.remoteAddress,
275
+ // Per-call subject so the user can tell at a glance this is a
276
+ // pin-confirmation, not a routine login. AF-9.
277
+ subjectOverride: `Confirm your pin: ${shortcode}`,
275
278
  });
276
279
  res.status(202).end(); // "we'll email you the link"
277
280
  });
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.7 | 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
@@ -141,7 +147,7 @@ const auth = knowless({
141
147
  | `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
142
148
  | `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
143
149
  | `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
144
- | `startLogin` | ({email, nextUrl?, sourceIp?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. SPEC §7.3a. AF-7.3. |
150
+ | `startLogin` | ({email, nextUrl?, sourceIp?, subjectOverride?}) | 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` for this call. SPEC §7.3a. AF-7.3. |
145
151
  | `deriveHandle` | (email: string) | string | HMAC-SHA256(secret, normalize(email)) using the configured secret. Use to compute owner-handles outside HTTP context. AF-7.4. |
146
152
  | `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
147
153
  | `config` | -- | object | Merged effective config; safe to read (do not mutate) |
@@ -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.7",
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
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import { normalize, deriveHandle } from './handle.js';
3
3
  import { issueToken, hashToken } from './token.js';
4
4
  import { newSid, signSession, verifySessionSignature } from './session.js';
5
- import { composeBody } from './mailer.js';
5
+ import { composeBody, validateSubject } from './mailer.js';
6
6
  import { renderLoginForm } from './form.js';
7
7
  import {
8
8
  buildTrustedPeers,
@@ -243,7 +243,7 @@ export function createHandlers({ store, mailer, config }) {
243
243
  * handle is null only when the email failed to normalize (programmer
244
244
  * bug for startLogin; same-shape silent for /login).
245
245
  */
246
- async function runSendLink({ emailRaw, nextRaw, sourceIp }) {
246
+ async function runSendLink({ emailRaw, nextRaw, sourceIp, subject }) {
247
247
  // Step 1: parse + normalize
248
248
  let emailNorm;
249
249
  try {
@@ -319,10 +319,17 @@ 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
 
325
+ // AF-9: programmatic callers may override the subject per call
326
+ // (addypin sends confirmation / login / expiry-warning all via
327
+ // magic links and needs distinct subjects). Decision happens
328
+ // BEFORE the hit/miss branch — same subject for sham and real,
329
+ // so timing equivalence is preserved.
330
+ const effectiveSubject = subject ?? cfg.subject;
324
331
  try {
325
- await mailer.submit({ to: toAddress, subject: cfg.subject, body: mailBody });
332
+ await mailer.submit({ to: toAddress, subject: effectiveSubject, body: mailBody });
326
333
  } catch (err) {
327
334
  // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
328
335
  console.error('[knowless] mail submit failed:', err.message);
@@ -392,7 +399,12 @@ export function createHandlers({ store, mailer, config }) {
392
399
  sameResponse(res, result.emailNorm, result.nextValidated ?? '');
393
400
  }
394
401
 
395
- async function startLogin({ email, nextUrl, sourceIp = '' } = {}) {
402
+ async function startLogin({
403
+ email,
404
+ nextUrl,
405
+ sourceIp = '',
406
+ subjectOverride,
407
+ } = {}) {
396
408
  // Programmer-error guards (AF-7.3). These DO throw; they're not
397
409
  // silent-miss conditions, they're "you called the API wrong."
398
410
  if (typeof email !== 'string' || email.length === 0) {
@@ -404,10 +416,21 @@ export function createHandlers({ store, mailer, config }) {
404
416
  if (typeof sourceIp !== 'string') {
405
417
  throw new Error('startLogin: sourceIp must be a string when provided');
406
418
  }
419
+ // AF-9: per-call subject override. Validated with the same rules as
420
+ // the factory subject (ASCII, ≤60 chars, no CR/LF). Throws on
421
+ // invalid — same "programmer-error" treatment as other startLogin
422
+ // arg validation. Spam-trigger warnings are NOT thrown for; the
423
+ // caller has more context than knowless about what's appropriate.
424
+ let subject;
425
+ if (subjectOverride !== undefined && subjectOverride !== null) {
426
+ validateSubject(subjectOverride); // throws on invalid
427
+ subject = subjectOverride;
428
+ }
407
429
  const { handle } = await runSendLink({
408
430
  emailRaw: email,
409
431
  nextRaw: nextUrl ?? null,
410
432
  sourceIp,
433
+ subject,
411
434
  });
412
435
  // Same-shape return: rate-limit / sham / real all collapse here.
413
436
  // `handle` is the HMAC of the normalized email (or null if email
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