Skip to content

Commit

Permalink
Added totp and hotp
Browse files Browse the repository at this point in the history
  • Loading branch information
halvardssm committed Aug 9, 2024
1 parent c69d935 commit d24cfc4
Show file tree
Hide file tree
Showing 11 changed files with 313 additions and 1 deletion.
34 changes: 34 additions & 0 deletions crypto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
8 changes: 7 additions & 1 deletion crypto/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions crypto/hotp.bench.ts
Original file line number Diff line number Diff line change
@@ -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);
});
63 changes: 63 additions & 0 deletions crypto/hotp.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
81 changes: 81 additions & 0 deletions crypto/hotp.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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<string> {
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<boolean> {
return otp === await generateHotp(key, counter);
}
11 changes: 11 additions & 0 deletions crypto/totp.bench.ts
Original file line number Diff line number Diff line change
@@ -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);
});
22 changes: 22 additions & 0 deletions crypto/totp.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
31 changes: 31 additions & 0 deletions crypto/totp.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<boolean> {
return otp === await generateTotp(key, t0, t);
}
14 changes: 14 additions & 0 deletions crypto/utils.bench.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
12 changes: 12 additions & 0 deletions crypto/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 27 additions & 0 deletions crypto/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}

0 comments on commit d24cfc4

Please sign in to comment.