Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
245 changes: 145 additions & 100 deletions README.md

Large diffs are not rendered by default.

99 changes: 99 additions & 0 deletions extensions/authenticator/backslash.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
[extension]
id = "authenticator"
title = "Authenticator"
description = "Two-factor (TOTP) codes for your accounts, with secrets kept in the system keyring"
version = "1.0.0"
author = "Backslash"
icon = "bs-shield"
license = "MIT"

# Secrets never go to the extension's storage in clear:
# - secret-tool stores each one in the system keyring (libsecret: GNOME
# Keyring, KWallet, KeePassXC), under application=backslash-authenticator;
# - without a keyring, they are encrypted (AES-256-GCM, key derived from the
# "Vault passphrase" preference) before being stored.
# Scanning a QR code on screen runs in the background (systemd-run) once the
# launcher is hidden, with the first area capture tool installed (grim + slurp,
# gnome-screenshot, spectacle, maim, scrot or ImageMagick's import), then
# zbarimg; the scanned link goes straight into the keyring. Here those programs
# are only started to check that they are installed. wl-paste and xclip read a
# copied picture of a QR code. Exports of Aegis and 2FAS are imported from
# ~/Downloads or ~/Documents.
[permissions]
run = [
"secret-tool",
"systemd-run",
"zbarimg",
"grim",
"slurp",
"gnome-screenshot",
"spectacle",
"maim",
"scrot",
"import",
"wl-paste",
"xclip",
]
read = ["~/.cache/backslash/authenticator", "~/Downloads", "~/Documents", "/etc/os-release"]
write = ["~/.cache/backslash/authenticator"]
env = [
"HOME",
"DBUS_SESSION_BUS_ADDRESS",
"XDG_SESSION_TYPE",
"XDG_CURRENT_DESKTOP",
"WAYLAND_DISPLAY",
"DISPLAY",
"XAUTHORITY",
"HYPRLAND_INSTANCE_SIGNATURE",
"SWAYSOCK",
]
clipboard-read = true

[[commands]]
name = "codes"
title = "Authenticator"
description = "Your two-factor codes: Enter copies, the code refreshes live"
icon = "bs-shield"
keywords = [
"2fa",
"totp",
"otp",
"mfa",
"authenticator",
"one-time code",
"code",
"verification",
"double authentification",
"code de vérification",
"authentification",
"google authenticator",
"aegis",
]

[[commands]]
name = "add"
title = "Add 2FA Account"
description = "Paste an otpauth:// link or enter the secret key shown by the website"
icon = "bs-plus"
keywords = ["2fa", "totp", "otp", "add", "ajouter", "new account", "nouveau compte", "secret", "otpauth"]

[[commands]]
name = "scan-screen"
title = "Scan 2FA QR Code on Screen"
description = "Select the QR code a website shows: the account is added to Authenticator"
icon = "bs-scan"
mode = "no-view"
keywords = ["2fa", "totp", "qr", "scan", "scanner", "screen", "écran", "add", "ajouter", "authenticator"]

[[preferences]]
name = "passphrase"
title = "Vault passphrase"
description = "Only used when no system keyring is available: secrets are then encrypted with this passphrase in the extension's storage"
type = "secret"

[[preferences]]
name = "showNext"
title = "Show the next code"
description = "Shows the upcoming code in the list, handy near the end of a period"
type = "boolean"
default = true
12 changes: 12 additions & 0 deletions extensions/authenticator/commands/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Context } from "@backslash/api";
import { linkForm, manualForm, type Prefs } from "../ui.ts";

// A copied otpauth:// link opens the link form, filled in; otherwise the secret key form.
export default async (ctx: Context) => {
const prefs = ctx.preferences as Prefs;
const query = ctx.query.trim();
if (/^otpauth(-migration)?:\/\//i.test(query)) return linkForm(prefs, true);
const links = await linkForm(prefs, true);
const prefilled = links.fields[0]?.value;
return prefilled ? links : manualForm(prefs, true);
};
54 changes: 54 additions & 0 deletions extensions/authenticator/commands/codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type Context, Icons, type Item, list, toast, updateCommandMetadata } from "@backslash/api";
import { sortAccounts } from "../lib.ts";
import { importScans, keyring, loadAll, type Loaded, nextTick, VaultError } from "../system.ts";
import { addActions, addedMessage, addItems, codeItem, codesFor, type Prefs, setupItem } from "../ui.ts";

let count = 0;
let locked = 0;

export default (ctx: Context) => {
const prefs = ctx.preferences as Prefs;
return list({
placeholder: "Search accounts…",
empty: { title: "No matching account", description: "Ctrl+K adds one", icon: Icons.shield },
actions: addActions(prefs),
status: () =>
count
? `${count} account${count === 1 ? "" : "s"}${locked ? ` · ${locked} locked` : ""} · Enter copies the code`
: "Add your first account",
// Secrets are read when the list opens and after a change; codes tick every second.
async *items({ signal }) {
let accounts: Loaded[] = [];
let extra: Item[] = [];
let reload = true;
while (!signal.aborted) {
if (reload) {
const ring = await keyring();
extra = [];
try {
const scanned = await importScans(prefs.passphrase);
if (scanned.result.added.length || scanned.result.duplicates || scanned.skipped.length) {
toast(addedMessage(scanned.result, scanned.skipped), { style: "success" }).catch(() => {});
}
} catch (e) {
if (!(e instanceof VaultError)) throw e;
extra.push(await setupItem(ring, e.message));
}
accounts = sortAccounts(await loadAll(prefs.passphrase));
count = accounts.length;
locked = accounts.filter((a) => !a.key).length;
if (ring !== "ok" && !prefs.passphrase && !extra.length) extra.push(await setupItem(ring));
extra.push(...addItems(prefs, ring));
updateCommandMetadata({ subtitle: count ? `${count} account${count === 1 ? "" : "s"}` : null }).catch(
() => {},
);
}
const seconds = Date.now() / 1000;
const rows = await Promise.all(accounts.map(async (a) => codeItem(a, await codesFor(a, seconds), prefs)));
yield [...rows, ...extra];
// Wake up right after the next second, so the countdown stays exact.
reload = (await nextTick(1000 - (Date.now() % 1000) + 20, signal)) === "change";
}
},
});
};
16 changes: 16 additions & 0 deletions extensions/authenticator/commands/scan-screen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { run } from "@backslash/api";
import { capturePackages, installCommand } from "../lib.ts";
import { family, MissingTool, scanScreen, session } from "../system.ts";

// Hides the launcher right away; the background job selects, decodes and keeps
// the link (in the keyring) until Authenticator is opened again.
export default run(async () => {
try {
await scanScreen();
} catch (e) {
if (!(e instanceof MissingTool)) throw e;
const fam = await family();
const packages = e.packages.length ? e.packages : capturePackages(session());
throw new Error(`${e.message}. Install it: ${fam ? installCommand(packages, fam) : packages.join(", ")}`);
}
});
Loading
Loading