diff --git a/index.ts b/index.ts index 670bedc..6fd1278 100644 --- a/index.ts +++ b/index.ts @@ -1 +1,2 @@ -export * as pwd from './src/pwd' +import {hash, verify} from './src/pwd' +export const pwd = {hash, verify} diff --git a/package.json b/package.json index 80d5853..441c99e 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,19 @@ { "name": "crypto-server", - "description": "Various crypto utils that only works on the server side", + "description": "Various crypto utils that only work on the server side", "version": "1.0.0", - "author": "Pascal Perrenoud ", + "dependencies": { + "log": "git+https://git.pband.ch/typescript/log.git", + "misc": "git+https://git.pband.ch/typescript/misc.git" + }, + "devDependencies": { + "@types/bun": "^1.1.6", + "logger-console": "git+https://git.pband.ch/typescript/logger-console.git" + }, + "module": "index.ts", "type": "module", - "files": ["index.ts", "src"], - - "scripts": { - "test": "bun test" - }, - - "dependencies": { - "jose": "^5.3.0", - "log": "git+https://git.pband.ch/typescript/log.git" - }, - "devDependencies": { - "@types/bun": "^1.1.2" - } + "files": ["index.ts", "src"] } diff --git a/src/pwd.ts b/src/pwd.ts index 29249b5..6ece00a 100644 --- a/src/pwd.ts +++ b/src/pwd.ts @@ -1,16 +1,142 @@ import logger from 'log' +import * as misc from 'misc' + const log = logger('crypto-server:pwd') -export function hash(pwd: string): Promise { - log.debug('hash password') - return Bun.password.hash(pwd) +type Salt = Uint8Array +type Hash = ArrayBuffer +type Iterations = number + +export enum Strength { + Easy = 100_000, + Medium = 300_000, + Hard = 750_000, } -export async function verify(pwd: string, hash: string): Promise { - log.debug("verify password's hash") - return Bun.password.verify(pwd, hash).catch(e => { - log.warn('Password verification failed') - log.debug(`Error : ${e}`) - return false - }) +export async function hash(password: string, strength: Strength = Strength.Medium) : Promise { + log.debug('hash password') + const iterations = strength as number + const salt = crypto.getRandomValues(new Uint8Array(16)) + const h = await internal_hash(password, iterations, salt) + return encode(salt, iterations, h) +} +export async function verify(password: string, hash: string) : Promise { + log.debug("verify password's hash") + const params = decode(hash) + if (params === null) { + log.warn('Invalid hash') + return false + } + const h = await internal_hash(password, params[1], params[0]) + return compare(h, params[2]) +} + +export function encode(salt: Salt, iterations: Iterations, hash: Hash) : string { + const s = salt_encode(salt) + const i = iterations_encode(iterations) + const h = hash_encode(hash) + + // version.iterations$salt.hash + return `1.${i}$${s}.${h}` +} +export function decode(hash: string) : [Salt, Iterations, Hash] | null { + // TODO : Log + const parts = hash.split('$') + if (parts.length !== 2) return null + + const p1 = parts[0].split('.') + if (p1.length !== 2) return null + + const p2 = parts[1].split('.') + if (p2.length !== 2) return null + + // Version + if (p1[0] !== '1') return null + + const iterations = iterations_decode(p1[1]) + if (iterations === null) return null + + const salt = salt_decode(p2[0]) + if (salt === null) return null + + const h = hash_decode(p2[1]) + if (h === null) return null + + return [salt, iterations, h] +} + +export function iterations_encode(iterations: Iterations) : string { + return iterations.toString(16) +} +export function iterations_decode(iterations: string) : Iterations | null { + try { + return parseInt(iterations, 16) + } catch(e) { + log.warn('Invalid iterations count') + log.debug('Error', e) + return null + } +} + +export function salt_encode(salt: Salt) : string { + return misc.a2b64(salt) +} +export function salt_decode(salt: string) : Salt | null { + const res = misc.b642a(salt) + if (res.is_err()) { + log.warn('Invalid salt') + return null + } else { + return res.unwrap() + } +} + +export function hash_encode(hash: Hash) : string { + return misc.a2b64(new Uint8Array(hash)) +} +export function hash_decode(hash: string) : Hash | null { + const res = misc.b642a(hash) + if (res.is_err()) { + log.warn('Invalid hash') + return null + } else { + return res.unwrap().buffer as ArrayBuffer + } +} + +export async function internal_hash(password: string, iterations: Iterations, salt: Salt): Promise { + const pwd = await encode_password(password) + return crypto.subtle.deriveBits( + { + name: 'PBKDF2', + hash: 'SHA-512', + iterations, + salt, + }, + pwd, + 256 + ) +} +export function compare(a: Hash, b: Hash) : boolean { + const a1 = new Uint8Array(a) + const b1 = new Uint8Array(b) + + let result = a1.length === b1.length + for (let i = 0;i < Math.max(a1.length, b1.length); ++i) { + const a2 = a1[i] ?? 'a' + const b2 = b1[i] ?? 'b' + const test = a2 === b2 + result = result && test + } + + return result +} +export async function encode_password(password: string) : Promise { + return crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ) } diff --git a/test/pwd.test.ts b/test/pwd.test.ts index 3e58eb1..233447a 100644 --- a/test/pwd.test.ts +++ b/test/pwd.test.ts @@ -1,14 +1,25 @@ -import {test, expect} from 'bun:test' +import {beforeAll, describe, test, expect} from 'bun:test' +import {Level, options, writers} from 'log' +import {Console} from 'logger-console' -import {pwd} from '../index' +import * as pwd from '../src/pwd' + +const console = new Console({ + minLevel: Level.DEBUG, + with_color: true, +}) + +beforeAll(() => { + options.verbose = false + writers.set('console', console) +}) test('base case', async () => { const password = "AwesomePassword123!" const hash = await pwd.hash(password) const verification = await pwd.verify(password, hash) - expect(verification).toBe(true) + expect(verification).toBeTrue() }) - test('wrong password', async () => { const password1 = "AwesomePassword123!" const password2 = "AwesomePassword321!" @@ -16,20 +27,57 @@ test('wrong password', async () => { const hash = await pwd.hash(password1) const verification = await pwd.verify(password2, hash) - expect(verification).toBe(false) + expect(verification).toBeFalse() }) +test("Empty password isn't a trick", async () => { + const p1 = "" + const p2 = "abc" + const hash = await pwd.hash(p1) + const verification = await pwd.verify(p2, hash) + expect(verification).toBeFalse() +}) test('salt changes', async () => { const password = "AwesomePassword123!" const hash1 = await pwd.hash(password) const hash2 = await pwd.hash(password) expect(hash1).not.toEqual(hash2) }) - test('tampered hash', async () => { const password = "AwesomePassword123" const hash = await pwd.hash(password) - const tamperedHash = hash.replace('a', 'b') + + const tamperedHash = hash + .replace('a', 'b') + .replace('c', 'd') + .replace('e', 'f') + expect(tamperedHash).not.toEqual(hash) + const verification = await pwd.verify(password, tamperedHash) - expect(verification).toBe(false) + expect(verification).toBeFalse() +}) + +describe('Serialization', () => { + describe('Iterations', () => { + test('base case', () => { + const i = 10000 + const ser = pwd.iterations_encode(i) + const de = pwd.iterations_decode(ser) + expect(de).toEqual(i) + }) + }) + describe('Hash', () => { + test('base case', () => { + const h = new Uint8Array(16).buffer as ArrayBuffer + const ser = pwd.hash_encode(h) + const de = pwd.hash_decode(ser) + expect(de).toEqual(h) + }) + }) + describe('Salt', () => { + const salt = new Uint8Array(16) + const ser = pwd.salt_encode(salt) + const de = pwd.salt_decode(ser) + expect(de).toBe(salt) + }) })