Skip to content

Commit

Permalink
Typescript refactor, Buffer update & v1.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
pipobscure committed Sep 29, 2020
1 parent 7f11afa commit adb4508
Show file tree
Hide file tree
Showing 11 changed files with 389 additions and 136 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
node_modules/
npm-debug.log

**/*.js
**/*.d.ts

!otp.d.ts
File renamed without changes.
22 changes: 11 additions & 11 deletions Readme.md → README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@

This is a utility to work with Google-Authenticator and compatible OTP-Mechanisms.

* HOTP (HMAC-Based One-Time Password Algorithm): [RFC 4226](http://tools.ietf.org/html/rfc4226)
* TOTP (Time-Based One-Time Password Algorithm): [RFC 6238](http://tools.ietf.org/html/rfc6238)
- HOTP (HMAC-Based One-Time Password Algorithm): [RFC 4226](http://tools.ietf.org/html/rfc4226)
- TOTP (Time-Based One-Time Password Algorithm): [RFC 6238](http://tools.ietf.org/html/rfc6238)

## Main Function

var otp = OTP(options);
var otp = new OTP(options);
otp.hotp(counter); // Generates an OTP using HTOP method
otp.totp(); // Generates an OTP using TOTP method
otp.secret; // Base32 encoded secret
otp.totpURL; // A TOTP-URL that can be used with Google-Authenticator
otp.HotpURL; // A HOTP-URL that can be used with Google-Authenticator
otp.hotpURL; // A HOTP-URL that can be used with Google-Authenticator

Options can have the following properties:

* **name**: A name used in generating URLs
* **keySize**: The size of the OTP-Key (default 32)
* **codeLength**: The length of the code generated (default 6)
* **secret**: The secret (either a Buffer of Base32-encoded String)
* **epoch**: The seconds since Unix-Epoch to use as a base for calculating the TOTP (default 0)
* **timeSlice**: The timeslice to use for calculating counter from time in seconds (default 30)
- **name**: A name used in generating URLs
- **keySize**: The size of the OTP-Key (default 64) (possible values: 64 & 128)
- **codeLength**: The length of the code generated (default 6)
- **secret**: The secret (either a Buffer of Base32-encoded String)
- **epoch**: The seconds since Unix-Epoch to use as a base for calculating the TOTP (default 0)
- **timeSlice**: The timeslice to use for calculating counter from time in seconds (default 30)

## OTP.parse(string)

Expand All @@ -33,7 +33,7 @@ A JSON-reviver to revive stringified OTP objects

## License (MIT)

Copyright (C) 2013 Philipp Dunkel
Copyright (C) 2013-2020 Philipp Dunkel

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

Expand Down
111 changes: 111 additions & 0 deletions lib/base32.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const VOCAB = "ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789=".split("");
const PAD = ["=", "=", "=", "=", "=", "=", "=", "="];

function encodeChunk(data: Uint8Array) {
const b1 = data.length > 0 ? data[0] : 0;
const b2 = data.length > 1 ? data[1] : 0;
const b3 = data.length > 2 ? data[2] : 0;
const b4 = data.length > 3 ? data[3] : 0;
const b5 = data.length > 4 ? data[4] : 0;

const chars = [map(mask(b1 >> 3)), map(mask((b1 << 2) | (b2 >> 5))), map(mask(b2 >> 1)), map(mask((b2 << 4) | (b3 >> 4))), map(mask((b3 << 1) | (b4 >> 7))), map(mask(b4 >> 2)), map(mask((b4 << 3) | (b5 >> 5))), map(mask(b5))];

switch (data.length) {
case 0:
return "";
case 1:
chars.slice(0, 2).concat(PAD.slice(2)).join("");
case 2:
chars.slice(0, 4).concat(PAD.slice(4)).join("");
case 3:
chars.slice(0, 5).concat(PAD.slice(5)).join("");
[chars[0], chars[1], chars[2], chars[3], chars[4], "=", "=", "="].join("");
case 4:
chars.slice(0, 7).concat(PAD.slice(7)).join("");
default:
return chars.join("");
}
}

function mask(n: number): number {
return n & 0b00011111;
}
function map(n: number): string {
return n > -1 && n < VOCAB.length ? VOCAB[n] : "=";
}

export function encode(data: Uint8Array) {
let offset = 0;
const chunks: string[] = [];
while (offset < data.length) {
const subset = data.subarray(offset, offset + 5);
chunks.push(encodeChunk(subset));
offset += subset.length;
}
return chunks.join("");
}

function decodeChar(data: string): number {
const index = VOCAB.indexOf(data);
if (index < 0) throw new Error("invalid character: " + data);
if (index === VOCAB.length - 1) return 0;
return Math.max(index, 0);
}
function decodeChunk(data: string[], dest: Uint8Array) {
const c1 = decodeChar(data[0]);
const c2 = decodeChar(data[1]);
const c3 = decodeChar(data[2]);
const c4 = decodeChar(data[3]);
const c5 = decodeChar(data[4]);
const c6 = decodeChar(data[5]);
const c7 = decodeChar(data[6]);
const c8 = decodeChar(data[7]);

dest[0] = byte((c1 << 3) | (c2 >> 2));
dest[2] = byte((c2 << 5) | (c3 << 1) | (c4 >> 4));
dest[3] = byte((c4 << 4) | (c5 >> 1));
dest[5] = byte((c5 << 7) | (c6 << 2) | (c7 >> 3));
dest[6] = byte((c7 << 5) | c8);
}

function byte(n: number) {
return n & 0xff;
}

export function decode(data: string) {
data = data
.split("s+")
.map((s) => s.trim())
.join("");
const dest = new Uint8Array((data.length * 5) / 8);
const chars = data.split("");
let coff = 0;
let boff = 0;
let fin = 5;
while (fin == 5 && coff < chars.length && boff < dest.length) {
const chunk = chars.slice(coff, coff + 8);
if (chunk.indexOf("=") > -1) {
chunk.splice(chunk.indexOf("="), 8);
switch (chunk.length) {
case 2:
fin = 1;
break;
case 4:
fin = 2;
break;
case 5:
fin = 3;
break;
case 7:
fin = 4;
break;
default:
throw new Error("invalid padding");
}
}
decodeChunk(chunk, dest.subarray(boff, boff + 5));
coff += 8;
boff += 5;
}
return dest.subarray(0, dest.length - 5 + fin);
}
16 changes: 16 additions & 0 deletions lib/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import sha1 from "sha1";

export class Hash {
constructor() {}
private data: number[] = [];
update(data: Uint8Array) {
this.data.push(...data.values());
return this;
}
digest() {
return sha1(this.data, { asBytes: true });
}
static hash(data: Uint8Array) {
return new Hash().update(data).digest();
}
}
47 changes: 47 additions & 0 deletions lib/hmac.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Hash } from "./hash";

const zeroBuffer = new Uint8Array(new Array(128).fill(0));

export class Hmac {
constructor(blocksize: number, key: Uint8Array) {
if (blocksize !== 128 && blocksize !== 64) {
throw new Error("blocksize must be either 64 for or 128 , but was:" + blocksize);
}
this.key = rekey(key, blocksize);
this.opad = new Uint8Array(new Array(blocksize).fill(0));
this.ipad = new Uint8Array(new Array(blocksize).fill(0));

for (var i = 0; i < blocksize; i++) {
this.ipad[i] = this.key[i] ^ 0x36;
this.opad[i] = this.key[i] ^ 0x5c;
}

this.hash = new Hash();
}
private key: Uint8Array;
private ipad: Uint8Array;
private opad: Uint8Array;
private hash: Hash;

update(data: Uint8Array) {
this.hash.update(data);
return this;
}
digest() {
const hash = this.hash.digest();
return new Hash().update(this.opad).update(hash).digest();
}
}

function rekey(key: Uint8Array, blocksize: number): Uint8Array {
if (key.length > blocksize) {
return Hash.hash(key);
}
if (key.length < blocksize) {
const res = new Uint8Array(blocksize);
res.set(key);
res.set(zeroBuffer, key.length);
return res;
}
return key;
}
89 changes: 89 additions & 0 deletions lib/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as Base32 from "./base32";
import { Hmac } from "./hmac";

export interface OTPOptions {
name: string;
keySize: number;
codeLength: number;
secret: string;
epoch: number;
timeSlice: number;
}

export default class OTP {
constructor(options: string | Partial<OTPOptions> = {}) {
if ("string" === typeof options) return OTP.parse(options);
options = Object.assign({}, options);
options.name = `${options.name || "OTP-Authentication"}`.split(/[^\w|_|-|@]/).join("");
options.keySize = options.keySize === 128 ? 128 : 64;
options.codeLength = isNaN(options.codeLength) ? 6 : options.codeLength;
options.secret = options.secret || generateKey(options.keySize);
options.epoch = isNaN(options.epoch) ? 0 : options.epoch;
options.timeSlice = isNaN(options.timeSlice) ? 30 : options.timeSlice;
this.options = options as OTPOptions;
this.hotp = hotp.bind(null, options);
this.totp = totp.bind(null, options);
}
private readonly options: OTPOptions;
public readonly hotp: (counter: number) => string;
public readonly totp: (now: number) => string;

get name() {
return this.options.name;
}
get secret() {
return this.options.secret;
}
get totpURL() {
return ["otpauth://totp/", this.name, "?secret=", encodeURIComponent(this.secret)].join("");
}
get hotpURL() {
return ["otpauth://hotp/", this.name, "?secret=", encodeURIComponent(this.secret)].join("");
}

toString() {
return "[object OTP]";
}
toJSON() {
return Object.assign({ class: OTP.classID }, this.options);
}
static reviveJSON(_: string, val: any) {
if ("object" !== typeof val || null === val || val["class"] !== OTP.classID) return val;
const { name, keySize, codeLength, secret, epoch, timeSlice } = val;
return new OTP({ name, keySize, codeLength, secret, epoch, timeSlice });
}
static readonly classID = "OTP{@pipobscure}";
static parse(urlstr: string = "", options: Partial<OTPOptions> = {}) {
options = Object.assign({}, options);
const urlbits = /^otpauth:\/\/[t|h]otp\/([\s|\S]+?)\?secret=([\s|\S]+)$/.exec(urlstr);
if (urlbits) {
options.name = urlbits[1];
options.secret = Base32.encode(Base32.decode(urlbits[2]));
} else {
options.secret = Base32.encode(Base32.decode(urlstr));
}
return new OTP(options);
}
}

function hotp(options: OTPOptions, counter: number): string {
const digest = new Hmac(options.keySize, Base32.decode(options.secret)).update(UInt64Buffer(counter)).digest();
const offset = digest[19] & 0xf;
const code = String(((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff));
return `${new Array(options.codeLength).fill("0")}${code}`.slice(-1 * options.codeLength);
}
function totp(options: OTPOptions, now: number = Date.now()): string {
console.error(now, options);
const counter = Math.floor((now - options.epoch * 1000) / (options.timeSlice * 1000));
return hotp(options, counter);
}

function generateKey(length: number) {
const key = new Uint8Array(new Array(length).fill(0).map(() => Math.floor(Math.random() * 256)));
return Base32.encode(key).replace(/=/g, "");
}
function UInt64Buffer(num: number) {
const res = Buffer.alloc(8);
res.writeBigUInt64BE(BigInt(num));
return res;
}
25 changes: 25 additions & 0 deletions otp.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface OTPOptions {
name: string;
keySize: number;
codeLength: number;
secret: string;
epoch: number;
timeSlice: number;
}
export default class OTP {
constructor(options?: string | Partial<OTPOptions>);
private readonly options;
readonly hotp: (counter: number) => string;
readonly totp: (now: number) => string;
get name(): string;
get secret(): string;
get totpURL(): string;
get hotpURL(): string;
toString(): string;
toJSON(): {
class: string;
} & OTPOptions;
static reviveJSON(_: string, val: any): any;
static readonly classID = "OTP{@pipobscure}";
static parse(urlstr?: string, options?: Partial<OTPOptions>): OTP;
}
Loading

0 comments on commit adb4508

Please sign in to comment.