[web] Steam support on web version of auth (#1840)
This commit is contained in:
commit
ca24a86179
5 changed files with 123 additions and 23 deletions
|
@ -7,6 +7,7 @@
|
|||
"@ente/accounts": "*",
|
||||
"@ente/eslint-config": "*",
|
||||
"@ente/shared": "*",
|
||||
"jssha": "~3.3.1",
|
||||
"otpauth": "^9"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
|
|
74
web/apps/auth/src/services/steam.ts
Normal file
74
web/apps/auth/src/services/steam.ts
Normal 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");
|
||||
};
|
|
@ -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).
|
||||
|
|
Loading…
Add table
Reference in a new issue