diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml new file mode 100644 index 00000000..21033096 --- /dev/null +++ b/.github/workflows/release-extension.yml @@ -0,0 +1,169 @@ +name: Release Extension + +on: + push: + branches: ["main"] + paths: + - "apps/extension/package.json" + - ".github/workflows/release-extension.yml" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-extension + cancel-in-progress: false + +jobs: + prepare: + name: Prepare release + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.version.outputs.changed }} + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Check out code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Detect version change + id: version + shell: bash + run: | + current_version="$(jq -r '.version' apps/extension/package.json)" + + if [[ "${{ github.event_name }}" != "push" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "tag=ext-v$current_version" >> "$GITHUB_OUTPUT" + echo "version=$current_version" >> "$GITHUB_OUTPUT" + exit 0 + fi + + previous_version="$( + git show "${{ github.event.before }}:apps/extension/package.json" 2>/dev/null | + jq -r '.version' || + true + )" + + if [[ "$current_version" == "$previous_version" ]]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "The release workflow changed, but the extension version did not." + exit 0 + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "tag=ext-v$current_version" >> "$GITHUB_OUTPUT" + echo "version=$current_version" >> "$GITHUB_OUTPUT" + + build: + name: Build and verify + needs: prepare + if: needs.prepare.outputs.changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.3 + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Typecheck + run: bun run typecheck + working-directory: apps/extension + + - name: Test + run: bun test + working-directory: apps/extension + + - name: Package + run: bun run package + working-directory: apps/extension + + - name: Check the manifests carry the released version + shell: bash + run: | + for target in chrome firefox; do + manifest_version="$(jq -r '.version' "apps/extension/dist/$target/manifest.json")" + if [[ "$manifest_version" != "${{ needs.prepare.outputs.version }}" ]]; then + echo "$target manifest is $manifest_version, expected ${{ needs.prepare.outputs.version }}" + exit 1 + fi + done + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: extension-packages + path: apps/extension/release/*.zip + if-no-files-found: error + + release: + name: Publish release + needs: [prepare, build] + if: needs.prepare.outputs.changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: extension-packages + path: artifacts + + - name: Build release notes + env: + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + shell: bash + run: | + previous_tag="$( + git tag --list 'ext-v*' --sort=-v:refname | + grep -Fvx "$TAG" | + head -n 1 || + true + )" + + if [[ -n "$previous_tag" ]]; then + changes="$(git log --format='- %s (`%h`)' "$previous_tag..HEAD" -- apps/extension)" + else + changes="$(git log --format='- %s (`%h`)' HEAD -- apps/extension)" + fi + + if [[ -z "$changes" ]]; then + changes="- Release metadata only." + fi + + { + echo "## Authenticator extension v$VERSION" + echo + echo "$changes" + echo + echo "Upload \`*-chrome-*.zip\` to the Chrome Web Store and" + echo "\`*-firefox-*.zip\` to AMO, with \`*-source-*.zip\` as the" + echo "required source submission. Submission notes:" + echo "https://github.com/${{ github.repository }}/blob/$TAG/apps/extension/store/listing.md" + } > extension-release-notes.md + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.prepare.outputs.tag }} + target_commitish: ${{ github.sha }} + name: Authenticator extension v${{ needs.prepare.outputs.version }} + body_path: extension-release-notes.md + make_latest: false + files: artifacts/*.zip diff --git a/CLAUDE.md b/CLAUDE.md index a843da03..9fc4b365 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,10 @@ Turborepo monorepo (bun workspaces, single root `bun.lock`, Biome lint/format at - `apps/envoy-cli/` — Rust `envy` CLI. This monorepo is the canonical source and release owner; `.github/workflows/release-envoy-cli.yml` publishes `envoy-v*` releases when its Cargo package version changes on `main`. +- `apps/extension/` — Vite + React MV3 browser extension (Chrome and Firefox) + holding an offline TOTP vault synced with `/api/admin/authenticator`. Uses + `@repo/ui` for the design system. `.github/workflows/release-extension.yml` + publishes `ext-v*` releases when its package version changes on `main`. - `apps/terminal/` — compiled Bun web-terminal daemon. Runs on the Pi host under systemd, not in Docker. - `packages/cloud-core/` — Pi-side cloud logic: drizzle schema, storage/S3, projects, ops, sync, middleware. - `packages/cloud-ui/`, `packages/cloud-auth-client/` — shared client pieces for the two Vercel cloud apps. @@ -228,6 +232,14 @@ Canonical API contract lives in `packages/schemas` (zod schemas; all TS types ar - `DELETE /resources/{id}/sub-resources/{subId}` → `{ status: "deleted" }` (also deletes health logs) - Checks run from the backend in the health-check cron (`runAllSubResourceChecks` in `lib/resource-agent.ts`); logs share `HealthCheckLog` keyed by sub-resource id; public `/api/public/resource-status` nests `subResources` per parent +### Authenticator +- `GET /authenticator` → `{ accounts: IAuthenticatorAccount[] }` (no secrets) +- `GET /authenticator/codes` → `{ codes: IAuthenticatorCode[] }` — server-computed, used by the admin and desktop UIs +- `GET /authenticator/export` → `{ accounts: IAuthenticatorExportAccount[], exportedAt }` — **the only route that returns decrypted base32 secrets.** It exists so `apps/extension` can hold an offline vault; a leaked API key here costs every secret, not one code. Do not call it from the web or desktop UIs, and do not persist its response anywhere unencrypted. +- `POST /authenticator` → `{ label, issuer, accountName, secret, algorithm?, digits?, period? }` → `{ account }` +- `PATCH /authenticator/{id}` → label/issuer/accountName only; a secret is never updated in place +- `DELETE /authenticator/{id}` → `{ success: true }` + ### Upload - `POST /upload` → FormData with "file" field → `{ url, hash }`. Stores to the self-hosted cloud S3 via `uploadFileToStorage(file, "image")`, where `"image"` is the bucket name, not a type filter — the route enforces no type or size limit. Pinata is gone — the spreadsheets routes read and write the same self-hosted storage, and only their `pinata*` column names survive. diff --git a/README.md b/README.md index dc0fca90..f6fde117 100644 --- a/README.md +++ b/README.md @@ -22,21 +22,40 @@ keeping plaintext secrets and encryption keys on the user's machine. ## Repository map +### Applications + | Path | Description | Audience | | --- | --- | --- | | `apps/web` | Public website, writing, projects, and private administration | Personal | | `apps/desktop` | Native life-dashboard client built with Tauri | Personal | +| `apps/extension` | Chrome and Firefox extension holding an offline authenticator vault | Personal | | `apps/api` | API for my self-hosted cloud | Personal | | `apps/cloud` | Administration interface for cloud services | Personal | | `apps/email-classifier` | Python Logistic Regression Email Classifier API | Personal | +| `apps/markets-relay` | Bun WebSocket relay fanning out Tiingo market quotes | Personal | | `apps/storage` | Browser-based file manager | Personal | | `apps/terminal` | Web-terminal daemon for the cloud host | Personal | | `apps/envoy` | Envoy website and encrypted-storage API | Public | | `apps/envoy-cli` | Rust command-line client for Envoy | Public | | `apps/ssh-server` | Go based ssh-server that powers my business card | Personal | -| `packages/*` | Shared contracts, UI, utilities, and application modules | Shared | | `infra/*` | Deployment definitions for self-hosted services | Personal | +### Packages + +| Path | Description | +| --- | --- | +| `packages/schemas` | Canonical wire contracts as Zod schemas; every application type is inferred from here | +| `packages/ui` | Design system and component library shared by every browser interface | +| `packages/admin` | Shared administration feature screens rendered by both the website and the desktop client | +| `packages/markets` | Portfolio engine, pricing logic, and market data contracts | +| `packages/latex-editor` | Reusable multi-file LaTeX editing workspace with compile log and preview | +| `packages/whiteboard-render` | Server-side rendering of whiteboard documents to SVG | +| `packages/cloud-core` | Self-hosted cloud logic: database schema, S3 storage, projects, operations, sync | +| `packages/cloud-ui` | Shared interface pieces for the cloud and storage applications | +| `packages/cloud-auth-client` | Client-side authentication for the cloud applications | +| `packages/utils` | Shared helpers for money, recurrence, and tree structures | +| `packages/typescript-config` | Shared TypeScript configuration presets | + ## Architecture The TypeScript applications are organized as Bun workspaces and coordinated by @@ -52,8 +71,9 @@ CLI and shares versioned API fixtures across both implementations. - Bun, TypeScript, Turborepo - Next.js, React, Tailwind CSS +- Vite and Manifest V3 for the browser extension - Rust and Tauri -- Python, FastAPI +- Go, Python, FastAPI - Hono, PostgreSQL, MongoDB, Redis - Prisma and Drizzle - Docker, GitHub Actions, Vercel diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore new file mode 100644 index 00000000..3765a80e --- /dev/null +++ b/apps/extension/.gitignore @@ -0,0 +1,5 @@ +dist/ +release/ +# Regenerated by scripts/generate-icons.ts on every build; SOURCE.md tells +# reviewers no binary assets are checked in, so keep it true. +public/icons/ diff --git a/apps/extension/README.md b/apps/extension/README.md new file mode 100644 index 00000000..3e17afde --- /dev/null +++ b/apps/extension/README.md @@ -0,0 +1,48 @@ +# Authenticator extension + +Offline-first TOTP codes for Chrome and Firefox, synced with +`/api/admin/authenticator` on denizlg24.com. + +## How it works + +Secrets are pulled once from `GET /api/admin/authenticator/export`, sealed with +AES-256-GCM under a key derived from a passphrase (PBKDF2-SHA256, 600k +iterations), and kept in `storage.local`. Codes are generated locally, so the +extension keeps working when the server does not — that is the point of holding +a copy rather than polling for codes. + +The unlocked key lives in `storage.session` (memory only, extension contexts +only), which is what lets the background worker sync without a second unlock and +what makes a browser restart lock the vault again. + +Sync runs when the popup opens, after every local change, and on a timer. Local +changes are pushed before the pull, so the merge never has to arbitrate. Accounts +deleted on the server move to a local trash and are purged after the retention +window rather than being dropped on the spot. + +## Commands + +```bash +bun run dev # chrome, watch mode +bun run dev:firefox +bun run build # both targets into dist/ +bun run package # release/*.zip, store-ready +bun run icons # regenerate public/icons from scripts/generate-icons.ts +bun test +bun run typecheck +``` + +`EXT_API_BASE_URL` (or `--api-base-url=`) overrides the compiled-in default and +the manifest's `host_permissions` entry. + +## Loading a development build + +- Chrome: `chrome://extensions` → Developer mode → Load unpacked → `dist/chrome` +- Firefox: `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → + `dist/firefox/manifest.json` + +## Releasing + +Bump `version` in `package.json` and merge to `main`. `release-extension.yml` +detects the change, builds both targets plus the source archive, and publishes a +GitHub release tagged `ext-v`. Uploading to the two stores is manual. diff --git a/apps/extension/SOURCE.md b/apps/extension/SOURCE.md new file mode 100644 index 00000000..b7734b3b --- /dev/null +++ b/apps/extension/SOURCE.md @@ -0,0 +1,56 @@ +# Build instructions (add-on reviewers) + +The submitted package is produced by Vite from the TypeScript sources in this +archive. The archive is the full repository tree at the released commit; only +`apps/extension` and the workspace packages it imports (`packages/ui`, +`packages/schemas`, `packages/typescript-config`) take part in this build. + +## Environment + +| Tool | Version | +| --- | --- | +| Bun | 1.3.3 | +| Operating system | any; the release is built on `ubuntu-latest` | + +Bun is the only prerequisite — it provides the package manager, the runtime for +the build script, and the test runner. Install it from https://bun.sh. + +## Steps + +```bash +bun install --ignore-scripts +cd apps/extension +bun run build:firefox +``` + +`--ignore-scripts` skips an unrelated repository postinstall hook (it downloads a +LaTeX toolchain used by a different app in the monorepo) and has no effect on +this extension. + +The output appears in `apps/extension/dist/firefox` and is byte-for-byte what +the uploaded package contains: + +- `manifest.json` — generated by `scripts/manifest.ts` +- `background.js` — single IIFE bundle of `src/background/index.ts` +- `popup.html`, `options.html`, `assets/*` — Vite build of the two pages +- `icons/*.png` — generated by `scripts/generate-icons.ts`, no binary assets are + checked in + +Replace `build:firefox` with `build:chrome` for the Chrome package; the two +differ only in the generated manifest. + +## Verifying + +```bash +cd apps/extension +bun test # includes the RFC 6238 vectors and the sync merge rules +bun run typecheck +``` + +## Third-party code + +All dependencies are resolved from the public npm registry and pinned by +`bun.lock` at the repository root. The runtime dependencies are: `react`, +`react-dom`, `otpauth` (TOTP), `webextension-polyfill`, `radix-ui`, +`lucide-react`, `clsx`, `tailwind-merge`, `class-variance-authority`, `sonner`. +No code is fetched at runtime; the manifest's CSP is `script-src 'self'`. diff --git a/apps/extension/bunfig.toml b/apps/extension/bunfig.toml new file mode 100644 index 00000000..9e75dd23 --- /dev/null +++ b/apps/extension/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test-setup.ts"] diff --git a/apps/extension/options.html b/apps/extension/options.html new file mode 100644 index 00000000..b55b0ac8 --- /dev/null +++ b/apps/extension/options.html @@ -0,0 +1,12 @@ + + + + + + Authenticator Settings + + +
+ + + diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 00000000..ed44c619 --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,46 @@ +{ + "name": "authenticator-extension", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Offline-first TOTP authenticator that mirrors the denizlg24.com authenticator", + "scripts": { + "dev": "bun scripts/build.ts --target=chrome --watch", + "dev:firefox": "bun scripts/build.ts --target=firefox --watch", + "build": "bun scripts/build.ts --target=chrome && bun scripts/build.ts --target=firefox", + "build:chrome": "bun scripts/build.ts --target=chrome", + "build:firefox": "bun scripts/build.ts --target=firefox", + "icons": "bun scripts/generate-icons.ts", + "package": "bun scripts/package.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@repo/schemas": "workspace:*", + "@repo/ui": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.28.0", + "otpauth": "^9.5.1", + "radix-ui": "^1.6.7", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "webextension-polyfill": "^0.12.0" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@tailwindcss/vite": "^4.3.3", + "@types/bun": "^1.3.14", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@types/webextension-polyfill": "^0.12.5", + "@vitejs/plugin-react": "^6.0.5", + "fflate": "^0.8.3", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", + "typescript": "5.9.3", + "vite": "^8.2.0" + } +} diff --git a/apps/extension/popup.html b/apps/extension/popup.html new file mode 100644 index 00000000..bce8a0cb --- /dev/null +++ b/apps/extension/popup.html @@ -0,0 +1,11 @@ + + + + + Authenticator + + +
+ + + diff --git a/apps/extension/scripts/build.ts b/apps/extension/scripts/build.ts new file mode 100644 index 00000000..15418f9d --- /dev/null +++ b/apps/extension/scripts/build.ts @@ -0,0 +1,132 @@ +/** + * Builds one loadable extension directory per browser. + * + * Three artifacts have to line up: the page bundles (Vite MPA), the background + * bundle (one classic script, see below) and a generated manifest. Running them + * from a single script keeps the manifest honest about what was emitted. + */ + +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build, type InlineConfig } from "vite"; +import { generateIcons } from "./generate-icons.ts"; +import { + buildManifest, + type ExtensionTarget, + toMatchPattern, +} from "./manifest.ts"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +interface BuildOptions { + target: ExtensionTarget; + apiBaseUrl: string; + outDir: string; + watch: boolean; +} + +function parseArgs(argv: string[]): BuildOptions { + const flag = (name: string) => + argv + .find((arg) => arg.startsWith(`--${name}=`)) + ?.slice(name.length + 3) + .trim(); + + const target = flag("target") === "firefox" ? "firefox" : "chrome"; + + return { + target, + apiBaseUrl: + flag("api-base-url") ?? + process.env.EXT_API_BASE_URL ?? + "https://denizlg24.com/api/admin", + outDir: flag("out-dir") ?? `dist/${target}`, + watch: argv.includes("--watch"), + }; +} + +/** + * The background bundle is deliberately a standalone IIFE. Chrome runs it as an + * MV3 service worker and Firefox as an event page; a single classic script is + * the one shape both accept without module-loading differences, and it also + * means the background never depends on the page chunks. + */ +function backgroundConfig( + options: BuildOptions, + version: string, +): InlineConfig { + return { + configFile: false, + root: ROOT, + define: { + __EXT_TARGET__: JSON.stringify(options.target), + __EXT_VERSION__: JSON.stringify(version), + __DEFAULT_API_BASE_URL__: JSON.stringify(options.apiBaseUrl), + }, + resolve: { + alias: { "@": resolve(ROOT, "src") }, + }, + build: { + outDir: options.outDir, + emptyOutDir: false, + minify: true, + sourcemap: options.watch, + target: "es2022", + copyPublicDir: false, + lib: { + entry: resolve(ROOT, "src/background/index.ts"), + formats: ["iife"], + name: "AuthenticatorBackground", + fileName: () => "background.js", + }, + watch: options.watch ? {} : null, + }, + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const pkg = (await import("../package.json", { with: { type: "json" } })) as { + default: { version: string }; + }; + const version = pkg.default.version; + + generateIcons(); + + process.env.EXT_TARGET = options.target; + process.env.EXT_API_BASE_URL = options.apiBaseUrl; + process.env.EXT_OUT_DIR = options.outDir; + process.env.EXT_WATCH = options.watch ? "1" : "0"; + + // Emptied here rather than by Vite: the pages build would otherwise wipe + // background.js and manifest.json on every rebuild in watch mode. + const outDir = resolve(ROOT, options.outDir); + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + + await build({ + configFile: resolve(ROOT, "vite.config.ts"), + build: { emptyOutDir: false, watch: options.watch ? {} : null }, + }); + + await build(backgroundConfig(options, version)); + + const manifest = buildManifest({ + target: options.target, + version, + apiOrigin: toMatchPattern(options.apiBaseUrl), + }); + + writeFileSync( + resolve(outDir, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + + console.log( + `Built ${options.target} extension v${version} → ${options.outDir}`, + ); + if (options.watch) console.log("Watching for changes…"); +} + +await main(); diff --git a/apps/extension/scripts/generate-icons.ts b/apps/extension/scripts/generate-icons.ts new file mode 100644 index 00000000..3c3ec41c --- /dev/null +++ b/apps/extension/scripts/generate-icons.ts @@ -0,0 +1,163 @@ +/** + * Renders the toolbar icon set into public/icons. + * + * The icon is drawn analytically (rounded square + keyhole) and encoded to PNG + * here rather than checked in as binary blobs, so the palette stays tied to the + * design tokens and the sizes can be regenerated with `bun run icons`. + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateSync } from "node:zlib"; + +const OUTPUT_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + "../public/icons", +); +const SIZES = [16, 32, 48, 128]; +/** Samples per axis inside a pixel; the icon has curves and no other AA. */ +const SUBSAMPLES = 4; + +/** --accent from @repo/ui/theme.css: legible against light and dark toolbars. */ +const BACKGROUND: Rgb = [0xa1, 0xbc, 0x98]; +/** --accent-strong. */ +const GLYPH: Rgb = [0x30, 0x36, 0x30]; + +type Rgb = [number, number, number]; + +/** Signed distance to a rounded box centred on (0.5, 0.5), in unit coordinates. */ +function roundedBoxDistance( + x: number, + y: number, + half: number, + radius: number, +) { + const dx = Math.abs(x - 0.5) - (half - radius); + const dy = Math.abs(y - 0.5) - (half - radius); + const outside = Math.hypot(Math.max(dx, 0), Math.max(dy, 0)); + const inside = Math.min(Math.max(dx, dy), 0); + return outside + inside - radius; +} + +function insideKeyhole(x: number, y: number) { + const headRadius = 0.15; + const headCenterY = 0.42; + if (Math.hypot(x - 0.5, y - headCenterY) <= headRadius) return true; + + const stemTop = headCenterY; + const stemBottom = 0.74; + if (y < stemTop || y > stemBottom) return false; + + const progress = (y - stemTop) / (stemBottom - stemTop); + const halfWidth = 0.055 + progress * 0.055; + return Math.abs(x - 0.5) <= halfWidth; +} + +function renderPixels(size: number): Uint8Array { + const pixels = new Uint8Array(size * size * 4); + const step = 1 / (size * SUBSAMPLES); + + for (let py = 0; py < size; py++) { + for (let px = 0; px < size; px++) { + let coverage = 0; + let glyphCoverage = 0; + + for (let sy = 0; sy < SUBSAMPLES; sy++) { + for (let sx = 0; sx < SUBSAMPLES; sx++) { + const x = (px * SUBSAMPLES + sx + 0.5) * step; + const y = (py * SUBSAMPLES + sy + 0.5) * step; + + if (roundedBoxDistance(x, y, 0.5, 0.22) > 0) continue; + coverage++; + if (insideKeyhole(x, y)) glyphCoverage++; + } + } + + const samples = SUBSAMPLES * SUBSAMPLES; + const alpha = coverage / samples; + const glyphMix = coverage === 0 ? 0 : glyphCoverage / coverage; + const offset = (py * size + px) * 4; + + for (let channel = 0; channel < 3; channel++) { + const base = BACKGROUND[channel] as number; + const glyph = GLYPH[channel] as number; + pixels[offset + channel] = Math.round(base + (glyph - base) * glyphMix); + } + pixels[offset + 3] = Math.round(alpha * 255); + } + } + + return pixels; +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer: Buffer): number { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = ((CRC_TABLE[(crc ^ byte) & 0xff] as number) ^ (crc >>> 8)) >>> 0; + } + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([length, body, crc]); +} + +function encodePng(size: number, pixels: Uint8Array): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; // bit depth + header[9] = 6; // colour type: RGBA + header[10] = 0; // deflate + header[11] = 0; // adaptive filtering + header[12] = 0; // no interlace + + const stride = size * 4; + const raw = Buffer.alloc((stride + 1) * size); + for (let row = 0; row < size; row++) { + raw[row * (stride + 1)] = 0; // filter type: none + Buffer.from(pixels.buffer, row * stride, stride).copy( + raw, + row * (stride + 1) + 1, + ); + } + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +export function generateIcons() { + mkdirSync(OUTPUT_DIR, { recursive: true }); + for (const size of SIZES) { + const png = encodePng(size, renderPixels(size)); + writeFileSync(resolve(OUTPUT_DIR, `icon-${size}.png`), png); + } + return SIZES.map((size) => `icons/icon-${size}.png`); +} + +if (import.meta.main) { + const written = generateIcons(); + console.log(`Wrote ${written.length} icons to ${OUTPUT_DIR}`); +} diff --git a/apps/extension/scripts/manifest.ts b/apps/extension/scripts/manifest.ts new file mode 100644 index 00000000..cfb7727b --- /dev/null +++ b/apps/extension/scripts/manifest.ts @@ -0,0 +1,115 @@ +/** + * Manifest generation for both stores. + * + * Chrome and Firefox agree on almost all of MV3, but not on how the background + * runs (service worker vs. event page) or on extension identity, so the two + * manifests are generated from one description rather than kept in sync by hand. + */ + +export type ExtensionTarget = "chrome" | "firefox"; + +/** Stable id Firefox needs to sign and update the add-on. Never change it. */ +export const FIREFOX_EXTENSION_ID = "authenticator@denizlg24.com"; + +export interface ManifestOptions { + target: ExtensionTarget; + version: string; + /** Origin the extension is allowed to talk to without an extra prompt. */ + apiOrigin: string; +} + +interface ManifestJson { + manifest_version: 3; + name: string; + version: string; + description: string; + icons: Record; + permissions: string[]; + host_permissions: string[]; + optional_host_permissions?: string[]; + action: Record; + options_ui: Record; + background: Record; + commands: Record; + content_security_policy: Record; + browser_specific_settings?: Record; + minimum_chrome_version?: string; +} + +/** Turns "https://denizlg24.com/api/admin" into the "https://denizlg24.com/*" match pattern. */ +export function toMatchPattern(apiBaseUrl: string): string { + const url = new URL(apiBaseUrl); + return `${url.protocol}//${url.host}/*`; +} + +export function buildManifest({ + target, + version, + apiOrigin, +}: ManifestOptions): ManifestJson { + const manifest: ManifestJson = { + manifest_version: 3, + name: "denizlg24 Authenticator", + version, + description: + "Offline TOTP codes that stay in sync with the denizlg24.com authenticator.", + icons: { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png", + }, + // storage: the encrypted vault and settings. + // alarms: periodic background sync and the idle auto-lock timer. + // clipboardWrite: copying a code out of the popup. + permissions: ["storage", "alarms", "clipboardWrite"], + // Only the server this extension syncs with. Anything else the owner points + // it at is requested at runtime through optional_host_permissions. + host_permissions: [apiOrigin], + optional_host_permissions: ["https://*/*"], + action: { + default_popup: "popup.html", + default_title: "Authenticator", + default_icon: { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png", + }, + }, + options_ui: { + page: "options.html", + open_in_tab: true, + }, + background: + target === "firefox" + ? { scripts: ["background.js"] } + : { service_worker: "background.js" }, + commands: { + _execute_action: { + suggested_key: { + default: "Alt+Shift+A", + mac: "Alt+Shift+A", + }, + description: "Open the authenticator", + }, + }, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self'", + }, + }; + + if (target === "firefox") { + manifest.browser_specific_settings = { + gecko: { + id: FIREFOX_EXTENSION_ID, + // storage.session, which holds the unlocked vault key, landed in 115. + strict_min_version: "115.0", + }, + }; + } else { + manifest.minimum_chrome_version = "116"; + } + + return manifest; +} diff --git a/apps/extension/scripts/package.ts b/apps/extension/scripts/package.ts new file mode 100644 index 00000000..c0397c88 --- /dev/null +++ b/apps/extension/scripts/package.ts @@ -0,0 +1,91 @@ +/** + * Produces the three archives a release needs: + * - one zip per browser, ready to upload to the Chrome Web Store / AMO + * - a source archive, which AMO requires whenever the submitted code was + * produced by a bundler (see SOURCE.md for the build steps a reviewer runs) + */ + +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { zipSync } from "fflate"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const REPO_ROOT = resolve(ROOT, "../.."); +const RELEASE_DIR = resolve(ROOT, "release"); + +const TARGETS = ["chrome", "firefox"] as const; + +/** 1980-01-01, the earliest timestamp the ZIP format can store. Pinning it keeps + * the same build reproducible byte for byte. */ +const FIXED_MTIME = Date.UTC(1980, 0, 1); + +function collect(dir: string, base = dir): Record { + const files: Record = {}; + + for (const name of readdirSync(dir)) { + const absolute = join(dir, name); + if (statSync(absolute).isDirectory()) { + Object.assign(files, collect(absolute, base)); + continue; + } + // Zip entries always use forward slashes, on every platform. + files[relative(base, absolute).split("\\").join("/")] = new Uint8Array( + readFileSync(absolute), + ); + } + + return files; +} + +function run(command: string, args: string[], cwd: string) { + const result = spawnSync(command, args, { cwd, stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed`); + } +} + +function humanSize(bytes: number) { + return `${(bytes / 1024).toFixed(0)} kB`; +} + +const version = ( + JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")) as { + version: string; + } +).version; + +rmSync(RELEASE_DIR, { recursive: true, force: true }); +mkdirSync(RELEASE_DIR, { recursive: true }); + +for (const target of TARGETS) { + run("bun", ["scripts/build.ts", `--target=${target}`], ROOT); + + const archive = zipSync(collect(resolve(ROOT, "dist", target)), { + level: 9, + mtime: FIXED_MTIME, + }); + const name = `denizlg24-authenticator-${target}-v${version}.zip`; + writeFileSync(resolve(RELEASE_DIR, name), archive); + console.log(`${name} — ${humanSize(archive.length)}`); +} + +// `git archive` gives exactly the tracked tree, so nothing gitignored (and no +// build output) can leak into the source review. +const sourceName = `denizlg24-authenticator-source-v${version}.zip`; +run( + "git", + ["archive", "--format=zip", "-o", join(RELEASE_DIR, sourceName), "HEAD"], + REPO_ROOT, +); +console.log( + `${sourceName} — ${humanSize(statSync(resolve(RELEASE_DIR, sourceName)).size)}`, +); diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts new file mode 100644 index 00000000..db94ea47 --- /dev/null +++ b/apps/extension/src/background/index.ts @@ -0,0 +1,259 @@ +/** + * Background worker: the only writer of the vault, plus the timers. + * + * Chrome runs this as an MV3 service worker and Firefox as an event page, so it + * has to assume it can be torn down between events — nothing is kept in module + * state except the in-flight mutation queue, and the unlocked key is read back + * from `storage.session` on every wake-up. + */ + +import { browser } from "../lib/browser"; +import { + applyEdit, + createEntry, + emptyPayload, + markForDeletion, + purgeFromTrash, + restoreFromTrash, +} from "../lib/entries"; +import type { ExtensionRequest, ExtensionResponse } from "../lib/messages"; +import { + clearSessionKey, + lastActivityAt, + loadSessionKey, + storeSessionKey, +} from "../lib/session"; +import { + clearAll, + readPreferences, + readVaultRecord, + writePreferences, + writeVaultRecord, +} from "../lib/storage"; +import { NotConfiguredError, syncVault } from "../lib/sync"; +import type { SyncResult } from "../lib/types"; +import { + changePassphrase, + createVault, + readVault, + VaultLockedError, + writeVault, +} from "../lib/vault"; + +const TICK_ALARM = "tick"; +const TICK_PERIOD_MINUTES = 1; + +/** Serialises every mutation; concurrent popup edits and timer syncs would + * otherwise read-modify-write the same encrypted blob and lose one of them. */ +let queue: Promise = Promise.resolve(); + +function enqueue(work: () => Promise): Promise { + const next = queue.then(work, work); + queue = next.catch(() => undefined); + return next; +} + +async function requireKey(): Promise { + const key = await loadSessionKey(); + if (!key) throw new VaultLockedError(); + return key; +} + +async function handleSetup( + request: Extract, +): Promise { + await writePreferences({ apiBaseUrl: request.apiBaseUrl }); + const key = await createVault( + request.passphrase, + emptyPayload(request.apiKey), + ); + await storeSessionKey(key); + return syncVault(key); +} + +async function handle(request: ExtensionRequest): Promise { + switch (request.type) { + case "setup": + return handleSetup(request); + + case "sync": { + return syncVault(await requireKey()); + } + + case "addAccounts": { + const key = await requireKey(); + const payload = await readVault(key); + const entries = request.inputs.map(createEntry); + await writeVault(key, { + ...payload, + entries: [...entries, ...payload.entries], + }); + // Best effort: an account added offline still lands, and the next sync + // pushes it up. + await syncVault(key).catch(() => undefined); + return { added: entries.length }; + } + + case "editAccount": { + const key = await requireKey(); + const payload = await readVault(key); + await writeVault(key, { + ...payload, + entries: payload.entries.map((entry) => + entry.id === request.id ? applyEdit(entry, request.edit) : entry, + ), + }); + await syncVault(key).catch(() => undefined); + return null; + } + + case "deleteAccount": { + const key = await requireKey(); + const payload = await readVault(key); + await writeVault(key, markForDeletion(payload, request.id)); + await syncVault(key).catch(() => undefined); + return null; + } + + case "restoreAccount": { + const key = await requireKey(); + const payload = await readVault(key); + await writeVault(key, restoreFromTrash(payload, request.id)); + await syncVault(key).catch(() => undefined); + return null; + } + + case "purgeTrashEntry": { + const key = await requireKey(); + const payload = await readVault(key); + await writeVault(key, purgeFromTrash(payload, request.id)); + return null; + } + + case "emptyTrash": { + const key = await requireKey(); + const payload = await readVault(key); + // Entries still holding an unsent delete stay; dropping them would strand + // the account on the server forever. + await writeVault(key, { + ...payload, + trash: payload.trash.filter((entry) => entry.pendingDelete), + }); + return null; + } + + case "changePassphrase": { + const key = await requireKey(); + const rekeyed = await changePassphrase(key, request.passphrase); + await storeSessionKey(rekeyed); + return null; + } + + case "updateCredentials": { + const key = await requireKey(); + const payload = await readVault(key); + await writePreferences({ apiBaseUrl: request.apiBaseUrl }); + await writeVault(key, { ...payload, apiKey: request.apiKey }); + return null; + } + + case "updatePreferences": + return writePreferences(request.patch); + + case "replaceVault": { + // The imported blob is sealed with its own passphrase, so whatever key is + // in the session no longer opens it. + await writeVaultRecord(request.record); + await clearSessionKey(); + return null; + } + + case "reset": { + await clearSessionKey(); + await clearAll(); + return null; + } + + case "lock": + await clearSessionKey(); + return null; + } +} + +browser.runtime.onMessage.addListener((message: unknown) => { + const request = message as ExtensionRequest; + if (!request || typeof request.type !== "string") return undefined; + + return enqueue(async (): Promise> => { + try { + return { ok: true, data: await handle(request) }; + } catch (error) { + const failure = + error instanceof Error ? error : new Error("Unexpected failure"); + + if (request.type === "sync" || request.type === "setup") { + await writePreferences({ lastSyncError: failure.message }).catch( + () => undefined, + ); + } + + return { ok: false, error: failure.message, name: failure.name }; + } + }); +}); + +async function tick(): Promise { + const key = await loadSessionKey(); + if (!key) return; + + const preferences = await readPreferences(); + + const lastActive = await lastActivityAt(); + if ( + lastActive !== null && + Date.now() - lastActive > preferences.autoLockMinutes * 60_000 + ) { + await clearSessionKey(); + return; + } + + const lastSync = preferences.lastSyncAt + ? new Date(preferences.lastSyncAt).getTime() + : 0; + if (Date.now() - lastSync < preferences.syncIntervalMinutes * 60_000) return; + + await enqueue(() => syncVault(key)).catch(async (error: unknown) => { + // A background sync failing is normal (offline, server down). Record it for + // the options page instead of surfacing anything. + if (error instanceof NotConfiguredError) return; + await writePreferences({ + lastSyncError: error instanceof Error ? error.message : "Sync failed", + }).catch(() => undefined); + }); +} + +browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== TICK_ALARM) return; + void tick(); +}); + +async function ensureAlarm(): Promise { + const existing = await browser.alarms.get(TICK_ALARM); + if (existing) return; + await browser.alarms.create(TICK_ALARM, { + periodInMinutes: TICK_PERIOD_MINUTES, + }); +} + +browser.runtime.onInstalled.addListener(() => { + void ensureAlarm(); +}); + +browser.runtime.onStartup.addListener(() => { + void ensureAlarm(); + // A vault that exists but was never opened this session stays locked; nothing + // to do beyond making sure the timer is armed. + void readVaultRecord(); +}); + +void ensureAlarm(); diff --git a/apps/extension/src/components/account-row.tsx b/apps/extension/src/components/account-row.tsx new file mode 100644 index 00000000..7a1b961b --- /dev/null +++ b/apps/extension/src/components/account-row.tsx @@ -0,0 +1,108 @@ +import { Button } from "@repo/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/ui/dropdown-menu"; +import { + Check, + CloudOff, + Copy, + MoreHorizontal, + Pencil, + Trash2, +} from "lucide-react"; +import { useState } from "react"; +import { copyText } from "../lib/clipboard"; +import { formatCode, type GeneratedCode } from "../lib/totp"; +import type { VaultEntry } from "../lib/types"; +import { CountdownRing } from "./countdown-ring"; + +interface AccountRowProps { + entry: VaultEntry; + code: GeneratedCode | undefined; + onEdit: () => void; + onDelete: () => void; +} + +export function AccountRow({ entry, code, onEdit, onDelete }: AccountRowProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!code) return; + await copyText(code.code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+
+ + {entry.label} + + {(entry.pendingPush || !entry.serverId) && ( + + )} +
+ {entry.accountName && ( +

+ {entry.accountName} +

+ )} +
+ + + + {code && ( + + )} + + + + + + + + + Edit + + + + Delete + + + +
+ ); +} diff --git a/apps/extension/src/components/add-account-dialog.tsx b/apps/extension/src/components/add-account-dialog.tsx new file mode 100644 index 00000000..7bf080c4 --- /dev/null +++ b/apps/extension/src/components/add-account-dialog.tsx @@ -0,0 +1,368 @@ +import { Button } from "@repo/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@repo/ui/dialog"; +import { Input } from "@repo/ui/input"; +import { Label } from "@repo/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/tabs"; +import { Textarea } from "@repo/ui/textarea"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import type { EntryEdit, NewAccountInput } from "../lib/entries"; +import { parseOtpAuthUri, splitUriList } from "../lib/otpauth-uri"; +import type { TotpAlgorithm, VaultEntry } from "../lib/types"; + +interface AddAccountDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (inputs: NewAccountInput[]) => Promise; +} + +const ALGORITHMS: TotpAlgorithm[] = ["SHA1", "SHA256", "SHA512"]; + +export function AddAccountDialog({ + open, + onOpenChange, + onAdd, +}: AddAccountDialogProps) { + const [label, setLabel] = useState(""); + const [issuer, setIssuer] = useState(""); + const [accountName, setAccountName] = useState(""); + const [secret, setSecret] = useState(""); + const [algorithm, setAlgorithm] = useState("SHA1"); + const [digits, setDigits] = useState("6"); + const [period, setPeriod] = useState("30"); + const [uris, setUris] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const reset = () => { + setLabel(""); + setIssuer(""); + setAccountName(""); + setSecret(""); + setAlgorithm("SHA1"); + setDigits("6"); + setPeriod("30"); + setUris(""); + setError(null); + }; + + const submit = async (build: () => NewAccountInput[]) => { + setError(null); + let inputs: NewAccountInput[]; + try { + inputs = build(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Invalid input"); + return; + } + + setSaving(true); + try { + await onAdd(inputs); + reset(); + onOpenChange(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Failed to add"); + } finally { + setSaving(false); + } + }; + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + Add account + + + + + + Manual + + + otpauth:// + + + + +
+ + setLabel(event.target.value)} + className="h-8 text-sm" + /> +
+ +
+
+ + setIssuer(event.target.value)} + className="h-8 text-sm" + /> +
+
+ + setAccountName(event.target.value)} + className="h-8 text-sm" + /> +
+
+ +
+ + setSecret(event.target.value)} + className="h-8 text-sm font-mono" + autoComplete="off" + spellCheck={false} + /> +
+ +
+
+ + +
+
+ + setDigits(event.target.value)} + className="h-8 text-sm tabular-nums" + /> +
+
+ + setPeriod(event.target.value)} + className="h-8 text-sm tabular-nums" + /> +
+
+ + + + +
+ + +