knowless 0.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/src/mailer.js ADDED
@@ -0,0 +1,156 @@
1
+ import crypto from 'node:crypto';
2
+ import nodemailer from 'nodemailer';
3
+
4
+ const ASCII_RE = /^[\x00-\x7f]*$/;
5
+
6
+ /**
7
+ * Compose a fully-formed RFC822 message per SPEC §12.1. Nodemailer's
8
+ * own MimeNode disagreed with our SPEC on Content-Transfer-Encoding
9
+ * (insisted on QP or base64 even for ASCII bodies, breaking the URL
10
+ * with QP soft-breaks per the v0.11 POC finding). We sidestep its
11
+ * encoding by providing the raw message and using nodemailer only as
12
+ * the SMTP submission transport.
13
+ *
14
+ * @param {object} args
15
+ * @param {string} args.from
16
+ * @param {string} args.to
17
+ * @param {string} args.subject
18
+ * @param {string} args.body ASCII-only plain text
19
+ * @returns {string} RFC822 message with CRLF line endings
20
+ */
21
+ function composeRaw({ from, to, subject, body }) {
22
+ const fromDomain = from.includes('@') ? from.split('@').pop() : 'localhost';
23
+ const messageId = `<${crypto.randomUUID()}@${fromDomain}>`;
24
+ const date = new Date().toUTCString();
25
+ const headers = [
26
+ `From: ${from}`,
27
+ `To: ${to}`,
28
+ `Subject: ${subject}`,
29
+ `Date: ${date}`,
30
+ `Message-ID: ${messageId}`,
31
+ 'MIME-Version: 1.0',
32
+ 'Content-Type: text/plain; charset=utf-8',
33
+ 'Content-Transfer-Encoding: 7bit',
34
+ ].join('\r\n');
35
+ const normalized = body.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
36
+ return `${headers}\r\n\r\n${normalized}`;
37
+ }
38
+
39
+ /**
40
+ * Compose the plain-text body of the magic-link email per SPEC §12.2.
41
+ *
42
+ * Body shape (default):
43
+ * Click to sign in:
44
+ *
45
+ * <magic link URL>
46
+ *
47
+ * This link expires in 15 minutes. If you didn't request this,
48
+ * ignore this email.
49
+ *
50
+ * Plus, when lastLoginAt is provided:
51
+ *
52
+ * Last sign-in: <ISO 8601 UTC timestamp>.
53
+ * If that wasn't you, do not click the link above.
54
+ *
55
+ * The URL appears on its own line. Body is ASCII-only.
56
+ *
57
+ * @param {object} args
58
+ * @param {string} args.tokenRaw 43-char base64url token
59
+ * @param {string} args.baseUrl e.g. 'https://app.example.com'
60
+ * @param {string} args.linkPath e.g. '/auth/callback'
61
+ * @param {number|null} [args.lastLoginAt] Unix ms; null/undefined to omit
62
+ * @returns {string} the body text (ASCII)
63
+ */
64
+ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt }) {
65
+ const url = `${baseUrl}${linkPath}?t=${tokenRaw}`;
66
+ let body =
67
+ 'Click to sign in:\n\n' +
68
+ `${url}\n\n` +
69
+ "This link expires in 15 minutes. If you didn't request this,\n" +
70
+ 'ignore this email.\n';
71
+ if (lastLoginAt != null) {
72
+ const iso = new Date(lastLoginAt).toISOString();
73
+ body +=
74
+ `\nLast sign-in: ${iso}.\n` + 'If that wasn\'t you, do not click the link above.\n';
75
+ }
76
+ if (!ASCII_RE.test(body)) {
77
+ throw new Error('mail body contains non-ASCII');
78
+ }
79
+ return body;
80
+ }
81
+
82
+ /**
83
+ * Validate operator-overridden subject per SPEC §12.5.
84
+ * Throws on invalid; warns (returns warnings array) on suspicious-but-allowed.
85
+ *
86
+ * @param {string} subject
87
+ * @returns {string[]} warnings, possibly empty
88
+ */
89
+ export function validateSubject(subject) {
90
+ if (typeof subject !== 'string' || subject.length === 0) {
91
+ throw new Error('subject must be a non-empty string');
92
+ }
93
+ if (subject.length > 60) throw new Error('subject longer than 60 chars');
94
+ if (!ASCII_RE.test(subject)) throw new Error('subject contains non-ASCII');
95
+ const warnings = [];
96
+ const triggers = ['!!', '$$', 'FREE', 'URGENT', 'WINNER'];
97
+ for (const t of triggers) {
98
+ if (subject.includes(t)) warnings.push(`subject contains likely spam trigger: "${t}"`);
99
+ }
100
+ return warnings;
101
+ }
102
+
103
+ /**
104
+ * Create a knowless mailer per SPEC §12.
105
+ *
106
+ * Submits to a localhost MTA over plain SMTP. Forces 7bit encoding,
107
+ * strips X-Mailer, refuses non-ASCII bodies. The submit() method is
108
+ * the only public surface.
109
+ *
110
+ * For tests: pass `transportOverride` (e.g. nodemailer.createTransport
111
+ * with streamTransport:true) to capture the raw bytes without an MTA.
112
+ *
113
+ * @param {object} cfg
114
+ * @param {string} cfg.from sender, e.g. 'auth@app.example.com'
115
+ * @param {string} [cfg.smtpHost='localhost']
116
+ * @param {number} [cfg.smtpPort=25]
117
+ * @param {object} [cfg.transportOverride] for tests
118
+ * @returns {{ submit(args: {to:string, subject:string, body:string}): Promise<any>, close(): void }}
119
+ */
120
+ export function createMailer(cfg) {
121
+ const { from, smtpHost = 'localhost', smtpPort = 25, transportOverride } = cfg;
122
+ if (typeof from !== 'string' || from.length === 0) {
123
+ throw new Error('mailer: from is required');
124
+ }
125
+ if (!ASCII_RE.test(from)) throw new Error('mailer: from must be ASCII');
126
+
127
+ const transport =
128
+ transportOverride ??
129
+ nodemailer.createTransport({
130
+ host: smtpHost,
131
+ port: smtpPort,
132
+ secure: false,
133
+ ignoreTLS: true, // localhost only; SPEC §10.4 / FR-15
134
+ // Safety: refuse SMTP auth — this transport must not carry credentials.
135
+ auth: undefined,
136
+ });
137
+
138
+ return {
139
+ async submit({ to, subject, body }) {
140
+ if (typeof to !== 'string' || !ASCII_RE.test(to)) {
141
+ throw new Error('mailer: recipient must be ASCII');
142
+ }
143
+ if (!ASCII_RE.test(body)) {
144
+ throw new Error('mailer: body must be ASCII');
145
+ }
146
+ const raw = composeRaw({ from, to, subject, body });
147
+ return transport.sendMail({
148
+ envelope: { from, to: [to] },
149
+ raw,
150
+ });
151
+ },
152
+ close() {
153
+ if (typeof transport.close === 'function') transport.close();
154
+ },
155
+ };
156
+ }
package/src/session.js ADDED
@@ -0,0 +1,75 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ /**
4
+ * Domain-separation tag for session signatures. See SPEC §3.4 / §5.2.
5
+ * The trailing 0x00 prevents prefix-collision with future HMAC uses
6
+ * of the same secret.
7
+ */
8
+ const SESS_TAG = Buffer.from('sess\x00');
9
+
10
+ /**
11
+ * Generate a new session id: 32 random bytes, base64url-encoded.
12
+ * @returns {string} 43-char base64url
13
+ */
14
+ export function newSid() {
15
+ return crypto.randomBytes(32).toString('base64url');
16
+ }
17
+
18
+ /**
19
+ * @param {string} sidB64u
20
+ * @param {Buffer|string} secret
21
+ * @returns {string} 64-char lowercase hex
22
+ */
23
+ function signature(sidB64u, secret) {
24
+ return crypto
25
+ .createHmac('sha256', secret)
26
+ .update(SESS_TAG)
27
+ .update(sidB64u, 'utf8')
28
+ .digest('hex');
29
+ }
30
+
31
+ /**
32
+ * Sign a sid into a session cookie value per SPEC §5.1.
33
+ * Cookie format: <sid_b64u>.<sig_hex> (108 chars total).
34
+ *
35
+ * @param {string} sidB64u 43-char base64url sid (typically from newSid())
36
+ * @param {Buffer|string} secret operator HMAC secret
37
+ * @returns {string} cookie value
38
+ */
39
+ export function signSession(sidB64u, secret) {
40
+ if (typeof sidB64u !== 'string' || !/^[A-Za-z0-9_-]+$/.test(sidB64u)) {
41
+ throw new Error('invalid sid');
42
+ }
43
+ if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
44
+ throw new Error('secret required');
45
+ }
46
+ return `${sidB64u}.${signature(sidB64u, secret)}`;
47
+ }
48
+
49
+ /**
50
+ * Verify a cookie value's signature per SPEC §5.5.
51
+ * Returns the sid_b64u string on success; null on any failure (bad
52
+ * format, signature mismatch, malformed inputs). Caller does the
53
+ * DB lookup that resolves sid → handle.
54
+ *
55
+ * Constant-time comparison via crypto.timingSafeEqual.
56
+ *
57
+ * @param {string} cookie cookie value: <sid>.<sig>
58
+ * @param {Buffer|string} secret operator HMAC secret
59
+ * @returns {string|null}
60
+ */
61
+ export function verifySessionSignature(cookie, secret) {
62
+ if (typeof cookie !== 'string' || cookie.length === 0) return null;
63
+ const dot = cookie.indexOf('.');
64
+ if (dot < 0) return null;
65
+ const sidB64u = cookie.slice(0, dot);
66
+ const sigHex = cookie.slice(dot + 1);
67
+ if (sigHex.length !== 64) return null;
68
+ if (!/^[a-f0-9]{64}$/.test(sigHex)) return null;
69
+ if (sidB64u.length === 0 || !/^[A-Za-z0-9_-]+$/.test(sidB64u)) return null;
70
+ const expected = signature(sidB64u, secret);
71
+ const a = Buffer.from(sigHex, 'hex');
72
+ const b = Buffer.from(expected, 'hex');
73
+ if (!crypto.timingSafeEqual(a, b)) return null;
74
+ return sidB64u;
75
+ }
package/src/store.js ADDED
@@ -0,0 +1,265 @@
1
+ import Database from 'better-sqlite3';
2
+
3
+ /**
4
+ * Default token-sweeper grace: keep used tokens for 24h after redemption
5
+ * to support audit correlation, then delete. SPEC §4.6.
6
+ */
7
+ const DEFAULT_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
8
+
9
+ const SCHEMA_VERSION = '1';
10
+
11
+ const DDL = `
12
+ CREATE TABLE IF NOT EXISTS handles (
13
+ handle TEXT PRIMARY KEY,
14
+ last_login_at INTEGER
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS tokens (
18
+ token_hash TEXT PRIMARY KEY,
19
+ handle TEXT NOT NULL,
20
+ expires_at INTEGER NOT NULL,
21
+ used_at INTEGER,
22
+ next_url TEXT,
23
+ is_sham INTEGER NOT NULL DEFAULT 0
24
+ );
25
+ CREATE INDEX IF NOT EXISTS idx_tokens_expires ON tokens(expires_at);
26
+ CREATE INDEX IF NOT EXISTS idx_tokens_handle ON tokens(handle);
27
+
28
+ CREATE TABLE IF NOT EXISTS sessions (
29
+ sid_hash TEXT PRIMARY KEY,
30
+ handle TEXT NOT NULL,
31
+ expires_at INTEGER NOT NULL
32
+ );
33
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
34
+
35
+ CREATE TABLE IF NOT EXISTS rate_limits (
36
+ scope TEXT NOT NULL,
37
+ key TEXT NOT NULL,
38
+ window_start INTEGER NOT NULL,
39
+ count INTEGER NOT NULL,
40
+ PRIMARY KEY (scope, key, window_start)
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window_start);
43
+
44
+ CREATE TABLE IF NOT EXISTS meta (
45
+ key TEXT PRIMARY KEY,
46
+ value TEXT NOT NULL
47
+ );
48
+ `;
49
+
50
+ /**
51
+ * Create a knowless storage backend. SPEC §6 (schema), §13 (interface).
52
+ *
53
+ * @param {string} [dbPath=':memory:'] path to SQLite file, or ':memory:'
54
+ * @returns {Store}
55
+ */
56
+ export function createStore(dbPath = ':memory:') {
57
+ const db = new Database(dbPath);
58
+ db.pragma('journal_mode = WAL');
59
+ db.pragma('synchronous = NORMAL');
60
+ db.pragma('foreign_keys = OFF');
61
+ db.pragma('temp_store = MEMORY');
62
+ db.exec(DDL);
63
+
64
+ const existing = db
65
+ .prepare("SELECT value FROM meta WHERE key = 'schema_version'")
66
+ .get();
67
+ if (!existing) {
68
+ db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(
69
+ SCHEMA_VERSION,
70
+ );
71
+ } else if (existing.value !== SCHEMA_VERSION) {
72
+ throw new Error(`unsupported schema_version: ${existing.value}`);
73
+ }
74
+
75
+ const stmt = {
76
+ handleExists: db.prepare('SELECT 1 AS one FROM handles WHERE handle = ?'),
77
+ upsertHandleNoLogin: db.prepare(
78
+ `INSERT INTO handles (handle, last_login_at) VALUES (?, NULL)
79
+ ON CONFLICT(handle) DO NOTHING`,
80
+ ),
81
+ upsertLastLogin: db.prepare(
82
+ `INSERT INTO handles (handle, last_login_at) VALUES (?, ?)
83
+ ON CONFLICT(handle) DO UPDATE SET last_login_at = excluded.last_login_at`,
84
+ ),
85
+ getLastLogin: db.prepare(
86
+ 'SELECT last_login_at AS lastLoginAt FROM handles WHERE handle = ?',
87
+ ),
88
+ deleteHandleRow: db.prepare('DELETE FROM handles WHERE handle = ?'),
89
+ deleteHandleTokens: db.prepare('DELETE FROM tokens WHERE handle = ?'),
90
+ deleteHandleSessions: db.prepare('DELETE FROM sessions WHERE handle = ?'),
91
+
92
+ insertToken: db.prepare(
93
+ `INSERT INTO tokens (token_hash, handle, expires_at, used_at, next_url, is_sham)
94
+ VALUES (?, ?, ?, NULL, ?, ?)`,
95
+ ),
96
+ getToken: db.prepare(
97
+ `SELECT handle, expires_at AS expiresAt, used_at AS usedAt,
98
+ next_url AS nextUrl, is_sham AS isSham
99
+ FROM tokens WHERE token_hash = ?`,
100
+ ),
101
+ markTokenUsed: db.prepare(
102
+ `UPDATE tokens SET used_at = ?
103
+ WHERE token_hash = ? AND used_at IS NULL`,
104
+ ),
105
+ countActiveTokens: db.prepare(
106
+ `SELECT COUNT(*) AS n FROM tokens
107
+ WHERE handle = ? AND used_at IS NULL AND expires_at > ?`,
108
+ ),
109
+ evictOldestActive: db.prepare(
110
+ `DELETE FROM tokens
111
+ WHERE token_hash = (
112
+ SELECT token_hash FROM tokens
113
+ WHERE handle = ? AND used_at IS NULL AND expires_at > ?
114
+ ORDER BY expires_at ASC LIMIT 1
115
+ )`,
116
+ ),
117
+ sweepTokens: db.prepare(
118
+ `DELETE FROM tokens
119
+ WHERE expires_at <= ?
120
+ OR (used_at IS NOT NULL AND used_at <= ?)`,
121
+ ),
122
+
123
+ insertSession: db.prepare(
124
+ 'INSERT INTO sessions (sid_hash, handle, expires_at) VALUES (?, ?, ?)',
125
+ ),
126
+ getSession: db.prepare(
127
+ 'SELECT handle, expires_at AS expiresAt FROM sessions WHERE sid_hash = ?',
128
+ ),
129
+ deleteSession: db.prepare('DELETE FROM sessions WHERE sid_hash = ?'),
130
+ sweepSessions: db.prepare('DELETE FROM sessions WHERE expires_at <= ?'),
131
+
132
+ rateLimitIncrement: db.prepare(
133
+ `INSERT INTO rate_limits (scope, key, window_start, count)
134
+ VALUES (?, ?, ?, 1)
135
+ ON CONFLICT(scope, key, window_start)
136
+ DO UPDATE SET count = count + 1
137
+ RETURNING count`,
138
+ ),
139
+ rateLimitGet: db.prepare(
140
+ `SELECT count FROM rate_limits
141
+ WHERE scope = ? AND key = ? AND window_start = ?`,
142
+ ),
143
+ sweepRateLimits: db.prepare('DELETE FROM rate_limits WHERE window_start < ?'),
144
+ };
145
+
146
+ // Transactional cap-check + insert per SPEC §4.7.
147
+ const insertTokenAtomic = db.transaction(
148
+ (tokenHash, handle, expiresAt, nextUrl, isSham, maxActive, now) => {
149
+ if (maxActive > 0) {
150
+ const { n: count } = stmt.countActiveTokens.get(handle, now);
151
+ let toEvict = count - maxActive + 1;
152
+ while (toEvict > 0) {
153
+ stmt.evictOldestActive.run(handle, now);
154
+ toEvict--;
155
+ }
156
+ }
157
+ stmt.insertToken.run(tokenHash, handle, expiresAt, nextUrl, isSham);
158
+ },
159
+ );
160
+
161
+ // Transactional account deletion per FR-37a.
162
+ const deleteHandleAtomic = db.transaction((handle) => {
163
+ stmt.deleteHandleSessions.run(handle);
164
+ stmt.deleteHandleTokens.run(handle);
165
+ stmt.deleteHandleRow.run(handle);
166
+ });
167
+
168
+ return {
169
+ // --- Handle ---
170
+ handleExists(handle) {
171
+ return !!stmt.handleExists.get(handle);
172
+ },
173
+ upsertHandle(handle) {
174
+ stmt.upsertHandleNoLogin.run(handle);
175
+ },
176
+ deleteHandle(handle) {
177
+ deleteHandleAtomic(handle);
178
+ },
179
+
180
+ // --- Token ---
181
+ insertToken(args) {
182
+ const {
183
+ tokenHash,
184
+ handle,
185
+ expiresAt,
186
+ nextUrl = null,
187
+ isSham = false,
188
+ maxActive = 0,
189
+ now = Date.now(),
190
+ } = args;
191
+ insertTokenAtomic(
192
+ tokenHash,
193
+ handle,
194
+ expiresAt,
195
+ nextUrl,
196
+ isSham ? 1 : 0,
197
+ maxActive,
198
+ now,
199
+ );
200
+ },
201
+ getToken(tokenHash) {
202
+ const row = stmt.getToken.get(tokenHash);
203
+ if (!row) return null;
204
+ return {
205
+ handle: row.handle,
206
+ expiresAt: row.expiresAt,
207
+ usedAt: row.usedAt,
208
+ nextUrl: row.nextUrl,
209
+ isSham: row.isSham === 1,
210
+ };
211
+ },
212
+ markTokenUsed(tokenHash, usedAt) {
213
+ return stmt.markTokenUsed.run(usedAt, tokenHash).changes > 0;
214
+ },
215
+ countActiveTokens(handle, now = Date.now()) {
216
+ return stmt.countActiveTokens.get(handle, now).n;
217
+ },
218
+ evictOldestActiveToken(handle, now = Date.now()) {
219
+ return stmt.evictOldestActive.run(handle, now).changes;
220
+ },
221
+ sweepTokens(now = Date.now(), graceMs = DEFAULT_TOKEN_GRACE_MS) {
222
+ return stmt.sweepTokens.run(now, now - graceMs).changes;
223
+ },
224
+
225
+ // --- Last login ---
226
+ upsertLastLogin(handle, at) {
227
+ stmt.upsertLastLogin.run(handle, at);
228
+ },
229
+ getLastLogin(handle) {
230
+ const row = stmt.getLastLogin.get(handle);
231
+ return row ? row.lastLoginAt : null;
232
+ },
233
+
234
+ // --- Session ---
235
+ insertSession(sidHash, handle, expiresAt) {
236
+ stmt.insertSession.run(sidHash, handle, expiresAt);
237
+ },
238
+ getSession(sidHash) {
239
+ return stmt.getSession.get(sidHash) ?? null;
240
+ },
241
+ deleteSession(sidHash) {
242
+ return stmt.deleteSession.run(sidHash).changes > 0;
243
+ },
244
+ sweepSessions(now = Date.now()) {
245
+ return stmt.sweepSessions.run(now).changes;
246
+ },
247
+
248
+ // --- Rate limiting ---
249
+ rateLimitIncrement(scope, key, windowStart) {
250
+ return stmt.rateLimitIncrement.get(scope, key, windowStart).count;
251
+ },
252
+ rateLimitGet(scope, key, windowStart) {
253
+ const row = stmt.rateLimitGet.get(scope, key, windowStart);
254
+ return row ? row.count : 0;
255
+ },
256
+ sweepRateLimits(olderThan) {
257
+ return stmt.sweepRateLimits.run(olderThan).changes;
258
+ },
259
+
260
+ // --- Lifecycle ---
261
+ close() {
262
+ db.close();
263
+ },
264
+ };
265
+ }
package/src/token.js ADDED
@@ -0,0 +1,38 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ /**
4
+ * Issue a magic-link token per SPEC §4.1.
5
+ *
6
+ * Returns the raw token (for embedding in the email URL) and the hash
7
+ * (for storage). Raw bytes never touch persistent storage; only the
8
+ * hash does. See FR-13, FR-34.
9
+ *
10
+ * @returns {{ raw: string, hash: string }}
11
+ * raw — 43-char base64url (32 bytes, no padding)
12
+ * hash — 64-char lowercase hex (SHA-256 of the 32 raw bytes)
13
+ */
14
+ export function issueToken() {
15
+ const bytes = crypto.randomBytes(32);
16
+ return {
17
+ raw: bytes.toString('base64url'),
18
+ hash: crypto.createHash('sha256').update(bytes).digest('hex'),
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Hash a raw token for store lookup per SPEC §4.5.
24
+ *
25
+ * Returns null on malformed input (wrong length, wrong alphabet, decode
26
+ * fail). Caller treats null exactly like "no row found" — the silent
27
+ * failure path of verifyToken.
28
+ *
29
+ * @param {string} raw 43-char base64url token from a magic link
30
+ * @returns {string|null} 64-char lowercase hex hash, or null
31
+ */
32
+ export function hashToken(raw) {
33
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 64) return null;
34
+ if (!/^[A-Za-z0-9_-]+$/.test(raw)) return null;
35
+ const bytes = Buffer.from(raw, 'base64url');
36
+ if (bytes.length !== 32) return null;
37
+ return crypto.createHash('sha256').update(bytes).digest('hex');
38
+ }