@luisotaviopilotto/cryptmp 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis Otávio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # Cryptmp — Node.js
2
+
3
+ Implementação em Node.js com `node:crypto` nativo (zero dependências).
4
+ Interoperável com as versões
5
+ [**Go**](https://github.com/luisotaviopilotto/cryptmp-go) e
6
+ [**Python**](https://github.com/luisotaviopilotto/cryptmp-py): um hash gerado
7
+ aqui verifica lá e vice-versa, com a mesma chave global.
8
+
9
+ Requer **Node 16+** (OpenSSL 1.1.1+, para `sha3-256` e `shake256`).
10
+
11
+ ## Instalação
12
+
13
+ ```bash
14
+ npm install cryptmp
15
+ ```
16
+
17
+ > O comando acima só funciona depois que o pacote estiver **publicado no npm**
18
+ > com esse nome. Enquanto não publicar, dá para instalar do jeito local — mesmo
19
+ > nome, mesmo `require('cryptmp')`:
20
+ >
21
+ > ```bash
22
+ > # a) direto da pasta do projeto
23
+ > npm install /caminho/para/cryptmp-js
24
+ >
25
+ > # b) via tarball (gera cryptmp-1.0.0.tgz)
26
+ > cd cryptmp-js && npm pack
27
+ > npm install /caminho/para/cryptmp-1.0.0.tgz
28
+ >
29
+ > # c) direto do GitHub
30
+ > npm install git+https://github.com/luisotaviopilotto/cryptmp-js.git
31
+ > ```
32
+
33
+ ### Publicar no npm (para o `npm install cryptmp` funcionar para todos)
34
+
35
+ ```bash
36
+ cd cryptmp-js
37
+ npm login
38
+ npm publish # nome "cryptmp" precisa estar livre no npm
39
+ ```
40
+
41
+ Se o nome `cryptmp` já estiver ocupado, use um nome com escopo — troque
42
+ `"name"` no `package.json` para `"@luisotaviopilotto/cryptmp"` e rode
43
+ `npm publish --access public`; a instalação vira
44
+ `npm install @luisotaviopilotto/cryptmp`.
45
+
46
+ ## Uso
47
+
48
+ ```js
49
+ const th = require('cryptmp'); // instalado; ou require('./cryptmp') local
50
+
51
+ // 1. Chave global de 32 bytes — de um segredo, nunca do banco.
52
+ const key = th.Key.fromHex(1, '…64 hex chars…'); // ou th.Key.generate(1)
53
+ const kr = new th.Keyring(key);
54
+
55
+ // 2. Hasher (calibre o custo).
56
+ const hasher = new th.Cryptmp(kr, { cost: 12 });
57
+
58
+ // 3. Cadastro / login.
59
+ const encoded = hasher.hash('minha_senha'); // grave no banco
60
+ const ok = hasher.verify('minha_senha', encoded); // true
61
+
62
+ // 4. Upgrade transparente.
63
+ if (ok && hasher.needsRehash(encoded)) {
64
+ // regrave hasher.hash('minha_senha')
65
+ }
66
+ ```
67
+
68
+ ### Rotação de chave
69
+
70
+ ```js
71
+ const k1 = th.Key.fromHex(1, '…antiga…');
72
+ const k2 = th.Key.generate(2);
73
+ const kr = new th.Keyring(k2, k1); // k2 ativa, k1 legada
74
+ const hasher = new th.Cryptmp(kr, { cost: 12 });
75
+ hasher.verify(pw, hashAntigo); // acha k1 pelo keyid automaticamente
76
+ ```
77
+
78
+ ## CLI
79
+
80
+ ```bash
81
+ export CRYPTMP_KEY=$(node bin/cryptmp.js gen-key)
82
+ printf 'minha_senha' | node bin/cryptmp.js hash --cost 12
83
+ node bin/cryptmp.js verify '$th$1$12$1$...' 'minha_senha'
84
+ ```
85
+
86
+ ## Testes
87
+
88
+ ```bash
89
+ npm test # ou: node test.js (KAT + roundtrip)
90
+ ```
91
+
92
+ Os vetores em `vectors.json` são idênticos aos das versões Go e Python.
93
+
94
+ > ⚠️ Construção educacional/de pesquisa, não auditada. Para produção prefira
95
+ > `argon2`/`scrypt`. Modelo de segurança e limitações no
96
+ > [README do cryptmp-go](https://github.com/luisotaviopilotto/cryptmp-go#limitações-de-segurança).
package/bin/cryptmp.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Cryptmp CLI (Node).
4
+ // cryptmp gen-key
5
+ // cryptmp hash [--cost N] [--key HEX | env CRYPTMP_KEY] [password]
6
+ // cryptmp verify [--key HEX | env CRYPTMP_KEY] <encoded> [password]
7
+ const th = require('../cryptmp');
8
+
9
+ function loadKey(hexFlag) {
10
+ const val = hexFlag || process.env.CRYPTMP_KEY || '';
11
+ if (!val) {
12
+ console.error('error: no key (pass --key or set CRYPTMP_KEY)');
13
+ process.exit(1);
14
+ }
15
+ return th.Key.fromHex(1, val);
16
+ }
17
+
18
+ function readPassword(arg) {
19
+ if (arg !== undefined) return arg;
20
+ try {
21
+ const data = require('fs').readFileSync(0, 'utf8');
22
+ return data.replace(/[\r\n]+$/, '');
23
+ } catch {
24
+ return '';
25
+ }
26
+ }
27
+
28
+ function popFlag(args, name) {
29
+ const i = args.indexOf(name);
30
+ if (i === -1) return undefined;
31
+ const val = args[i + 1];
32
+ args.splice(i, 2);
33
+ return val;
34
+ }
35
+
36
+ function main() {
37
+ const [, , cmd, ...rest] = process.argv;
38
+ if (cmd === 'gen-key') {
39
+ console.log(th.Key.generate(1).hex);
40
+ return;
41
+ }
42
+ if (cmd === 'hash') {
43
+ const cost = Number(popFlag(rest, '--cost') ?? th.DEFAULT_COST);
44
+ const key = loadKey(popFlag(rest, '--key'));
45
+ const hasher = new th.Cryptmp(new th.Keyring(key), { cost });
46
+ console.log(hasher.hash(readPassword(rest[0])));
47
+ return;
48
+ }
49
+ if (cmd === 'verify') {
50
+ const key = loadKey(popFlag(rest, '--key'));
51
+ const encoded = rest[0];
52
+ if (!encoded) {
53
+ console.error('usage: cryptmp verify [--key HEX] <encoded> [password]');
54
+ process.exit(1);
55
+ }
56
+ const hasher = new th.Cryptmp(new th.Keyring(key));
57
+ const ok = hasher.verify(readPassword(rest[1]), encoded);
58
+ console.log(ok ? 'OK: password matches' : 'FAIL: password does not match');
59
+ process.exit(ok ? 0 : 1);
60
+ }
61
+ console.error('usage: cryptmp <gen-key|hash|verify> ...');
62
+ process.exit(2);
63
+ }
64
+
65
+ main();
package/cryptmp.js ADDED
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+ /**
3
+ * Cryptmp — password hashing with a temporal pepper (Node.js, zero deps).
4
+ *
5
+ * Interoperable with the Go and Python implementations: a hash produced by any
6
+ * one verifies in the others given the same global key. All primitives are the
7
+ * standard SHA-3 family via node:crypto (requires OpenSSL 1.1.1+, Node 16+):
8
+ *
9
+ * - SHA3-256 fixed 256-bit digests
10
+ * - HMAC-SHA3-256 temporal pepper + blinding
11
+ * - SHAKE256 (XOF) memory-hard KDF row/seed expansion
12
+ *
13
+ * WARNING: educational/research construction, not independently audited. Prefer
14
+ * argon2/scrypt for production unless reviewed by a cryptographer.
15
+ */
16
+ const crypto = require('crypto');
17
+
18
+ // ---- Parameters ----------------------------------------------------------
19
+ const VERSION = 1;
20
+ const TIME_WINDOW = 30;
21
+ const MEMORY_COL = 1024;
22
+ const MIN_COST = 4;
23
+ const MAX_COST = 31;
24
+ const DEFAULT_COST = 12;
25
+ const DEFAULT_SALT_SIZE = 16;
26
+ const HASH_OUTPUT = 32;
27
+ const KEY_SIZE = 32;
28
+
29
+ // ---- Primitives ----------------------------------------------------------
30
+ const sha3_256 = (buf) => crypto.createHash('sha3-256').update(buf).digest();
31
+ const hmacSha3_256 = (key, msg) =>
32
+ crypto.createHmac('sha3-256', key).update(msg).digest();
33
+ const shake256 = (data, n) =>
34
+ crypto.createHash('shake256', { outputLength: n }).update(data).digest();
35
+
36
+ function u16(x) {
37
+ const b = Buffer.alloc(2);
38
+ b.writeUInt16BE(x);
39
+ return b;
40
+ }
41
+ function u32(x) {
42
+ const b = Buffer.alloc(4);
43
+ b.writeUInt32BE(x >>> 0);
44
+ return b;
45
+ }
46
+ function u64(x) {
47
+ const b = Buffer.alloc(8);
48
+ b.writeBigUInt64BE(BigInt(x));
49
+ return b;
50
+ }
51
+ function xorBuf(a, b) {
52
+ const out = Buffer.allocUnsafe(a.length);
53
+ for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i];
54
+ return out;
55
+ }
56
+
57
+ // ---- Core functions ------------------------------------------------------
58
+ function normalizePassword(password) {
59
+ return sha3_256(Buffer.from(password.normalize('NFC'), 'utf8'));
60
+ }
61
+
62
+ function derivePepper(key, t, salt, cost) {
63
+ return hmacSha3_256(key, Buffer.concat([u64(t), salt, u16(cost)]));
64
+ }
65
+
66
+ function blindPassword(b, pepper, salt) {
67
+ return hmacSha3_256(pepper, Buffer.concat([b, salt]));
68
+ }
69
+
70
+ function memoryHardKDF(blinded, salt, cost) {
71
+ const rows = cost * 1024;
72
+ const seed = shake256(Buffer.concat([blinded, salt]), 32);
73
+
74
+ const m = new Array(rows);
75
+ m[0] = shake256(Buffer.concat([seed, u32(0)]), MEMORY_COL);
76
+ for (let i = 1; i < rows; i++) {
77
+ m[i] = shake256(Buffer.concat([m[i - 1], u32(i)]), MEMORY_COL);
78
+ }
79
+
80
+ for (let rnd = 0; rnd < 3; rnd++) {
81
+ const rb = u32(rnd);
82
+ for (let i = 0; i < rows; i++) {
83
+ const j = m[i].readUInt32BE(0) % rows;
84
+ const mixed = xorBuf(m[i], m[j]);
85
+ m[i] = shake256(Buffer.concat([mixed, rb, u32(i)]), MEMORY_COL);
86
+ }
87
+ }
88
+
89
+ const final = Buffer.concat([
90
+ m[rows - 4], m[rows - 3], m[rows - 2], m[rows - 1], salt, u16(cost),
91
+ ]);
92
+ return sha3_256(final);
93
+ }
94
+
95
+ function computeHash(key, keyId, salt, cost, t, password) {
96
+ const b = normalizePassword(password);
97
+ const pepper = derivePepper(key, t, salt, cost);
98
+ const blinded = blindPassword(b, pepper, salt);
99
+ const digest = memoryHardKDF(blinded, salt, cost);
100
+ return { encoded: encode(VERSION, cost, keyId, salt, digest, t), digest };
101
+ }
102
+
103
+ // ---- Encoding ------------------------------------------------------------
104
+ function encode(version, cost, keyId, salt, digest, t) {
105
+ return `$th$${version}$${cost}$${keyId}$${salt.toString('hex')}$${digest.toString('hex')}$${t.toString()}`;
106
+ }
107
+
108
+ const DIGITS_RE = /^[0-9]+$/;
109
+ const MAX_T = 2n ** 64n - 1n;
110
+
111
+ // Strict ASCII decimal only: Number()/BigInt() would also accept "0x10",
112
+ // "1e2" and surrounding whitespace, which Go/Python reject.
113
+ function parseUint(s, lo, hi) {
114
+ if (!DIGITS_RE.test(s)) throw new Error('cryptmp: malformed hash string');
115
+ const n = Number(s);
116
+ if (!Number.isSafeInteger(n) || n < lo || n > hi)
117
+ throw new Error('cryptmp: malformed hash string');
118
+ return n;
119
+ }
120
+
121
+ function parse(encoded) {
122
+ const parts = encoded.split('$');
123
+ // Leading "$" -> parts[0] === ""
124
+ if (parts.length !== 8 || parts[0] !== '' || parts[1] !== 'th') {
125
+ throw new Error('cryptmp: malformed hash string');
126
+ }
127
+ const version = parseUint(parts[2], 1, 255);
128
+ const cost = parseUint(parts[3], MIN_COST, MAX_COST);
129
+ const keyId = parseUint(parts[4], 0, 0xffffffff);
130
+ const salt = Buffer.from(parts[5], 'hex');
131
+ const digest = Buffer.from(parts[6], 'hex');
132
+ if (!DIGITS_RE.test(parts[7])) throw new Error('cryptmp: malformed hash string');
133
+ const t = BigInt(parts[7]);
134
+ if (t > MAX_T) throw new Error('cryptmp: malformed hash string');
135
+ // salt hex must round-trip and be 16..32 bytes
136
+ if (salt.length < 16 || salt.length > 32 || salt.toString('hex') !== parts[5].toLowerCase())
137
+ throw new Error('cryptmp: malformed hash string');
138
+ if (digest.length !== HASH_OUTPUT || digest.toString('hex') !== parts[6].toLowerCase())
139
+ throw new Error('cryptmp: malformed hash string');
140
+ return { version, cost, keyId, salt, digest, t };
141
+ }
142
+
143
+ // ---- Keys ----------------------------------------------------------------
144
+ class Key {
145
+ constructor(id, bytes) {
146
+ if (!Buffer.isBuffer(bytes) || bytes.length !== KEY_SIZE)
147
+ throw new Error('cryptmp: global key must be exactly 32 bytes');
148
+ this.id = id;
149
+ this.bytes = bytes;
150
+ }
151
+ static generate(id) {
152
+ return new Key(id, crypto.randomBytes(32));
153
+ }
154
+ static fromHex(id, hex) {
155
+ return new Key(id, Buffer.from(hex, 'hex'));
156
+ }
157
+ get hex() {
158
+ return this.bytes.toString('hex');
159
+ }
160
+ }
161
+
162
+ class Keyring {
163
+ constructor(active, ...legacy) {
164
+ this._keys = new Map([[active.id, active]]);
165
+ this._active = active.id;
166
+ for (const k of legacy) {
167
+ if (this._keys.has(k.id)) throw new Error(`duplicate key id ${k.id}`);
168
+ this._keys.set(k.id, k);
169
+ }
170
+ }
171
+ active() {
172
+ return this._keys.get(this._active);
173
+ }
174
+ byId(id) {
175
+ return this._keys.get(id);
176
+ }
177
+ add(k) {
178
+ this._keys.set(k.id, k);
179
+ }
180
+ setActive(id) {
181
+ if (!this._keys.has(id)) throw new Error('cryptmp: unknown key id');
182
+ this._active = id;
183
+ }
184
+ }
185
+
186
+ // ---- Hasher --------------------------------------------------------------
187
+ class Cryptmp {
188
+ /**
189
+ * @param {Keyring} keyring
190
+ * @param {{cost?:number, saltSize?:number, timeWindow?:number,
191
+ * tolerance?:number, now?:() => number}} [opts]
192
+ */
193
+ constructor(keyring, opts = {}) {
194
+ this.keyring = keyring;
195
+ this.cost = opts.cost ?? DEFAULT_COST;
196
+ this.saltSize = opts.saltSize ?? DEFAULT_SALT_SIZE;
197
+ this.timeWindow = opts.timeWindow ?? TIME_WINDOW;
198
+ this.tolerance = opts.tolerance ?? 1;
199
+ this.now = opts.now ?? (() => Date.now() / 1000);
200
+ if (this.cost < MIN_COST || this.cost > MAX_COST)
201
+ throw new Error('cryptmp: cost out of range [4,31]');
202
+ if (this.saltSize < 16 || this.saltSize > 32)
203
+ throw new Error('cryptmp: salt size must be 16..32');
204
+ }
205
+
206
+ hash(password) {
207
+ const salt = crypto.randomBytes(this.saltSize);
208
+ const t = BigInt(Math.floor(Math.floor(this.now()) / this.timeWindow));
209
+ const key = this.keyring.active();
210
+ return computeHash(key.bytes, key.id, salt, this.cost, t, password).encoded;
211
+ }
212
+
213
+ verify(password, encoded) {
214
+ const p = parse(encoded);
215
+ const key = this.keyring.byId(p.keyId);
216
+ if (!key) throw new Error('cryptmp: unknown key id in hash');
217
+ const b = normalizePassword(password);
218
+ let match = 0;
219
+ const tol = BigInt(this.tolerance);
220
+ for (let d = -tol; d <= tol; d++) {
221
+ const tw = p.t + d;
222
+ if (tw < 0n || tw > MAX_T) continue;
223
+ const pepper = derivePepper(key.bytes, tw, p.salt, p.cost);
224
+ const blinded = blindPassword(b, pepper, p.salt);
225
+ const cand = memoryHardKDF(blinded, p.salt, p.cost);
226
+ // constant-time; accumulate without early return
227
+ if (cand.length === p.digest.length && crypto.timingSafeEqual(cand, p.digest)) {
228
+ match |= 1;
229
+ }
230
+ }
231
+ return match === 1;
232
+ }
233
+
234
+ needsRehash(encoded) {
235
+ let p;
236
+ try {
237
+ p = parse(encoded);
238
+ } catch {
239
+ return true;
240
+ }
241
+ if (p.version !== VERSION) return true;
242
+ if (p.keyId !== this.keyring.active().id) return true;
243
+ if (p.cost < this.cost) return true;
244
+ return false;
245
+ }
246
+ }
247
+
248
+ module.exports = {
249
+ VERSION, MIN_COST, MAX_COST, DEFAULT_COST, TIME_WINDOW,
250
+ Key, Keyring, Cryptmp,
251
+ // low-level (for tests / advanced use)
252
+ normalizePassword, derivePepper, blindPassword, memoryHardKDF,
253
+ computeHash, encode, parse,
254
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@luisotaviopilotto/cryptmp",
3
+ "version": "1.0.0",
4
+ "description": "Password hashing with a temporal pepper (SHA-3 family). Interoperable with the Go and Python implementations.",
5
+ "main": "cryptmp.js",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "cryptmp": "bin/cryptmp.js"
9
+ },
10
+ "files": [
11
+ "cryptmp.js",
12
+ "bin/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "test": "node test.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=16"
21
+ },
22
+ "keywords": [
23
+ "password",
24
+ "hashing",
25
+ "sha3",
26
+ "shake256",
27
+ "pepper",
28
+ "kdf",
29
+ "cryptmp",
30
+ "crypt"
31
+ ],
32
+ "author": "Luis Otavio",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/luisotaviopilotto/cryptmp-js.git"
37
+ }
38
+ }