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/CHANGELOG.md +142 -0
- package/GUIDE.md +441 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +167 -0
- package/knowless.context.md +429 -0
- package/package.json +48 -0
- package/src/abuse.js +80 -0
- package/src/form.js +102 -0
- package/src/handle.js +51 -0
- package/src/handlers.js +383 -0
- package/src/index.js +132 -0
- package/src/mailer.js +156 -0
- package/src/session.js +75 -0
- package/src/store.js +265 -0
- package/src/token.js +38 -0
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
|
+
}
|