Skip to content

Commit

Permalink
lib main code
Browse files Browse the repository at this point in the history
  • Loading branch information
tksst committed Feb 7, 2024
1 parent 3578d40 commit b30bd74
Show file tree
Hide file tree
Showing 14 changed files with 215 additions and 29 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build-lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
os:
- self-hosted
node-version:
- 16.14.0
- 18
- 20
- 21
Expand Down
20 changes: 16 additions & 4 deletions packages/mobilepass-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,36 @@
},
"type": "module",
"exports": {
"import": "./dist/lib/index.js",
"require": "./dist/lib/index.cjs"
".": {
"node": {
"import": "./dist/lib/node.js",
"require": "./dist/lib/node.cjs"
},
"default": {
"import": "./dist/lib/index.js",
"require": "./dist/lib/index.cjs"
}
}
},
"scripts": {
"clean": "tss-rmrf ./dist/",
"build": "run-s --continue-on-error clean build:*",
"build:lib": "tsup --config ./tsup.config.lib.mjs",
"build:node": "tsup --config ./tsup.config.node.mjs",
"prepack": "pnpm build",
"watch": "npm-run-all clean --parallel --continue-on-error --print-label watch:*",
"watch:lib": "pnpm build:lib --watch",
"watch:node": "pnpm build:node --watch",
"fix": "run-s --continue-on-error fix:*",
"fix:eslint": "pnpm lint:eslint --fix",
"lint": "run-p --continue-on-error --print-label lint:*",
"lint:eslint": "eslint --color .",
"lint:tsc": "tsc --noEmit"
},
"dependencies": {},
"dependencies": {
"base32-decode": "1.0.0"
},
"engines": {
"node": ">= 14"
"node": ">= 15.0.0"
}
}
3 changes: 2 additions & 1 deletion packages/mobilepass-core/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./main/main.js";
export { generate } from "./main/main.js";
export { InvalidActivationCodeError } from "./utils/utils.js";
20 changes: 0 additions & 20 deletions packages/mobilepass-core/src/lib/main/main.test.ts

This file was deleted.

12 changes: 9 additions & 3 deletions packages/mobilepass-core/src/lib/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export const main = (): void => {
// console.log("This is a template for Node.js CLI or Library project in TypeScript.");
};
import { hotpSHA256 } from "../utils/hotp.js";
import { activationCodeToKey, fixedKdfSha256 } from "../utils/utils.js";

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

return await hotpSHA256(key2, counter);
}
43 changes: 43 additions & 0 deletions packages/mobilepass-core/src/lib/node.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 "./node.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);
});
});
11 changes: 11 additions & 0 deletions packages/mobilepass-core/src/lib/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { generate } from "./main/main.js";
export { InvalidActivationCodeError } from "./utils/utils.js";

// @ts-expect-error This error is intended because this is polyfill for Node.js
import { webcrypto } from "node:crypto";

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (globalThis.crypto === undefined) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
globalThis.crypto = webcrypto;
}
18 changes: 18 additions & 0 deletions packages/mobilepass-core/src/lib/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export async function sha256(data: BufferSource): Promise<ArrayBuffer> {
return await crypto.subtle.digest("SHA-256", data);
}

export async function hmacSHA256(key: BufferSource, data: BufferSource): Promise<ArrayBuffer> {
const importedKey = await crypto.subtle.importKey(
"raw",
key,
{
name: "HMAC",
hash: { name: "SHA-256" },
},
false,
["sign"],
);

return await crypto.subtle.sign("HMAC", importedKey, data);
}
45 changes: 45 additions & 0 deletions packages/mobilepass-core/src/lib/utils/hotp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { hmacSHA256 } from "./crypto.js";

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
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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");
}
44 changes: 44 additions & 0 deletions packages/mobilepass-core/src/lib/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import base32Decode from "base32-decode";

import { sha256 } from "./crypto.js";

export class InvalidActivationCodeError extends Error {
public constructor(message?: string, options?: { cause: unknown }) {
super(message, options);
this.name = "InvalidActivationCodeError";
}
}

const activationCodeRegex = /^([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";

export function activationCodeToKey(activationCode: string): Uint8Array {
const result = activationCodeRegex.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
export async function fixedKdfSha256(secret: Uint8Array): Promise<ArrayBuffer> {
const data = new Uint8Array(secret.length + 4);
data.set(secret, 0);

return await sha256(data);
}
4 changes: 4 additions & 0 deletions packages/mobilepass-core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"extends": "@tksst/project-configs/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "dom"]
},
"include": ["src"]
}
1 change: 1 addition & 0 deletions packages/mobilepass-core/tsup.config.lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defineConfig } from "tsup";
export default defineConfig({
...libOptions,
entry: ["src/lib/index.ts"],
target: ["safari15"],

// If you know that this library is for Node.js or for a browser, you may want to set the platform.
// platform: "node",
Expand Down
12 changes: 12 additions & 0 deletions packages/mobilepass-core/tsup.config.node.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { libOptions } from "@tksst/project-configs/tsup-config.mjs";
import { defineConfig } from "tsup";

export default defineConfig({
...libOptions,
entry: ["src/lib/node.ts"],
target: ["node15.0.0"],

// If you know that this library is for Node.js or for a browser, you may want to set the platform.
// platform: "node",
// platform: "browser",
});
10 changes: 9 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 b30bd74

Please sign in to comment.