diff --git a/crypto/README.md b/crypto/README.md index c1b5d5e..e95a624 100644 --- a/crypto/README.md +++ b/crypto/README.md @@ -13,9 +13,43 @@ The following algorithms are provided: - Argon2 - Bcrypt +- Scrypt ```ts import { hash, verify } from "@stdext/crypto/hash"; const h = hash("argon2", "password"); verify("argon2", "password", h); ``` + +### HOTP (HMAC One-Time Password) + +```ts +import { generateHotp, verifyHotp } from "@stdext/crypto/hotp"; +import { generateSecret } from "@stdext/crypto/utils"; + +const secret = generateSecret(); +const hotp = generateHotp(secret, 42); +verifyHotp(hotp, secret, 42); +``` + +### TOTP (Time-based One-Time Password) + +```ts +import { generateTotp, verifyTotp } from "@stdext/crypto/totp"; +import { generateSecret } from "@stdext/crypto/utils"; + +const secret = generateSecret(); +const totp = generateTotp(secret, 42); +verifyTotp(totp, secret, 42); +``` + +### Utils + +```ts +import { generateSecretBytes } from "@stdext/crypto/utils"; +import { encodeBase64 } from "@std/encoding"; + +const secretBytes = generateSecretBytes(); +// You can select your own encoding +const encodedSecret = encodeBase64(secretBytes); +``` diff --git a/crypto/deno.json b/crypto/deno.json index a30f2b1..a1c13c1 100644 --- a/crypto/deno.json +++ b/crypto/deno.json @@ -2,7 +2,13 @@ "version": "0.0.5", "name": "@stdext/crypto", "exports": { - "./hash": "./hash.ts" + "./hash": "./hash.ts", + "./hash/argon2": "./hash/argon2.ts", + "./hash/bcrypt": "./hash/bcrypt.ts", + "./hash/scrypt": "./hash/scrypt.ts", + "./hotp": "./hotp.ts", + "./totp": "./totp.ts", + "./utils": "./utils.ts" }, "tasks": { "build": "deno task build:argon2 && deno task build:bcrypt && deno task build:scrypt", diff --git a/crypto/hotp.bench.ts b/crypto/hotp.bench.ts new file mode 100644 index 0000000..72f0ca0 --- /dev/null +++ b/crypto/hotp.bench.ts @@ -0,0 +1,11 @@ +import { generateHotp, verifyHotp } from "./hotp.ts"; + +const secret = "OCOMBLGUREYUXFQJIL75FQFCKYFCKLQP"; + +Deno.bench("generateHotp()", async () => { + await generateHotp(secret, 1000000000), "270103"; +}); + +Deno.bench("verifyHotp()", async () => { + await verifyHotp("270103", secret, 1000000000); +}); diff --git a/crypto/hotp.test.ts b/crypto/hotp.test.ts new file mode 100644 index 0000000..793dd1c --- /dev/null +++ b/crypto/hotp.test.ts @@ -0,0 +1,63 @@ +import { assert, assertEquals } from "@std/assert"; +import { + counterToBuffer, + generateHmacSha1, + generateHotp, + truncate, + verifyHotp, +} from "./hotp.ts"; +import { decodeBase32, encodeHex } from "@std/encoding"; + +const secret = "OCOMBLGUREYUXFQJIL75FQFCKYFCKLQP"; +const secretBytes = decodeBase32(secret); + +Deno.test("counterToBuffer()", () => { + assertEquals( + new Uint8Array(counterToBuffer(0).buffer), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]), + ); + assertEquals( + new Uint8Array(counterToBuffer(100).buffer), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 100]), + ); + assertEquals( + new Uint8Array(counterToBuffer(1000).buffer), + new Uint8Array([0, 0, 0, 0, 0, 0, 3, 232]), + ); + assertEquals( + new Uint8Array(counterToBuffer(1000000000000).buffer), + new Uint8Array([0, 0, 0, 232, 212, 165, 16, 0]), + ); +}); + +Deno.test("generateHmacSha1() should generate hashes", async () => { + const data = new Uint8Array([0, 0, 0, 0, 0, 0, 3, 232]); + const hmac = await generateHmacSha1(secretBytes, data); + assertEquals(encodeHex(hmac), "a5a0ee362d45dcb90fba5efb57ac90c2903b2c59"); +}); + +Deno.test("truncate() should output a numbered string", () => { + const data = new Uint8Array([0, 0, 0, 232, 212, 165, 16, 0]); + const numberedString0 = truncate(data, 0); + assertEquals(numberedString0, "0"); + const numberedString1 = truncate(data, 1); + assertEquals(numberedString1, "2"); + const numberedString6 = truncate(data, 6); + assertEquals(numberedString6, "000232"); + const numberedString10 = truncate(data, 10); + assertEquals(numberedString10, "0000000232"); +}); + +Deno.test("generateHotp()", async () => { + assertEquals(await generateHotp(secret, 0), "187492"); + assertEquals(await generateHotp(secret, 100), "907306"); + assertEquals(await generateHotp(secret, 1000), "303255"); + assertEquals(await generateHotp(secret, 1000000000000), "270103"); +}); + +Deno.test("verifyHotp()", async () => { + assert(await verifyHotp("187492", secret, 0)); + assert(await verifyHotp("907306", secret, 100)); + assert(await verifyHotp("303255", secret, 1000)); + assert(await verifyHotp("270103", secret, 1000000000000)); +}); diff --git a/crypto/hotp.ts b/crypto/hotp.ts new file mode 100644 index 0000000..88fc1d9 --- /dev/null +++ b/crypto/hotp.ts @@ -0,0 +1,81 @@ +import { decodeBase32 } from "@std/encoding"; + +/** Converts a counter value to a DataView. + * + * @ignore + */ +export function counterToBuffer(counter: number): DataView { + const buffer = new DataView(new ArrayBuffer(8)); + buffer.setBigUint64(0, BigInt(counter), false); + return buffer; +} + +/** Generates a HMAC-SHA1 hash of the specified key and counter. + * + * @ignore + */ +export async function generateHmacSha1( + key: Uint8Array, + data: BufferSource, +): Promise { + const importedKey = await crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: "SHA-1" }, + false, + ["sign"], + ); + + const signedData = await crypto.subtle.sign( + "HMAC", + importedKey, + data, + ); + + return new Uint8Array(signedData); +} + +/** Truncates the HMAC value to a 6-digit HOTP value. + * + * @ignore + */ +export function truncate(value: Uint8Array, length: number): string { + const offset = value[19] & 0xf; + const code = (value[offset] & 0x7f) << 24 | + (value[offset + 1] & 0xff) << 16 | + (value[offset + 2] & 0xff) << 8 | + (value[offset + 3] & 0xff); + const digits = code % Math.pow(10, length); + return digits.toString().padStart(length, "0"); +} + +/** Generates a HMAC-based one-time password (HOTP) using the specified key and counter. + * + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param counter a counter value used to generate the HOTP. + * @returns a 6-digit HOTP value. + */ +export async function generateHotp( + key: string | Uint8Array, + counter: number, +): Promise { + const parsedKey = typeof key === "string" ? decodeBase32(key) : key; + const buffer = counterToBuffer(counter); + + const hmac = await generateHmacSha1(parsedKey, buffer); + return truncate(hmac, 6); +} + +/** Verifies a HMAC-based one-time password (HOTP) using the specified key and counter. + * + * @param otp the one-time password to verify. + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param counter a counter value used to generate the HOTP. + */ +export async function verifyHotp( + otp: string, + key: string | Uint8Array, + counter: number, +): Promise { + return otp === await generateHotp(key, counter); +} diff --git a/crypto/totp.bench.ts b/crypto/totp.bench.ts new file mode 100644 index 0000000..665f310 --- /dev/null +++ b/crypto/totp.bench.ts @@ -0,0 +1,11 @@ +import { generateTotp, verifyTotp } from "./totp.ts"; + +const secret = "OCOMBLGUREYUXFQJIL75FQFCKYFCKLQP"; + +Deno.bench("generateTotp()", async () => { + await generateTotp(secret, 1000000000), "270103"; +}); + +Deno.bench("verifyTotp()", async () => { + await verifyTotp("270103", secret, 1000000000); +}); diff --git a/crypto/totp.test.ts b/crypto/totp.test.ts new file mode 100644 index 0000000..aafdab3 --- /dev/null +++ b/crypto/totp.test.ts @@ -0,0 +1,22 @@ +import { assert, assertEquals } from "@std/assert"; +import { generateTotp, verifyTotp } from "./totp.ts"; + +const secret = "OCOMBLGUREYUXFQJIL75FQFCKYFCKLQP"; +const t = 1704067200000; + +Deno.test("generateTotp()", async () => { + console.log(); + assertEquals(await generateTotp(secret, 0, t), "342743"); + assertEquals(await generateTotp(secret, 944996400, t), "149729"); + assertEquals(await generateTotp(secret, 976618800, t), "372018"); + assertEquals(await generateTotp(secret, 1723245550, t), "665341"); + assertEquals(await generateTotp(secret, t, t), "187492"); +}); + +Deno.test("verifyTotp()", async () => { + assert(await verifyTotp("342743", secret, 0, t)); + assert(await verifyTotp("149729", secret, 944996400, t)); + assert(await verifyTotp("372018", secret, 976618800, t)); + assert(await verifyTotp("665341", secret, 1723245550, t)); + assert(await verifyTotp("187492", secret, t, t)); +}); diff --git a/crypto/totp.ts b/crypto/totp.ts new file mode 100644 index 0000000..e4e5378 --- /dev/null +++ b/crypto/totp.ts @@ -0,0 +1,31 @@ +import { generateHotp } from "./hotp.ts"; + +/** Generate a TOTP value from a key and a time. + * + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param t0 the initial time to use for the counter. + * @returns the TOTP value. + */ +export function generateTotp( + key: string | Uint8Array, + t0 = 0, + t = Date.now(), +): Promise { + const counter = Math.floor((t - t0) / 30000); + return generateHotp(key, counter); +} + +/** Verifies a TOTP value from a key and a time. + * + * @param otp the one-time password to verify. + * @param key a secret key used to generate the HOTP. Can be a string in base32 encoding or a Uint8Array. + * @param t0 the initial time to use for the counter. + */ +export async function verifyTotp( + otp: string, + key: string | Uint8Array, + t0 = 0, + t = Date.now(), +): Promise { + return otp === await generateTotp(key, t0, t); +} diff --git a/crypto/utils.bench.ts b/crypto/utils.bench.ts new file mode 100644 index 0000000..9d0c590 --- /dev/null +++ b/crypto/utils.bench.ts @@ -0,0 +1,14 @@ +import { assertEquals } from "@std/assert"; +import { generateSecret, generateSecretBytes } from "./utils.ts"; + +for (const l of [1, 8, 20, 100, 1000]) { + Deno.bench(`generateSecretBytes - ${l}`, () => { + const secretBytes = generateSecretBytes(l); + assertEquals(secretBytes.length, l); + }); + + Deno.bench(`generateSecret - ${l}`, () => { + const secret = generateSecret(l); + assertEquals(secret.length, l); + }); +} diff --git a/crypto/utils.test.ts b/crypto/utils.test.ts new file mode 100644 index 0000000..217e8fa --- /dev/null +++ b/crypto/utils.test.ts @@ -0,0 +1,12 @@ +import { assertEquals } from "@std/assert"; +import { generateSecret, generateSecretBytes } from "./utils.ts"; + +Deno.test("generateSecretBytes - default", () => { + const secretBytes = generateSecretBytes(); + assertEquals(secretBytes.length, 20); +}); + +Deno.test("generateSecret - default", () => { + const secret = generateSecret(); + assertEquals(secret.length, 20); +}); diff --git a/crypto/utils.ts b/crypto/utils.ts new file mode 100644 index 0000000..513343b --- /dev/null +++ b/crypto/utils.ts @@ -0,0 +1,27 @@ +import { encodeBase32 } from "@std/encoding"; + +/** Generates a secret key of the specified length. + * + * @param length how many bytes the secret key should be. + * @returns a secret as a byte array + */ +export function generateSecretBytes(length: number = 20): Uint8Array { + const buffer = new Uint8Array(length); + crypto.getRandomValues(buffer); + return buffer; +} + +/** Generates a secret key of the specified length in selected encoding + * + * @param length how many characters the secret key should be. + * @returns a secret string + */ +export function generateSecret( + length: number = 20, +): string { + const buffer = generateSecretBytes(Math.max(6, length)); + + const encoded = encodeBase32(buffer); + + return encoded.replaceAll("=", "").slice(-length); +}