onehitter 2.0.7 → 2.0.8
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/dist/cjs/db/mongodb-functions.js +3 -2
- package/dist/cjs/db/shared.js +24 -1
- package/dist/cjs/db/sqlite-functions.js +6 -4
- package/dist/cjs/sender.js +7 -7
- package/dist/esm/db/mongodb-functions.js +4 -3
- package/dist/esm/db/shared.js +22 -0
- package/dist/esm/db/sqlite-functions.js +7 -5
- package/dist/esm/sender.js +6 -7
- package/dist/types/db/mongodb-functions.d.ts +1 -1
- package/dist/types/db/shared.d.ts +11 -0
- package/dist/types/sender.d.ts +1 -0
- package/package.json +1 -1
|
@@ -15,7 +15,7 @@ const otpCreate = async (client, otp) => {
|
|
|
15
15
|
throw new Error('otpCreate does not accept an _id; it will be generated by MongoDB');
|
|
16
16
|
}
|
|
17
17
|
const doc = {
|
|
18
|
-
|
|
18
|
+
contactId: (0, shared_1.computeContactId)(otp.contact),
|
|
19
19
|
otpHash: (0, shared_1.computeOtpHash)(otp.contact, otp.otp),
|
|
20
20
|
createdAt: otp.createdAt,
|
|
21
21
|
};
|
|
@@ -35,8 +35,9 @@ const otpValidateWithStatus = async (client, otp, now = new Date(), ttlSeconds)
|
|
|
35
35
|
const database = client.db(process.env.OTP_MONGO_DATABASE);
|
|
36
36
|
const cursor = database.collection(process.env.OTP_MONGO_COLLECTION);
|
|
37
37
|
const otpHash = (0, shared_1.computeOtpHash)(otp.contact, otp.otp);
|
|
38
|
+
const contactId = (0, shared_1.computeContactId)(otp.contact);
|
|
38
39
|
// Delete matching doc and retrieve the deleted document for inspection
|
|
39
|
-
const res = await cursor.findOneAndDelete({
|
|
40
|
+
const res = await cursor.findOneAndDelete({ contactId, otpHash });
|
|
40
41
|
const deleted = res && (res.createdAt ? res : (res.value ?? null));
|
|
41
42
|
if (!deleted)
|
|
42
43
|
return 'not_found';
|
package/dist/cjs/db/shared.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.computeOtpHash = exports.SQLITE_PATH = void 0;
|
|
3
|
+
exports.computeContactId = exports.computeOtpHash = exports.SQLITE_PATH = void 0;
|
|
4
4
|
exports.currentDriver = currentDriver;
|
|
5
5
|
function currentDriver() {
|
|
6
6
|
const d = (process.env.OTP_DB_DRIVER);
|
|
@@ -26,3 +26,26 @@ const computeOtpHash = (contact, otp, opts) => {
|
|
|
26
26
|
return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
|
|
27
27
|
};
|
|
28
28
|
exports.computeOtpHash = computeOtpHash;
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic contact identifier used in storage.
|
|
31
|
+
*
|
|
32
|
+
* This function derives a pseudonymous ID from the contact string using the
|
|
33
|
+
* same peppered HMAC/Hash strategy as `computeOtpHash`, but without the OTP
|
|
34
|
+
* component. It allows equality comparison (lookups) without storing the
|
|
35
|
+
* original contact value in the database.
|
|
36
|
+
*/
|
|
37
|
+
const computeContactId = (contact, opts) => {
|
|
38
|
+
const pepper = process.env.OTP_PEPPER || '';
|
|
39
|
+
const salt = opts?.salt || '';
|
|
40
|
+
const allowInsecure = process.env.ONEHITTER_ALLOW_INSECURE_HASH === 'true';
|
|
41
|
+
if (process.env.NODE_ENV === 'production' && !pepper && !allowInsecure) {
|
|
42
|
+
throw new Error('Security requirement: OTP_PEPPER must be set in production to HMAC-protect contact identifiers and OTP hashes (override with ONEHITTER_ALLOW_INSECURE_HASH=true for non-prod-like runs)');
|
|
43
|
+
}
|
|
44
|
+
const message = salt ? `${contact}|${salt}` : contact;
|
|
45
|
+
const crypto = require('crypto');
|
|
46
|
+
if (pepper) {
|
|
47
|
+
return crypto.createHmac('sha256', pepper).update(message, 'utf8').digest('hex');
|
|
48
|
+
}
|
|
49
|
+
return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
|
|
50
|
+
};
|
|
51
|
+
exports.computeContactId = computeContactId;
|
|
@@ -13,11 +13,11 @@ function getDb() {
|
|
|
13
13
|
db.serialize(() => {
|
|
14
14
|
db.run('CREATE TABLE IF NOT EXISTS otp (\n' +
|
|
15
15
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,\n' +
|
|
16
|
-
'
|
|
16
|
+
' contactId TEXT NOT NULL,\n' +
|
|
17
17
|
' otpHash TEXT NOT NULL,\n' +
|
|
18
18
|
' createdAt INTEGER NOT NULL\n' +
|
|
19
19
|
')');
|
|
20
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(
|
|
20
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(contactId, otpHash)');
|
|
21
21
|
db.run('CREATE INDEX IF NOT EXISTS idx_otp_createdAt ON otp(createdAt)');
|
|
22
22
|
});
|
|
23
23
|
return db;
|
|
@@ -26,8 +26,9 @@ const otpCreate = async (otp) => {
|
|
|
26
26
|
const database = getDb();
|
|
27
27
|
const createdAt = otp.createdAt ? otp.createdAt.getTime() : Date.now();
|
|
28
28
|
const otpHash = (0, shared_1.computeOtpHash)(otp.contact, otp.otp);
|
|
29
|
+
const contactId = (0, shared_1.computeContactId)(otp.contact);
|
|
29
30
|
return await new Promise((resolve, reject) => {
|
|
30
|
-
database.run('INSERT INTO otp (
|
|
31
|
+
database.run('INSERT INTO otp (contactId, otpHash, createdAt) VALUES (?, ?, ?)', [contactId, otpHash, createdAt], function (err) {
|
|
31
32
|
if (err)
|
|
32
33
|
return reject(err);
|
|
33
34
|
// Shape it like a Mongo InsertOneResult enough for callers
|
|
@@ -39,10 +40,11 @@ exports.otpCreate = otpCreate;
|
|
|
39
40
|
const otpValidateWithStatus = async (otp, now = new Date(), ttlSeconds) => {
|
|
40
41
|
const database = getDb();
|
|
41
42
|
const otpHash = (0, shared_1.computeOtpHash)(otp.contact, otp.otp);
|
|
43
|
+
const contactId = (0, shared_1.computeContactId)(otp.contact);
|
|
42
44
|
return await new Promise((resolve, reject) => {
|
|
43
45
|
// Single-statement atomicity: select the newest id, then conditionally delete it.
|
|
44
46
|
// We avoid explicit BEGIN/COMMIT to prevent nested transaction errors under concurrency.
|
|
45
|
-
database.get('SELECT id, createdAt FROM otp WHERE
|
|
47
|
+
database.get('SELECT id, createdAt FROM otp WHERE contactId = ? AND otpHash = ? ORDER BY id DESC LIMIT 1', [contactId, otpHash], function (err, row) {
|
|
46
48
|
if (err)
|
|
47
49
|
return reject(err);
|
|
48
50
|
if (!row)
|
package/dist/cjs/sender.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createSesV2Transport = createSesV2Transport;
|
|
6
7
|
const nodemailer_1 = __importDefault(require("nodemailer"));
|
|
7
8
|
const config_1 = require("./config");
|
|
8
9
|
/**
|
|
@@ -113,20 +114,16 @@ Once used, this one-time password can not be used again. That's why it's called
|
|
|
113
114
|
* @throws {Error} If the recipient (`to`) or the sender (`from`) address is missing.
|
|
114
115
|
*/
|
|
115
116
|
function createSesTransport(region) {
|
|
116
|
-
//
|
|
117
|
+
// Force SESv2 path only; do not use legacy SES
|
|
117
118
|
try {
|
|
118
119
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
119
120
|
const sesv2 = require('@aws-sdk/client-sesv2');
|
|
120
121
|
const client = new sesv2.SESv2Client({ region });
|
|
121
|
-
// Nodemailer v7 expects SESv2 via options.SES with { sesClient, SendEmailCommand }
|
|
122
122
|
const opts = { SES: { sesClient: client, SendEmailCommand: sesv2.SendEmailCommand } };
|
|
123
123
|
return nodemailer_1.default.createTransport(opts);
|
|
124
124
|
}
|
|
125
|
-
catch {
|
|
126
|
-
|
|
127
|
-
const aws = require('@aws-sdk/client-ses');
|
|
128
|
-
const legacy = new aws.SES({ apiVersion: '2010-12-01', region });
|
|
129
|
-
return nodemailer_1.default.createTransport({ SES: { ses: legacy, aws } });
|
|
125
|
+
catch (err) {
|
|
126
|
+
throw new Error('SESv2 client not available. Install @aws-sdk/client-sesv2 to use OneHitter sender, or pass a Nodemailer transporter explicitly.');
|
|
130
127
|
}
|
|
131
128
|
}
|
|
132
129
|
async function send(to, otp, url, expiry, message, opts) {
|
|
@@ -147,4 +144,7 @@ async function send(to, otp, url, expiry, message, opts) {
|
|
|
147
144
|
...(text ? { text } : {}),
|
|
148
145
|
});
|
|
149
146
|
}
|
|
147
|
+
function createSesV2Transport(region) {
|
|
148
|
+
return createSesTransport(region);
|
|
149
|
+
}
|
|
150
150
|
exports.default = send;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computeOtpHash } from './shared';
|
|
1
|
+
import { computeOtpHash, computeContactId } from './shared';
|
|
2
2
|
export const otpCreate = async (client, otp) => {
|
|
3
3
|
if (!process.env.OTP_MONGO_DATABASE || !process.env.OTP_MONGO_COLLECTION) {
|
|
4
4
|
throw new Error('Missing OTP_MONGO_DATABASE or OTP_MONGO_COLLECTION');
|
|
@@ -12,7 +12,7 @@ export const otpCreate = async (client, otp) => {
|
|
|
12
12
|
throw new Error('otpCreate does not accept an _id; it will be generated by MongoDB');
|
|
13
13
|
}
|
|
14
14
|
const doc = {
|
|
15
|
-
|
|
15
|
+
contactId: computeContactId(otp.contact),
|
|
16
16
|
otpHash: computeOtpHash(otp.contact, otp.otp),
|
|
17
17
|
createdAt: otp.createdAt,
|
|
18
18
|
};
|
|
@@ -31,8 +31,9 @@ export const otpValidateWithStatus = async (client, otp, now = new Date(), ttlSe
|
|
|
31
31
|
const database = client.db(process.env.OTP_MONGO_DATABASE);
|
|
32
32
|
const cursor = database.collection(process.env.OTP_MONGO_COLLECTION);
|
|
33
33
|
const otpHash = computeOtpHash(otp.contact, otp.otp);
|
|
34
|
+
const contactId = computeContactId(otp.contact);
|
|
34
35
|
// Delete matching doc and retrieve the deleted document for inspection
|
|
35
|
-
const res = await cursor.findOneAndDelete({
|
|
36
|
+
const res = await cursor.findOneAndDelete({ contactId, otpHash });
|
|
36
37
|
const deleted = res && (res.createdAt ? res : (res.value ?? null));
|
|
37
38
|
if (!deleted)
|
|
38
39
|
return 'not_found';
|
package/dist/esm/db/shared.js
CHANGED
|
@@ -21,3 +21,25 @@ export const computeOtpHash = (contact, otp, opts) => {
|
|
|
21
21
|
const crypto = require('crypto');
|
|
22
22
|
return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Deterministic contact identifier used in storage.
|
|
26
|
+
*
|
|
27
|
+
* This function derives a pseudonymous ID from the contact string using the
|
|
28
|
+
* same peppered HMAC/Hash strategy as `computeOtpHash`, but without the OTP
|
|
29
|
+
* component. It allows equality comparison (lookups) without storing the
|
|
30
|
+
* original contact value in the database.
|
|
31
|
+
*/
|
|
32
|
+
export const computeContactId = (contact, opts) => {
|
|
33
|
+
const pepper = process.env.OTP_PEPPER || '';
|
|
34
|
+
const salt = opts?.salt || '';
|
|
35
|
+
const allowInsecure = process.env.ONEHITTER_ALLOW_INSECURE_HASH === 'true';
|
|
36
|
+
if (process.env.NODE_ENV === 'production' && !pepper && !allowInsecure) {
|
|
37
|
+
throw new Error('Security requirement: OTP_PEPPER must be set in production to HMAC-protect contact identifiers and OTP hashes (override with ONEHITTER_ALLOW_INSECURE_HASH=true for non-prod-like runs)');
|
|
38
|
+
}
|
|
39
|
+
const message = salt ? `${contact}|${salt}` : contact;
|
|
40
|
+
const crypto = require('crypto');
|
|
41
|
+
if (pepper) {
|
|
42
|
+
return crypto.createHmac('sha256', pepper).update(message, 'utf8').digest('hex');
|
|
43
|
+
}
|
|
44
|
+
return crypto.createHash('sha256').update(message, 'utf8').digest('hex');
|
|
45
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SQLITE_PATH, computeOtpHash } from './shared';
|
|
1
|
+
import { SQLITE_PATH, computeOtpHash, computeContactId } from './shared';
|
|
2
2
|
let sqlite3;
|
|
3
3
|
let db = null;
|
|
4
4
|
function getDb() {
|
|
@@ -10,11 +10,11 @@ function getDb() {
|
|
|
10
10
|
db.serialize(() => {
|
|
11
11
|
db.run('CREATE TABLE IF NOT EXISTS otp (\n' +
|
|
12
12
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,\n' +
|
|
13
|
-
'
|
|
13
|
+
' contactId TEXT NOT NULL,\n' +
|
|
14
14
|
' otpHash TEXT NOT NULL,\n' +
|
|
15
15
|
' createdAt INTEGER NOT NULL\n' +
|
|
16
16
|
')');
|
|
17
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(
|
|
17
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_otp_contact_hash ON otp(contactId, otpHash)');
|
|
18
18
|
db.run('CREATE INDEX IF NOT EXISTS idx_otp_createdAt ON otp(createdAt)');
|
|
19
19
|
});
|
|
20
20
|
return db;
|
|
@@ -23,8 +23,9 @@ export const otpCreate = async (otp) => {
|
|
|
23
23
|
const database = getDb();
|
|
24
24
|
const createdAt = otp.createdAt ? otp.createdAt.getTime() : Date.now();
|
|
25
25
|
const otpHash = computeOtpHash(otp.contact, otp.otp);
|
|
26
|
+
const contactId = computeContactId(otp.contact);
|
|
26
27
|
return await new Promise((resolve, reject) => {
|
|
27
|
-
database.run('INSERT INTO otp (
|
|
28
|
+
database.run('INSERT INTO otp (contactId, otpHash, createdAt) VALUES (?, ?, ?)', [contactId, otpHash, createdAt], function (err) {
|
|
28
29
|
if (err)
|
|
29
30
|
return reject(err);
|
|
30
31
|
// Shape it like a Mongo InsertOneResult enough for callers
|
|
@@ -35,10 +36,11 @@ export const otpCreate = async (otp) => {
|
|
|
35
36
|
export const otpValidateWithStatus = async (otp, now = new Date(), ttlSeconds) => {
|
|
36
37
|
const database = getDb();
|
|
37
38
|
const otpHash = computeOtpHash(otp.contact, otp.otp);
|
|
39
|
+
const contactId = computeContactId(otp.contact);
|
|
38
40
|
return await new Promise((resolve, reject) => {
|
|
39
41
|
// Single-statement atomicity: select the newest id, then conditionally delete it.
|
|
40
42
|
// We avoid explicit BEGIN/COMMIT to prevent nested transaction errors under concurrency.
|
|
41
|
-
database.get('SELECT id, createdAt FROM otp WHERE
|
|
43
|
+
database.get('SELECT id, createdAt FROM otp WHERE contactId = ? AND otpHash = ? ORDER BY id DESC LIMIT 1', [contactId, otpHash], function (err, row) {
|
|
42
44
|
if (err)
|
|
43
45
|
return reject(err);
|
|
44
46
|
if (!row)
|
package/dist/esm/sender.js
CHANGED
|
@@ -108,20 +108,16 @@ Once used, this one-time password can not be used again. That's why it's called
|
|
|
108
108
|
* @throws {Error} If the recipient (`to`) or the sender (`from`) address is missing.
|
|
109
109
|
*/
|
|
110
110
|
function createSesTransport(region) {
|
|
111
|
-
//
|
|
111
|
+
// Force SESv2 path only; do not use legacy SES
|
|
112
112
|
try {
|
|
113
113
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
114
114
|
const sesv2 = require('@aws-sdk/client-sesv2');
|
|
115
115
|
const client = new sesv2.SESv2Client({ region });
|
|
116
|
-
// Nodemailer v7 expects SESv2 via options.SES with { sesClient, SendEmailCommand }
|
|
117
116
|
const opts = { SES: { sesClient: client, SendEmailCommand: sesv2.SendEmailCommand } };
|
|
118
117
|
return nodemailer.createTransport(opts);
|
|
119
118
|
}
|
|
120
|
-
catch {
|
|
121
|
-
|
|
122
|
-
const aws = require('@aws-sdk/client-ses');
|
|
123
|
-
const legacy = new aws.SES({ apiVersion: '2010-12-01', region });
|
|
124
|
-
return nodemailer.createTransport({ SES: { ses: legacy, aws } });
|
|
119
|
+
catch (err) {
|
|
120
|
+
throw new Error('SESv2 client not available. Install @aws-sdk/client-sesv2 to use OneHitter sender, or pass a Nodemailer transporter explicitly.');
|
|
125
121
|
}
|
|
126
122
|
}
|
|
127
123
|
async function send(to, otp, url, expiry, message, opts) {
|
|
@@ -142,4 +138,7 @@ async function send(to, otp, url, expiry, message, opts) {
|
|
|
142
138
|
...(text ? { text } : {}),
|
|
143
139
|
});
|
|
144
140
|
}
|
|
141
|
+
export function createSesV2Transport(region) {
|
|
142
|
+
return createSesTransport(region);
|
|
143
|
+
}
|
|
145
144
|
export default send;
|
|
@@ -21,3 +21,14 @@ export interface DbAdapter {
|
|
|
21
21
|
export declare const computeOtpHash: (contact: string, otp: string, opts?: {
|
|
22
22
|
salt?: string;
|
|
23
23
|
}) => string;
|
|
24
|
+
/**
|
|
25
|
+
* Deterministic contact identifier used in storage.
|
|
26
|
+
*
|
|
27
|
+
* This function derives a pseudonymous ID from the contact string using the
|
|
28
|
+
* same peppered HMAC/Hash strategy as `computeOtpHash`, but without the OTP
|
|
29
|
+
* component. It allows equality comparison (lookups) without storing the
|
|
30
|
+
* original contact value in the database.
|
|
31
|
+
*/
|
|
32
|
+
export declare const computeContactId: (contact: string, opts?: {
|
|
33
|
+
salt?: string;
|
|
34
|
+
}) => string;
|
package/dist/types/sender.d.ts
CHANGED
|
@@ -24,4 +24,5 @@ export interface MessageConfig {
|
|
|
24
24
|
template?: MessageTemplate;
|
|
25
25
|
}
|
|
26
26
|
declare function send(to: string, otp: string, url: string, expiry?: number | string, message?: MessageConfig | MessageTemplate, opts?: SendOptions): Promise<void>;
|
|
27
|
+
export declare function createSesV2Transport(region: string): nodemailer.Transporter;
|
|
27
28
|
export default send;
|