Files
ente/web/apps/auth/src/services/code.ts
2024-05-24 10:10:59 +05:30

175 lines
5.3 KiB
TypeScript

import { ensure } from "@/utils/ensure";
import { HOTP, TOTP } from "otpauth";
/**
* A parsed representation of an *OTP code URI.
*
* This is all the data we need to drive a OTP generator.
*/
export interface Code {
/** A unique id for the corresponding "auth entity" in our system. */
id?: String;
/** The type of the code. */
type: "totp" | "hotp";
/** The user's account or email for which this code is used. */
account?: string;
/** The name of the entity that issued this code. */
issuer: string;
/** Number of digits in the generated OTP. */
digits: number;
/**
* The time period (in seconds) for which a single OTP generated from this
* code remains valid.
*/
period: number;
/**
* The secret that is used to drive the OTP generator.
*
* This is an arbitrary key encoded in Base32 that drives the HMAC (in a
* {@link type}-specific manner).
*/
secret: string;
/** The (HMAC) algorithm used by the OTP generator. */
algorithm: "sha1" | "sha256" | "sha512";
/** The original string from which this code was generated. */
uriString: string;
}
/**
* Convert a OTP code URI into its parse representation, a {@link Code}.
*
* @param id A unique ID of this code within the auth app.
*
* @param uriString A string specifying how to generate a TOTP/HOTP/Steam OTP
* code. These strings are of the form:
*
* - (TOTP)
* otpauth://totp/ACME:user@example.org?algorithm=SHA1&digits=6&issuer=acme&period=30&secret=ALPHANUM
*
* See also `auth/test/models/code_test.dart`.
*/
export const codeFromURIString = (id: string, uriString: string): Code => {
try {
return _codeFromURIString(id, uriString);
} catch (e) {
// We might have legacy encodings of account names that contain a "#",
// which causes the rest of the URL to be treated as a fragment, and
// ignored. See if this was potentially such a case, otherwise rethrow.
if (uriString.includes("#"))
return _codeFromURIString(id, uriString.replaceAll("#", "%23"));
throw e;
}
};
const _codeFromURIString = (id: string, uriString: string): Code => {
const url = new URL(uriString);
return {
id,
type: parseType(url),
account: parseAccount(url),
issuer: parseIssuer(url),
digits: parseDigits(url),
period: parsePeriod(url),
secret: parseSecret(url),
algorithm: parseAlgorithm(url),
uriString,
};
};
const parseType = (url: URL): Code["type"] => {
const t = url.host.toLowerCase();
if (t == "totp" || t == "hotp") return t;
throw new Error(`Unsupported code with host ${t}`);
};
const parseAccount = (url: URL): string | undefined => {
// "/ACME:user@example.org" => "user@example.org"
let p = decodeURIComponent(url.pathname);
if (p.startsWith("/")) p = p.slice(1);
if (p.includes(":")) p = p.split(":").slice(1).join(":");
return p;
};
const parseIssuer = (url: URL): string => {
// If there is a "issuer" search param, use that.
let issuer = url.searchParams.get("issuer");
if (issuer) {
// This is to handle bug in old versions of Ente Auth app.
if (issuer.endsWith("period")) {
issuer = issuer.substring(0, issuer.length - 6);
}
return issuer;
}
// Otherwise use the `prefix:` from the account as the issuer.
// "/ACME:user@example.org" => "ACME"
let p = decodeURIComponent(url.pathname);
if (p.startsWith("/")) p = p.slice(1);
if (p.includes(":")) p = p.split(":")[0];
else if (p.includes("-")) p = p.split("-")[0];
return p;
};
const parseDigits = (url: URL): number =>
parseInt(url.searchParams.get("digits") ?? "", 10) || 6;
const parsePeriod = (url: URL): number =>
parseInt(url.searchParams.get("period") ?? "", 10) || 30;
const parseAlgorithm = (url: URL): Code["algorithm"] => {
switch (url.searchParams.get("algorithm")?.toLowerCase()) {
case "sha256":
return "sha256";
case "sha512":
return "sha512";
default:
return "sha1";
}
};
const parseSecret = (url: URL): string =>
ensure(url.searchParams.get("secret")).replaceAll(" ", "").toUpperCase();
/**
* Generate a pair of OTPs (one time passwords) from the given {@link code}.
*
* @param code The parsed code data, including the secret and code type.
*
* @returns a pair of OTPs, the current one and the next one, using the given
* {@link code}.
*/
export const generateOTPs = (code: Code): [otp: string, nextOTP: string] => {
let otp: string;
let nextOTP: string;
switch (code.type) {
case "totp": {
const totp = new TOTP({
secret: code.secret,
algorithm: code.algorithm,
period: code.period,
digits: code.digits,
});
otp = totp.generate();
nextOTP = totp.generate({
timestamp: new Date().getTime() + code.period * 1000,
});
break;
}
case "hotp": {
const hotp = new HOTP({
secret: code.secret,
counter: 0,
algorithm: code.algorithm,
});
otp = hotp.generate();
nextOTP = hotp.generate({ counter: 1 });
break;
}
}
return [otp, nextOTP];
};