diff --git a/index.ts b/index.ts index 96ffc94..7f00980 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,7 @@ import PrivateBox from './src/private-box' import PrivateWrap from './src/private-wrap' import PwdBox from './src/pwd-box' import PwdWrap from './src/pwd-wrap' +import {hash, verify} from './src/pwd' export enum Strength { weak, @@ -22,5 +23,6 @@ export * as kdf from './src/kdf' export * as misc from './src/misc' export * as signature from './src/signature' export * as JWT from './src/jwt' +export const pwd = {hash, verify} export {SecretBox, SecretWrap, PrivateBox, PrivateWrap, PwdBox, PwdWrap} diff --git a/src/pwd.ts b/src/pwd.ts new file mode 100644 index 0000000..be4547f --- /dev/null +++ b/src/pwd.ts @@ -0,0 +1,73 @@ +import logger from 'log' +import * as misc from 'misc' +import {pbkdf} from './kdf' + +const log = logger('libcrypto:pwd') + +type Salt = Uint8Array +type Hash = Uint8Array + +export async function hash(password: string) : Promise { + log.debug('hash password') + const salt = crypto.getRandomValues(new Uint8Array(16)) + const h = await pbkdf(salt, password) + return encode(salt, h!) +} +export async function verify(password: string, hash: string) : Promise { + log.debug("verify password's hash") + const params = decode(hash) + if (params === null) return false + const h = await pbkdf(params[0], password) + return compare(h!, params[1]) +} + +export function encode(salt: Salt, hash: Hash) : string { + const s = salt_encode(salt) + const h = hash_encode(hash) + + return `${s}.${h}` +} +export function decode(hash: string) : [Salt, Hash] | null { + const parts = hash.split('.') + if (parts.length !== 2) { + log.warn('Invalid part count for password hash :', parts.length) + return null + } + + const salt = salt_decode(parts[0]) + if (salt === null) return null + + const h = hash_decode(parts[1]) + if (h === null) return null + + return [salt, h] +} + +export function salt_encode(salt: Salt) : string { + return misc.a2b64(salt) +} +export function salt_decode(salt: string) : Salt | null { + return misc.b642a(salt) +} + +export function hash_encode(hash: Hash) : string { + return misc.a2b64(new Uint8Array(hash)) +} +export function hash_decode(hash: string) : Hash | null { + return misc.b642a(hash) +} + +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 +} diff --git a/test/pwd.test.ts b/test/pwd.test.ts new file mode 100644 index 0000000..fe6bd08 --- /dev/null +++ b/test/pwd.test.ts @@ -0,0 +1,79 @@ +import {beforeAll, describe, test, expect} from 'bun:test' +import {Level, options, writers} from 'log' +import {Console} from 'logger-console' + +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).toBeTrue() +}) +test('wrong password', async () => { + const password1 = "AwesomePassword123!" + const password2 = "AwesomePassword321!" + expect(password1).not.toEqual(password2) + + const hash = await pwd.hash(password1) + const verification = await pwd.verify(password2, hash) + 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() + + const h2 = await pwd.hash(p2) + const verification2 = await pwd.verify(p1, h2) + expect(verification2).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') + .replace('c', 'd') + .replace('e', 'f') + expect(tamperedHash).not.toEqual(hash) + + const verification = await pwd.verify(password, tamperedHash) + expect(verification).toBeFalse() +}) + +describe('Serialization', () => { + describe('Hash', () => { + test('base case', () => { + const h = new Uint8Array(16) + 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).toEqual(salt) + }) +})