diff --git a/mobile/lib/face/db.dart b/mobile/lib/face/db.dart
index c72b197b46..9b5f42f540 100644
--- a/mobile/lib/face/db.dart
+++ b/mobile/lib/face/db.dart
@@ -13,6 +13,8 @@ import "package:photos/face/model/face.dart";
import "package:photos/models/file/file.dart";
import "package:photos/services/machine_learning/face_ml/face_clustering/face_info_for_clustering.dart";
import 'package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart';
+import "package:photos/services/machine_learning/face_ml/face_ml_result.dart";
+import "package:photos/utils/ml_util.dart";
import 'package:sqlite_async/sqlite_async.dart';
/// Stores all data for the FacesML-related features. The database can be accessed by `FaceMLDataDB.instance.database`.
@@ -249,7 +251,7 @@ class FaceMLDataDB {
final List fileId = [recentFileID];
int? avatarFileId;
if (avatarFaceId != null) {
- avatarFileId = int.tryParse(avatarFaceId.split('_')[0]);
+ avatarFileId = tryGetFileIdFromFaceId(avatarFaceId);
if (avatarFileId != null) {
fileId.add(avatarFileId);
}
@@ -401,8 +403,10 @@ class FaceMLDataDB {
final personID = map[personIdColumn] as String;
final clusterID = map[fcClusterID] as int;
final faceID = map[fcFaceId] as String;
- result.putIfAbsent(personID, () => {}).putIfAbsent(clusterID, () => {})
- .add(faceID);
+ result
+ .putIfAbsent(personID, () => {})
+ .putIfAbsent(clusterID, () => {})
+ .add(faceID);
}
return result;
}
@@ -476,8 +480,7 @@ class FaceMLDataDB {
for (final map in maps) {
final clusterID = map[fcClusterID] as int;
final faceID = map[fcFaceId] as String;
- final x = faceID.split('_').first;
- final fileID = int.parse(x);
+ final fileID = getFileIdFromFaceId(faceID);
result[fileID] = (result[fileID] ?? {})..add(clusterID);
}
return result;
@@ -665,19 +668,38 @@ class FaceMLDataDB {
return maps.first['count'] as int;
}
- Future getClusteredFaceCount() async {
+ Future getClusteredFileCount() async {
final db = await instance.asyncDB;
final List
= ({ code, otp, nextOTP }) => {
color: "grey",
}}
>
- {code.account}
+ {code.account ?? ""}
= ({ period }) => {
useEffect(() => {
const advance = () => {
- const timeRemaining = us - ((new Date().getTime() * 1000) % us);
+ const timeRemaining = us - ((Date.now() * 1000) % us);
setProgress(timeRemaining / us);
};
diff --git a/web/apps/auth/src/services/code.ts b/web/apps/auth/src/services/code.ts
index 064cc78743..b5da0ffe55 100644
--- a/web/apps/auth/src/services/code.ts
+++ b/web/apps/auth/src/services/code.ts
@@ -1,5 +1,6 @@
+import { ensure } from "@/utils/ensure";
import { HOTP, TOTP } from "otpauth";
-import { URI } from "vscode-uri";
+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;
+ 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.
@@ -32,7 +39,7 @@ export interface Code {
/** The (HMAC) algorithm used by the OTP generator. */
algorithm: "sha1" | "sha256" | "sha512";
/** The original string from which this code was generated. */
- uriString?: string;
+ uriString: string;
}
/**
@@ -45,100 +52,109 @@ export interface Code {
*
* - (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 => {
- const santizedRawData = uriString
- .replaceAll("+", "%2B")
- .replaceAll(":", "%3A")
- .replaceAll("\r", "")
- // trim quotes
- .replace(/^"|"$/g, "");
-
- const uriParams = {};
- const searchParamsString =
- decodeURIComponent(santizedRawData).split("?")[1];
- searchParamsString.split("&").forEach((pair) => {
- const [key, value] = pair.split("=");
- uriParams[key] = value;
- });
-
- const uri = URI.parse(santizedRawData);
- let uriPath = decodeURIComponent(uri.path);
- if (uriPath.startsWith("/otpauth://") || uriPath.startsWith("otpauth://")) {
- uriPath = uriPath.split("otpauth://")[1];
- } else if (uriPath.startsWith("otpauth%3A//")) {
- uriPath = uriPath.split("otpauth%3A//")[1];
+ 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);
+
+ // A URL like
+ //
+ // new URL("otpauth://hotp/Test?secret=AAABBBCCCDDDEEEFFF&issuer=Test&counter=0")
+ //
+ // is parsed differently by the browser and Node depending on the scheme.
+ // When the scheme is http(s), then both of them consider "hotp" as the
+ // `host`. However, when the scheme is "otpauth", as is our case, the
+ // browser considers the entire thing as part of the pathname. so we get.
+ //
+ // host: ""
+ // pathname: "//hotp/Test"
+ //
+ // Since this code run on browsers only, we parse as per that behaviour.
+
+ const [type, path] = parsePathname(url);
return {
id,
- type: _getType(uriPath),
- account: _getAccount(uriPath),
- issuer: _getIssuer(uriPath, uriParams),
- digits: parseDigits(uriParams),
- period: parsePeriod(uriParams),
- secret: parseSecret(uriParams),
- algorithm: parseAlgorithm(uriParams),
+ type,
+ account: parseAccount(path),
+ issuer: parseIssuer(url, path),
+ length: parseLength(url, type),
+ period: parsePeriod(url),
+ secret: parseSecret(url),
+ algorithm: parseAlgorithm(url),
uriString,
};
};
-const _getType = (uriPath: string): Code["type"] => {
- const oauthType = uriPath.split("/")[0].substring(0);
- if (oauthType.toLowerCase() === "totp") {
- return "totp";
- } else if (oauthType.toLowerCase() === "hotp") {
- return "hotp";
- }
- throw new Error(`Unsupported format with host ${oauthType}`);
+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}"`);
};
-const _getAccount = (uriPath: string): string => {
- try {
- const path = decodeURIComponent(uriPath);
- if (path.includes(":")) {
- return path.split(":")[1];
- } else if (path.includes("/")) {
- return path.split("/")[1];
- }
- } catch (e) {
- return "";
- }
+const parseAccount = (path: string): string | undefined => {
+ // "/ACME:user@example.org" => "user@example.org"
+ let p = decodeURIComponent(path);
+ if (p.startsWith("/")) p = p.slice(1);
+ if (p.includes(":")) p = p.split(":").slice(1).join(":");
+ return p;
};
-const _getIssuer = (uriPath: string, uriParams: { get?: any }): string => {
- try {
- if (uriParams["issuer"] !== undefined) {
- let issuer = uriParams["issuer"];
- // This is to handle bug in the ente auth app
- if (issuer.endsWith("period")) {
- issuer = issuer.substring(0, issuer.length - 6);
- }
- return issuer;
+const parseIssuer = (url: URL, path: string): 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);
}
- let path = decodeURIComponent(uriPath);
- if (path.startsWith("totp/") || path.startsWith("hotp/")) {
- path = path.substring(5);
- }
- if (path.includes(":")) {
- return path.split(":")[0];
- } else if (path.includes("-")) {
- return path.split("-")[0];
- }
- return path;
- } catch (e) {
- return "";
+ return issuer;
}
+
+ // Otherwise use the `prefix:` from the account as the issuer.
+ // "/ACME:user@example.org" => "ACME"
+ let p = decodeURIComponent(path);
+ 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 = (uriParams): number =>
- parseInt(uriParams["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 = (uriParams): number =>
- parseInt(uriParams["period"] ?? "", 10) || 30;
+const parsePeriod = (url: URL): number =>
+ parseInt(url.searchParams.get("period") ?? "", 10) || 30;
-const parseAlgorithm = (uriParams): Code["algorithm"] => {
- switch (uriParams["algorithm"]?.toLowerCase()) {
+const parseAlgorithm = (url: URL): Code["algorithm"] => {
+ switch (url.searchParams.get("algorithm")?.toLowerCase()) {
case "sha256":
return "sha256";
case "sha512":
@@ -148,8 +164,8 @@ const parseAlgorithm = (uriParams): Code["algorithm"] => {
}
};
-const parseSecret = (uriParams): string =>
- uriParams["secret"].replaceAll(" ", "").toUpperCase();
+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}.
@@ -168,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;
}
@@ -187,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];
};
diff --git a/web/apps/auth/src/services/remote.ts b/web/apps/auth/src/services/remote.ts
index 07b15d7d71..11d57aa23b 100644
--- a/web/apps/auth/src/services/remote.ts
+++ b/web/apps/auth/src/services/remote.ts
@@ -35,7 +35,7 @@ export const getAuthCodes = async (): Promise => {
);
return codeFromURIString(entity.id, decryptedCode);
} catch (e) {
- log.error(`failed to parse codeId = ${entity.id}`);
+ log.error(`Failed to parse codeID ${entity.id}`, e);
return null;
}
}),
diff --git a/web/apps/auth/src/services/steam.ts b/web/apps/auth/src/services/steam.ts
new file mode 100644
index 0000000000..f214640c24
--- /dev/null
+++ b/web/apps/auth/src/services/steam.ts
@@ -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");
+};
diff --git a/web/apps/photos/package.json b/web/apps/photos/package.json
index 1541878c51..0ec924b29b 100644
--- a/web/apps/photos/package.json
+++ b/web/apps/photos/package.json
@@ -43,7 +43,6 @@
"similarity-transformation": "^0.0.1",
"transformation-matrix": "^2.16",
"uuid": "^9.0.1",
- "vscode-uri": "^3.0.7",
"xml-js": "^1.6.11",
"zxcvbn": "^4.4.2"
},
diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md
index 83a2b27990..3ea8fb2409 100644
--- a/web/docs/dependencies.md
+++ b/web/docs/dependencies.md
@@ -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).
diff --git a/web/yarn.lock b/web/yarn.lock
index 894a44dd02..aaa0d517a8 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -4804,11 +4804,6 @@ void-elements@3.1.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
-vscode-uri@^3.0.7:
- version "3.0.8"
- resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f"
- integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==
-
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"