[web] Steam support on web version of auth (#1840)

This commit is contained in:
Manav Rathi 2024-05-24 14:01:06 +05:30 committed by GitHub
commit ca24a86179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 123 additions and 23 deletions

View file

@ -7,6 +7,7 @@
"@ente/accounts": "*",
"@ente/eslint-config": "*",
"@ente/shared": "*",
"jssha": "~3.3.1",
"otpauth": "^9"
}
}

View file

@ -187,28 +187,21 @@ const CodeDisplay: React.FC<CodeDisplay> = ({ code }) => {
useEffect(() => {
// Generate to set the initial otp and nextOTP on component mount.
regen();
const codeType = code.type;
const codePeriodInMs = code.period * 1000;
const timeToNextCode =
codePeriodInMs - (new Date().getTime() % codePeriodInMs);
const interval = null;
const periodMs = code.period * 1000;
const timeToNextCode = periodMs - (Date.now() % periodMs);
let interval: ReturnType<typeof setInterval> | undefined;
// Wait until we are at the start of the next code period, and then
// start the interval loop.
setTimeout(() => {
// We need to call regen() once before the interval loop to set the
// initial otp and nextOTP.
regen();
codeType.toLowerCase() === "totp" ||
codeType.toLowerCase() === "hotp"
? setInterval(() => {
regen();
}, codePeriodInMs)
: null;
interval = setInterval(() => regen, periodMs);
}, timeToNextCode);
return () => {
if (interval) clearInterval(interval);
};
return () => interval && clearInterval(interval);
}, [code]);
return (
@ -346,7 +339,7 @@ const TimerProgress: React.FC<TimerProgressProps> = ({ period }) => {
useEffect(() => {
const advance = () => {
const timeRemaining = us - ((new Date().getTime() * 1000) % us);
const timeRemaining = us - ((Date.now() * 1000) % us);
setProgress(timeRemaining / us);
};

View file

@ -1,5 +1,6 @@
import { ensure } from "@/utils/ensure";
import { HOTP, TOTP } from "otpauth";
import { Steam } from "./steam";
/**
* A parsed representation of an *OTP code URI.
@ -10,13 +11,19 @@ export interface Code {
/** A unique id for the corresponding "auth entity" in our system. */
id?: String;
/** The type of the code. */
type: "totp" | "hotp";
type: "totp" | "hotp" | "steam";
/** 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;
/**
* Length of the generated OTP.
*
* This is vernacularly called "digits", which is an accurate description
* for the OG TOTP/HOTP codes. However, steam codes are not just digits, so
* we name this as a content-neutral "length".
*/
length: number;
/**
* The time period (in seconds) for which a single OTP generated from this
* code remains valid.
@ -85,7 +92,7 @@ const _codeFromURIString = (id: string, uriString: string): Code => {
type,
account: parseAccount(path),
issuer: parseIssuer(url, path),
digits: parseDigits(url),
length: parseLength(url, type),
period: parsePeriod(url),
secret: parseSecret(url),
algorithm: parseAlgorithm(url),
@ -97,6 +104,7 @@ const parsePathname = (url: URL): [type: Code["type"], path: string] => {
const p = url.pathname.toLowerCase();
if (p.startsWith("//totp")) return ["totp", url.pathname.slice(6)];
if (p.startsWith("//hotp")) return ["hotp", url.pathname.slice(6)];
if (p.startsWith("//steam")) return ["steam", url.pathname.slice(7)];
throw new Error(`Unsupported code or unparseable path "${url.pathname}"`);
};
@ -130,8 +138,17 @@ const parseIssuer = (url: URL, path: string): string => {
return p;
};
const parseDigits = (url: URL): number =>
parseInt(url.searchParams.get("digits") ?? "", 10) || 6;
/**
* Parse the length of the generated code.
*
* The URI query param is called digits since originally TOTP/HOTP codes used
* this for generating numeric codes. Now we also support steam, which instead
* shows non-numeric codes, and also with a different default length of 5.
*/
const parseLength = (url: URL, type: Code["type"]): number => {
const defaultLength = type == "steam" ? 5 : 6;
return parseInt(url.searchParams.get("digits") ?? "", 10) || defaultLength;
};
const parsePeriod = (url: URL): number =>
parseInt(url.searchParams.get("period") ?? "", 10) || 30;
@ -167,11 +184,11 @@ export const generateOTPs = (code: Code): [otp: string, nextOTP: string] => {
secret: code.secret,
algorithm: code.algorithm,
period: code.period,
digits: code.digits,
digits: code.length,
});
otp = totp.generate();
nextOTP = totp.generate({
timestamp: new Date().getTime() + code.period * 1000,
timestamp: Date.now() + code.period * 1000,
});
break;
}
@ -186,6 +203,17 @@ export const generateOTPs = (code: Code): [otp: string, nextOTP: string] => {
nextOTP = hotp.generate({ counter: 1 });
break;
}
case "steam": {
const steam = new Steam({
secret: code.secret,
});
otp = steam.generate();
nextOTP = steam.generate({
timestamp: Date.now() + code.period * 1000,
});
break;
}
}
return [otp, nextOTP];
};

View file

@ -0,0 +1,74 @@
import jsSHA from "jssha";
import { Secret } from "otpauth";
/**
* Steam OTPs.
*
* Steam's algorithm is a custom variant of TOTP that uses a 26-character
* alphabet instead of digits.
*
* A Dart implementation of the algorithm can be found in
* https://github.com/elliotwutingfeng/steam_totp/blob/main/lib/src/steam_totp_base.dart
* (MIT license), and we use that as a reference. Our implementation is written
* in the style of the other TOTP/HOTP classes that are provided by the otpauth
* JS library that we use for the normal TOTP/HOTP generation
* https://github.com/hectorm/otpauth/blob/master/src/hotp.js (MIT license).
*/
export class Steam {
secret: Secret;
period: number;
constructor({ secret }: { secret: string }) {
this.secret = Secret.fromBase32(secret);
this.period = 30;
}
generate({ timestamp }: { timestamp: number } = { timestamp: Date.now() }) {
// Same as regular TOTP.
const counter = Math.floor(timestamp / 1000 / this.period);
// Same as regular HOTP, but algorithm is fixed to SHA-1.
const digest = sha1HMACDigest(this.secret.buffer, uintToArray(counter));
// Same calculation as regular HOTP.
const offset = digest[digest.length - 1] & 15;
let otp =
((digest[offset] & 127) << 24) |
((digest[offset + 1] & 255) << 16) |
((digest[offset + 2] & 255) << 8) |
(digest[offset + 3] & 255);
// However, instead of using this as the OTP, use it to index into
// the steam OTP alphabet.
const alphabet = "23456789BCDFGHJKMNPQRTVWXY";
const N = alphabet.length;
const steamOTP = [];
for (let i = 0; i < 5; i++) {
steamOTP.push(alphabet[otp % N]);
otp = Math.trunc(otp / N);
}
return steamOTP.join("");
}
}
// Equivalent to
// https://github.com/hectorm/otpauth/blob/master/src/utils/encoding/uint.js
const uintToArray = (n: number): Uint8Array => {
const result = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
result[i] = n & 255;
n >>= 8;
}
return result;
};
// We don't necessarily need a dependency on `jssha`, we could use SubtleCrypto
// here too. However, SubtleCrypto has an async interface, and we already have a
// transitive dependency on `jssha` via `otpauth`, so just using it here doesn't
// increase our bundle size any further.
const sha1HMACDigest = (key: ArrayBuffer, message: Uint8Array) => {
const hmac = new jsSHA("SHA-1", "UINT8ARRAY");
hmac.setHMACKey(key, "ARRAYBUFFER");
hmac.update(message);
return hmac.getHMAC("UINT8ARRAY");
};

View file

@ -198,3 +198,7 @@ some cases.
- [otpauth](https://github.com/hectorm/otpauth) is used for the generation of
the actual OTP from the user's TOTP/HOTP secret.
- However, otpauth doesn't support steam OTPs. For these, we need to compute
the SHA-1, and we use the same library, `jssha` that `otpauth` uses (since
it is already part of our bundle).