Skip to content

Commit

Permalink
lib main code
Browse files Browse the repository at this point in the history
  • Loading branch information
tksst committed Jan 25, 2024
1 parent 799fdea commit 4f7ae2e
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 26 deletions.
7 changes: 6 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@
"lint:eslint": "eslint --color .",
"lint:tsc": "tsc --noEmit"
},
"dependencies": {},
"dependencies": {
"base32-decode": "1.0.0"
},
"engines": {
"node": ">= 14"
},
"devDependencies": {
"@types/node": "16.18.75"
}
}
64 changes: 64 additions & 0 deletions packages/core/src/lib/hotp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getWebCrypto } from "./webCrypto.js";

async function hmacSHA256(
key: ArrayBuffer | ArrayBufferView,
data: ArrayBuffer | ArrayBufferView,
): Promise<ArrayBuffer> {
const { subtle } = await getWebCrypto();

const importedKey = await subtle.importKey(
"raw",
key,
{
name: "HMAC",
hash: { name: "SHA-256" },
},
false,
["sign"],
);

return await subtle.sign("HMAC", importedKey, data);
}

function numToBigEndianBytes(value: number | bigint): ArrayBuffer {
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setBigUint64(0, BigInt(value));
return buf;
}

function truncate(mac: Uint8Array, digits: number): number {
// lowest 4 bits of the last byte
const offset = mac[mac.length - 1]! & 0x0f;

const view = new DataView(mac.buffer, offset);

// read 32 bit big endian integer and clear the highest bit
const value = view.getUint32(0) & 0x7fffffff;

return value % 10 ** digits;
}

export async function hotpSHA256(key: ArrayBuffer | ArrayBufferView, counter: number | bigint): Promise<string> {
if (counter < 0) {
throw new RangeError(`counter: ${counter} is not a natural number.`);
}

if (counter > 2n ** 64n - 1n) {
throw new RangeError(`counter: ${counter} must be less than 2^64 - 1.`);
}

if (typeof counter === "number") {
if (!Number.isInteger(counter)) {
throw new RangeError(`counter: ${counter} is not an integer.`);
}
if (!Number.isSafeInteger(counter)) {
throw new RangeError(`counter: ${counter} is not a safe integer. Use BigInt instead.`);
}
}

const mac = await hmacSHA256(key, numToBigEndianBytes(counter));

const digits = 6;
return truncate(new Uint8Array(mac), digits).toString().padStart(digits, "0");
}
43 changes: 43 additions & 0 deletions packages/core/src/lib/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";

import { generate, InvalidActivationCodeError } from "./index.js";

const actCode1 = "XUU75-RROTT-Y5IP6-U3BMV";
const actCode2 = "2G5NX-EYGRU-6IRP6-KDDG3";

describe("generate", () => {
it("Usual case", async () => {
expect(await generate(actCode1, 0)).toBe("246805");
expect(await generate(actCode1, 1)).toBe("598141");
expect(await generate(actCode1, 2)).toBe("445167");
expect(await generate(actCode1, 3)).toBe("201140");
expect(await generate(actCode1, 4)).toBe("474662");

expect(await generate(actCode2, 0)).toBe("700124");
expect(await generate(actCode2, 1)).toBe("205050");
expect(await generate(actCode2, 2)).toBe("317499");
expect(await generate(actCode2, 3)).toBe("178992");
expect(await generate(actCode2, 4)).toBe("555776");
});

it("Invalid counter: negative value", async () => {
await expect(generate(actCode1, -1)).rejects.toThrow(RangeError);
});

it("Invalid counter: non-integer value", async () => {
await expect(generate(actCode1, 0.5)).rejects.toThrow(RangeError);
});

it("Invalid counter: too large", async () => {
await expect(generate(actCode1, 2n ** 64n)).rejects.toThrow(RangeError);
});

it("Invalid counter: NaN", async () => {
await expect(generate(actCode1, Number.NaN)).rejects.toThrow(RangeError);
});

it("Invalid activation code", async () => {
await expect(generate("foo", 0)).rejects.toThrow(InvalidActivationCodeError);
await expect(generate(actCode1.toLowerCase(), 0)).rejects.toThrow(InvalidActivationCodeError);
});
});
56 changes: 55 additions & 1 deletion packages/core/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,55 @@
export * from "./main/main.js";
import base32Decode from "base32-decode";

import { getWebCrypto } from "./webCrypto.js";
import { hotpSHA256 } from "./hotp.js";

export class InvalidActivationCodeError extends Error {
public constructor(message?: string, options?: { cause: unknown }) {
// @ts-expect-error options.cause needs ES2022 but no harm for old environments
super(message, options);
this.name = "InvalidActivationCodeError";
}
}

const regex = /^([2-7A-Z]{4})[2-7A-Z]-([2-7A-Z]{4})[2-7A-Z]-([2-7A-Z]{4})[2-7A-Z]-([2-7A-Z]{4})[2-7A-Z]$/;

const errorStr = "Invalid activation code";

function codeToSecretBytes(activationCode: string): Uint8Array {
const result = regex.exec(activationCode);

if (result === null) {
throw new InvalidActivationCodeError(errorStr);
}

const decoded = (() => {
try {
return base32Decode(`${result[1]}${result[2]}${result[3]}${result[4]}`, "RFC4648");
} catch (error) {
throw new InvalidActivationCodeError(errorStr, { cause: error });
}
})();

return new Uint8Array(decoded);
}

// Ported and simplified from:
// datr/MobilePASSER
// https://github.com/datr/MobilePASSER/blob/798252e73eaeae9308521c67b09d1433bd322766/mobilepasser/utils/token_generation.py#L14
// bouncycastle
// https://github.com/bcgit/bc-java/blob/a4e57f2019867c7ee86508fc5b136d73fd4922d9/core/src/main/java/org/bouncycastle/crypto/generators/KDF1BytesGenerator.java
async function fixedKdfSha256(secret: Uint8Array): Promise<ArrayBuffer> {
const data = new Uint8Array(secret.length + 4);
data.set(secret, 0);

const crypto = await getWebCrypto();

return crypto.subtle.digest("SHA-256", data);
}

export async function generate(activationCode: string, counter: number | bigint): Promise<string> {
const key1 = codeToSecretBytes(activationCode);
const key2 = await fixedKdfSha256(key1);

return hotpSHA256(key2, counter);
}
20 changes: 0 additions & 20 deletions packages/core/src/lib/main/main.test.ts

This file was deleted.

3 changes: 0 additions & 3 deletions packages/core/src/lib/main/main.ts

This file was deleted.

17 changes: 17 additions & 0 deletions packages/core/src/lib/webCrypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export async function getWebCrypto() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (globalThis.crypto !== undefined) {
return globalThis.crypto;
}

try {
const nodeCrypto = await import("node:crypto");
return nodeCrypto.webcrypto;
} catch (error) {
// TODO
// @ts-expect-error error cause >= ES2022
throw new Error("This platform does not support Web Crypto API.", {
cause: error,
});
}
}
3 changes: 3 additions & 0 deletions packages/core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"extends": "@tksst/project-configs/tsconfig.json",
"compilerOptions": {
"lib": ["es2020", "dom"],
},
"include": ["src"],
}
18 changes: 17 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 4f7ae2e

Please sign in to comment.