commit 44aca6d4768fe3516cba0b4e09650984fb5a4ef2 Author: Asmir A Date: Sun Jul 26 21:19:04 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b2ff06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/build/ +/result +/result-* +*.o diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..efbcf88 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +`att_lock` — an Android-style 4×4 dot-pattern screen locker for wlroots-based +Wayland compositors. Single C11 binary, ~1500 LOC across four `src/*.c` files. No test suite exists. + +## Build + +Nix flakes are the build system; the host is NixOS and has **no** system Wayland/cairo/pam/xkbcommon +dev libraries outside the flake, so meson/ninja must always run inside `nix develop`. + +```sh +nix build # -> ./result/bin/att_lock +nix develop --command bash -c 'meson setup build && ninja -C build' # -> ./build/att_lock +nix develop --command ninja -C build # incremental rebuild +``` + +Add new dependencies to `nativeDeps`/`libDeps` in `flake.nix` *and* to `meson.build`, never to a +global environment. + +**Gotcha:** this repo is not git-tracked, so a plain `src = ./.` would vacuum the local `build/` dir +into the Nix sandbox and break `nix build` (meson chokes on a stale pre-configured build dir). +`flake.nix` filters `build` and `result*` out via `lib.cleanSourceWith` — keep that filter (or init +a git repo) whenever touching `flake.nix`. + +## Running it + +`att_lock` takes over the screen via `ext-session-lock-v1`. Launching it in the compositor you are +working in will lock that session — the protocol guarantees the session *stays* locked if the +process dies. Test in a nested compositor (e.g. `sway` inside a window) rather than the live session. +`--setup` re-records the pattern; delete `$XDG_DATA_HOME/att_lock/pattern.hash` to reset state. + +## Architecture + +The Wayland protocol bindings are generated at build time by `wayland-scanner` from the vendored +`protocols/ext-session-lock-v1.xml` into `ext-session-lock-v1-client-protocol.h` / +`-protocol.c` — they live only in the build dir, so grepping the source for +`ext_session_lock_v1_*` symbols finds call sites but not definitions. + +One `struct att` (in `src/att_lock.h`) is the whole application state and is passed to essentially +every listener; `struct att_output` and `struct att_seat` hang off it in `wl_list`s, one per +compositor global. + +**Control flow** — `main()` binds globals, calls `ext_session_lock_manager_v1_lock()`, then creates a +lock surface per output. A lock surface only becomes visible once its `configure` handler commits a +buffer, so `lock_surface_configure()` calls `render_output()` directly. Input handlers never render +inline; they set a `dirty` flag via `schedule_redraw()` and the loop renders all outputs once per +iteration. + +The loop is the `prepare_read`/`poll`/`read_events` idiom rather than `wl_display_dispatch()`, +because it needs a **timeout**: an active lockout has to wake the process to repaint its countdown, +and `wl_display_dispatch()` would sleep until the next input event with the displayed seconds +frozen. The timeout is computed to fire exactly when the displayed second changes, so an idle +lockout costs one repaint per second and an idle unlock screen blocks indefinitely. If you touch +this loop, every path must pair `wl_display_prepare_read()` with exactly one of +`wl_display_read_events()` or `wl_display_cancel_read()` — leaking a prepared read deadlocks the +locker, which means a session nobody can unlock. + +**Mode machine** — `enum att_mode` drives both input handling and what `prompt_text()`/`render_output()` +draw: `MODE_UNLOCK` ⇄ `MODE_PASSWORD` (any printable keypress enters password mode, Esc returns), and +`MODE_SETUP_FIRST` → `MODE_SETUP_CONFIRM` on first run or `--setup`. Setup modes are pattern-only. + +**Failed-attempt throttling** — `note_failure()` / `locked_out()` in `main.c`. Both `begin_stroke()` +and `submit_password()` are gated, deliberately sharing one counter so that switching to the password +field cannot sidestep an active pattern lockout; setup modes are not counted (there is no secret to +guess yet), and strokes shorter than `min_dots` are rejected before `auth_verify()` and so are not +failures either. Deadlines use `CLOCK_MONOTONIC` (`att_now_ms()`) precisely because it ignores +wall-clock changes and does not advance across suspend — a lockout must not be sleepable-off. + +**Coordinates** — everything in `struct grid` and `struct att_pattern` is in *logical* (surface-local) +pixels. `render_output()` allocates a device-pixel buffer of `width * scale` and applies +`cairo_scale(scale)` so all drawing code stays logical. The grid is a centred square at 70% of the +shorter output dimension (`att_grid()`), which is what makes rotation and arbitrary aspect ratios +work with no special cases. + +**Pattern model** (`src/pattern.c`) — a sequence of dot indices (`row * 4 + col`). `pattern_add()` +implements the Android rule that unvisited dots lying exactly on the straight line between the +previous and new dot get captured first, using the gcd of the row/col delta. `pattern_to_string()` +produces the canonical `"0-1-2-5"` form, which feeds the hash input, so any change to indexing or +serialisation invalidates every stored pattern. + +**Auth** (`src/auth.c`) — the pattern is verified against a salted `crypt_r` hash (yescrypt `$y$`, +falling back to sha512crypt `$6$`) at `$XDG_DATA_HOME/att_lock/pattern.hash`, mode 0600, dir 0700. +PAM is used *only* for the password fallback (`auth_pam()`, service from `--pam-service`, default +`login`). Comparison uses the constant-time `ct_streq()`, and secrets (password buffer, `crypt_data`, +hash input, first setup entry) are wiped with `explicit_bzero()` — preserve both when editing. + +Three things about the stored hash are deliberate and load-bearing: + +- **The hash input is `att_lock:v3::`, not the bare pattern** (`auth_input()`). The + pattern space is tiny — only 43,680 four-dot sequences — so binding the hash to the account stops + an attacker amortising one cracking run across users or machines. +- **Cost is set explicitly**, `ATT_YESCRYPT_COST 9` / `ATT_SHA512_ROUNDS 600000`, not the `0` + "low default". Cost 9 measures ~0.22 s and ~258 MB here; 11 would be ~0.88 s and ~1 GB, and an + allocation that large in the unlock path can fail on a memory-pressured machine — a user who + cannot unlock is worse than a marginally cheaper hash. Re-measure before changing it. +- **The file is versioned**: line 1 is `ATT_HASH_MAGIC`, line 2 the crypt string. Any change to the + hash input must bump that tag, otherwise stored patterns silently stop verifying and the user is + stuck at "Wrong pattern" forever. `auth_hash_state()` returns `AUTH_HASH_STALE` for an untagged or + corrupt file and `main()` routes that into the setup flow. + +**Rendering** (`src/render.c`) — `wl_shm` + Cairo only, no EGL/GPU. Two buffers per output with a +`busy` flag driven by `wl_buffer.release`; `buffer_get()` reallocates on size change. Background +images are PNG-only (Cairo's built-in loader). + +## Conventions + +- Linux kernel style: tabs, `snake_case`, opening brace on its own line for functions. +- Wayland listener structs are exhaustive — unused events get `(void)`-cast no-op stubs rather than + `NULL` entries. +- Failure while locked must never unlock: `lock_finished()` sets `running = false` without calling + `unlock_and_destroy()`, and `main()` exits non-zero when `app.lock` still exists. +- Built with `warning_level=2` and `-D_GNU_SOURCE`; keep the build warning-free. diff --git a/README.md b/README.md new file mode 100644 index 0000000..882f58c --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# att_lock + +An Android-style **4×4 dot pattern** screen locker for `wlroots`-based Wayland +compositors (Sway, Hyprland, river, labwc, …). It uses the secure +[`ext-session-lock-v1`] protocol, verifies the drawn pattern against a salted +hash stored in an XDG-compliant directory, and falls back to **PAM** password +authentication. + +[`ext-session-lock-v1`]: https://wayland.app/protocols/ext-session-lock-v1 + +## Features + +- **4×4 dot grid**, drawn by dragging with a **pointer or touch** — including + Android's rule that intermediate dots on a straight line get captured. +- **Rotation / any aspect ratio**: the grid is a centred square sized from the + compositor-provided surface dimensions, so portrait, landscape and rotated + outputs all just work. HiDPI (integer `wl_output` scale) is honoured. +- **First-run setup**: with no stored pattern (or `--setup`) you draw the + pattern twice to confirm it. It is stored as a salted **yescrypt** hash + (falling back to sha512crypt) at `$XDG_DATA_HOME/att_lock/pattern.hash` + (mode `0600`), never in plaintext. The hash uses cost factor 9 (~0.22 s per + attempt) and covers your username as well as the pattern, so the same pattern + on two accounts produces unrelated hashes. +- **PAM password fallback**: start typing at the unlock screen to switch to a + password field authenticated through PAM (for "forgot pattern"). +- **Background image** via a command-line argument (PNG), scaled to cover. +- Secure by construction: if the process dies while locked, the compositor + keeps the session locked (a guarantee of `ext-session-lock-v1`). + +## Build + +The build system is **Nix flakes**. + +```sh +# One-off build: +nix build # -> ./result/bin/att_lock + +# Or a dev shell + meson: +nix develop +meson setup build +ninja -C build # -> ./build/att_lock +``` + +Dependencies (provided by the flake): `wayland`, `wayland-scanner`, `cairo`, +`libxkbcommon`, `pam` (linux-pam), `libxcrypt`, plus `meson`/`ninja`/`pkg-config`. + +## Usage + +``` +att_lock [options] [IMAGE] + + -i, --image PATH Background image (PNG); also accepted positionally + -p, --pam-service NAME PAM service for the password fallback (default: login) + -m, --min-dots N Minimum dots in a pattern (default: 4) + --setup Force (re)creation of the stored pattern + -h, --help Show help +``` + +Examples: + +```sh +att_lock ~/wallpaper.png # lock with a background +att_lock --setup # re-record the pattern, then unlock +att_lock -p att_lock -i bg.png # use a dedicated PAM service +``` + +### Unlocking + +- **Draw** your pattern (≥ `--min-dots` dots) and release to unlock. +- **Type** any character to switch to the **password** field; press **Enter** to + authenticate via PAM, **Esc** to go back to the pattern. + +After 4 failed attempts, further attempts are locked out for an escalating +delay — 15 s, then 30 s, 60 s, 120 s, 240 s, capped at 5 minutes — with the +remaining time shown on screen. The counter is shared between the pattern and +the password field, so switching to the password does not sidestep an active +lockout, and it is held in memory only (there is no way to restart the locker +to clear it, since the compositor keeps the session locked regardless). The +countdown uses a monotonic clock, so it cannot be skipped by changing the +system time or by suspending the machine. + +## PAM configuration + +The password fallback authenticates through PAM using the service named by +`--pam-service` (default `login`, which exists on most systems). For a +dedicated service, create one that stacks `pam_unix`: + +**NixOS** (`configuration.nix`): + +```nix +security.pam.services.att_lock = {}; # inherits sane defaults (pam_unix) +``` + +then run `att_lock --pam-service att_lock`. + +**Other distros** (`/etc/pam.d/att_lock`): + +``` +auth include login +account include login +``` + +> The pattern itself is **not** checked by PAM — it is verified against the +> local salted hash. PAM only backs the password fallback. + +## Wiring it into your compositor + +Use it as your lock command, e.g. with `swayidle`: + +```sh +swayidle -w \ + timeout 300 'att_lock ~/wallpaper.png' \ + before-sleep 'att_lock ~/wallpaper.png' +``` + +or bind it in Sway: + +``` +bindsym $mod+Escape exec att_lock ~/wallpaper.png +``` + +## Notes & limitations + +- A pattern is not a password. The lockout above throttles *online* guessing at + the lock screen, but it cannot help offline: even at cost factor 9 there are + only 43,680 possible 4-dot patterns, so anyone who obtains `pattern.hash` can + search that space at their own pace. Raise `--min-dots` if that matters to + you; each extra dot multiplies the space by roughly an order of magnitude. +- Patterns are also far more shoulder-surfable than typed passwords, and on + touchscreens finger smudges can reveal them. +- Hash files written by versions before the `att_lock-hash-v3` format are not + readable; att_lock reports this and asks you to draw a new pattern. The PAM + password fallback still works in the meantime. In particular, v3 is the + format written since this project was renamed from `wlpattern`: the account + name and the project name are both baked into the hash input, so a pattern + enrolled under `wlpattern` (in `$XDG_DATA_HOME/wlpattern/`) cannot be carried + over and must be re-drawn once. +- Background images must be **PNG** (loaded via Cairo). Convert other formats + first (e.g. `magick in.jpg out.png`). +- The pattern is rendered identically on every connected output; the live + drag segment is drawn on the output receiving input. +- Rendering uses `wl_shm` + Cairo (no GPU/EGL dependency). + +## Layout + +``` +flake.nix Nix flake: package + dev shell +meson.build build definition +protocols/ext-session-lock-v1.xml vendored Wayland protocol +src/main.c Wayland client, input, unlock/setup logic +src/pattern.c grid geometry + pattern sequence model +src/render.c wl_shm buffers + Cairo drawing +src/auth.c hashing/storage (crypt) + PAM +src/att_lock.h shared types and interfaces +``` + +## License + +MIT. The vendored protocol XML is MIT (wlroots contributors). diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fdfbda4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0af9052 --- /dev/null +++ b/flake.nix @@ -0,0 +1,54 @@ +{ + description = "att_lock — Android-style pattern screen locker for wlroots compositors (ext-session-lock-v1 + PAM)"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + + nativeDeps = pkgs: with pkgs; [ meson ninja pkg-config wayland-scanner ]; + # libxcrypt provides crypt.h + crypt_gensalt; pam is linux-pam. + libDeps = pkgs: with pkgs; [ wayland cairo libxkbcommon pam libxcrypt ]; + + # Copy only the sources, never local build artifacts (build/, result). + # This repo isn't tracked by git, so `./.` would otherwise vacuum in a + # stale meson build dir and break the sandboxed build. + cleanSrc = pkgs: pkgs.lib.cleanSourceWith { + src = pkgs.lib.cleanSource ./.; + filter = path: type: + let base = baseNameOf path; + in base != "build" && !(pkgs.lib.hasPrefix "result" base); + }; + + mkPackage = pkgs: pkgs.stdenv.mkDerivation { + pname = "att_lock"; + version = "0.1.0"; + src = cleanSrc pkgs; + nativeBuildInputs = nativeDeps pkgs; + buildInputs = libDeps pkgs; + meta = with pkgs.lib; { + description = "Android-style 4x4 pattern screen locker for wlroots compositors"; + license = licenses.mit; + platforms = platforms.linux; + mainProgram = "att_lock"; + }; + }; + in + { + packages = forAll (pkgs: { + default = mkPackage pkgs; + att_lock = mkPackage pkgs; + }); + + devShells = forAll (pkgs: { + default = pkgs.mkShell { + nativeBuildInputs = nativeDeps pkgs; + buildInputs = libDeps pkgs; + }; + }); + + formatter = forAll (pkgs: pkgs.nixpkgs-fmt); + }; +} diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..6b69989 --- /dev/null +++ b/meson.build @@ -0,0 +1,48 @@ +project('att_lock', 'c', + version : '0.1.0', + license : 'MIT', + default_options : ['c_std=c11', 'warning_level=2']) + +add_project_arguments('-D_GNU_SOURCE', language : 'c') + +cc = meson.get_compiler('c') + +wayland_client = dependency('wayland-client') +cairo = dependency('cairo') +xkbcommon = dependency('xkbcommon') + +# libxcrypt ships a pkg-config file (libcrypt); fall back to a bare library lookup. +crypt = dependency('libcrypt', required : false) +if not crypt.found() + crypt = cc.find_library('crypt', has_headers : 'crypt.h') +endif + +pam = cc.find_library('pam', has_headers : 'security/pam_appl.h') + +wayland_scanner = find_program('wayland-scanner') + +protocol_xml = 'protocols/ext-session-lock-v1.xml' + +session_lock_client_header = custom_target('ext-session-lock-client-header', + input : protocol_xml, + output : 'ext-session-lock-v1-client-protocol.h', + command : [wayland_scanner, 'client-header', '@INPUT@', '@OUTPUT@']) + +session_lock_private_code = custom_target('ext-session-lock-private-code', + input : protocol_xml, + output : 'ext-session-lock-v1-protocol.c', + command : [wayland_scanner, 'private-code', '@INPUT@', '@OUTPUT@']) + +sources = files( + 'src/main.c', + 'src/pattern.c', + 'src/render.c', + 'src/auth.c', +) + +executable('att_lock', + sources, + session_lock_client_header, + session_lock_private_code, + dependencies : [wayland_client, cairo, xkbcommon, crypt, pam], + install : true) diff --git a/protocols/ext-session-lock-v1.xml b/protocols/ext-session-lock-v1.xml new file mode 100644 index 0000000..2df0008 --- /dev/null +++ b/protocols/ext-session-lock-v1.xml @@ -0,0 +1,175 @@ + + + + Copyright 2021 The wlroots contributors + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This protocol allows for a privileged Wayland client to lock the session + and display arbitrary graphics while the session is locked. + + The compositor may choose to restrict this protocol to a special client + launched by the compositor itself or expose it to all privileged clients, + this is compositor policy. + + The client is responsible for performing authentication and informing the + compositor when the session should be unlocked. If the client dies while + the session is locked the session remains locked, possibly permanently + depending on compositor policy. + + + + + This interface is used to request that the session be locked. + + + + + This informs the compositor that the lock manager object will no + longer be used. Existing objects created through this interface remain + valid. + + + + + + This request creates a session lock and asks the compositor to lock the + session. The compositor will send either the ext_session_lock_v1.locked + or ext_session_lock_v1.finished event on the created object in response + to this request. + + + + + + + + In response to the creation of this object the compositor must send + either the locked or finished event. + + + + + + + + + + + + + This informs the compositor that the lock object will no longer be + used. It is a protocol error to make this request if the locked event + was sent, the unlock_and_destroy request must be used instead. + + + + + + This client is now responsible for displaying graphics while the + session is locked and deciding when to unlock the session. + + + + + + The compositor has decided that the session lock should be destroyed + as it will no longer be used by the compositor. Exactly when this + event is sent is compositor policy, but it must never be sent more + than once for a given session lock object. + + + + + + The client is expected to create lock surfaces for all outputs + currently present and any new outputs as they are advertised. These + won't be displayed by the compositor unless the lock is successful. + + + + + + + + + This request indicates that the session should be unlocked, for + example because the user has entered their password and it has been + verified by the client. + + This request also informs the compositor that the lock object will no + longer be used and should be destroyed. + + + + + + + The client may use lock surfaces to display a screensaver, render a + dialog to enter a password and unlock the session, or however else it + sees fit. + + + + + + + + + + + + + + + + When a configure event is received, if a client commits the surface + in response to the configure event, then the client must make an + ack_configure request sometime before the commit request, passing + along the serial of the configure event. + + + + + + + This event is sent once on binding the interface and may be sent again + at the compositor's discretion, for example if output geometry changes. + + The width and height are in surface-local coordinates and the client + must create a buffer with exactly these dimensions. + + + + + + + diff --git a/src/att_lock.h b/src/att_lock.h new file mode 100644 index 0000000..9ca0007 --- /dev/null +++ b/src/att_lock.h @@ -0,0 +1,151 @@ +#ifndef ATT_LOCK_H +#define ATT_LOCK_H + +#include +#include +#include +#include +#include + +/* 4x4 dot grid. Dot index = row * GRID_N + col, row/col in [0, GRID_N). */ +#define GRID_N 4 +#define DOT_COUNT (GRID_N * GRID_N) +#define MAX_SEQ DOT_COUNT + +struct att; +struct att_output; + +enum att_mode { + MODE_UNLOCK, /* verify a drawn pattern against the stored hash */ + MODE_SETUP_FIRST, /* first run: draw a new pattern */ + MODE_SETUP_CONFIRM, /* first run: draw it again to confirm */ + MODE_PASSWORD, /* PAM password fallback (keyboard entry) */ +}; + +enum att_feedback { + FB_NONE, + FB_ERROR, + FB_OK, +}; + +/* Layout of the grid within one output, in surface-local (logical) pixels. */ +struct grid { + double ox, oy; /* top-left of the grid square */ + double cell; /* size of one cell */ + double radius; /* drawn dot radius */ + double hit; /* hit-test radius */ +}; + +struct att_pattern { + int seq[MAX_SEQ]; + int len; + bool visited[DOT_COUNT]; + bool drawing; + double cur_x, cur_y; /* live fingertip, in the active output's logical coords */ +}; + +struct att_buffer { + struct wl_buffer *wl_buffer; + void *data; + size_t size; + int width, height; /* in device pixels */ + bool busy; +}; + +struct att_output { + struct wl_list link; + struct att *app; + struct wl_output *wl_output; + uint32_t global_name; + int32_t scale; + struct wl_surface *surface; + struct ext_session_lock_surface_v1 *lock_surface; + int32_t width, height; /* logical size from configure */ + bool configured; + struct att_buffer buffers[2]; +}; + +struct att_seat { + struct wl_list link; + struct att *app; + struct wl_seat *wl_seat; + uint32_t global_name; + struct wl_pointer *pointer; + struct wl_touch *touch; + struct wl_keyboard *keyboard; + double px, py; /* latest pointer position (logical) */ + int32_t touch_id; /* active touch point, -1 if none */ + struct xkb_context *xkb_ctx; + struct xkb_keymap *xkb_keymap; + struct xkb_state *xkb_state; +}; + +struct att { + struct wl_display *display; + struct wl_registry *registry; + struct wl_compositor *compositor; + struct wl_shm *shm; + struct ext_session_lock_manager_v1 *lock_manager; + struct ext_session_lock_v1 *lock; + + struct wl_list outputs; /* struct att_output.link */ + struct wl_list seats; /* struct att_seat.link */ + + bool locked; + bool running; + bool dirty; + + enum att_mode mode; + enum att_feedback feedback; + + struct att_pattern pattern; + struct att_output *active_output; /* output currently receiving input */ + char first_entry[128]; /* canonical string of first setup draw */ + + char password[256]; + int password_len; + + /* Failed-attempt throttling. Kept in memory only: if this process dies + * the compositor keeps the session locked and there is no unlock UI at + * all, so there is no restart an attacker could use to clear these. */ + int fail_count; /* failed unlock attempts, pattern or password */ + int64_t lockout_until; /* CLOCK_MONOTONIC ms; 0 when not locked out */ + + /* configuration */ + const char *image_path; + const char *pam_service; + int min_dots; + + void *bg_surface; /* cairo_surface_t*, or NULL */ + char *hash_path; /* XDG data path for the pattern hash */ +}; + +/* main.c ------------------------------------------------------------------- */ +int64_t att_now_ms(void); +int att_lockout_secs(const struct att *app); /* 0 when input is allowed */ + +/* pattern.c ---------------------------------------------------------------- */ +void att_grid(const struct att_output *o, struct grid *g); +void pattern_reset(struct att_pattern *p); +int pattern_hit(const struct grid *g, double x, double y); +void pattern_add(struct att_pattern *p, int idx); +int pattern_to_string(const struct att_pattern *p, char *buf, size_t n); + +/* render.c ----------------------------------------------------------------- */ +void render_output(struct att_output *o); +void render_all(struct att *app); + +/* auth.c ------------------------------------------------------------------- */ +enum auth_hash_state { + AUTH_HASH_NONE, /* no stored pattern yet */ + AUTH_HASH_STALE, /* present but unreadable / written by an older ver */ + AUTH_HASH_OK, /* usable with auth_verify() */ +}; + +char *auth_hash_path(void); +enum auth_hash_state auth_hash_state(const char *path); +bool auth_store(const char *path, const char *pattern_str); +bool auth_verify(const char *path, const char *pattern_str); +bool auth_pam(const char *service, const char *password); + +#endif /* ATT_LOCK_H */ diff --git a/src/auth.c b/src/auth.c new file mode 100644 index 0000000..39dcb13 --- /dev/null +++ b/src/auth.c @@ -0,0 +1,294 @@ +#include "att_lock.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Processing cost. Per crypt(5), yescrypt accepts 1..11 (logarithmic, and it + * scales memory too) and sha512crypt accepts 1000..999999999 with a far too low + * default of 5000. The pattern space is tiny -- at the default --min-dots there + * are only 16*15*14*13 = 43680 possible sequences -- so once the hash file + * leaks, this cost is the only thing between an attacker and the pattern. + * + * Measured here: yescrypt cost 9 is ~0.22s and ~258MB peak RSS, cost 11 is + * ~0.88s and ~1GB. Cost 9 is the balance point: a ~1GB allocation in the unlock + * path risks failing on a memory-pressured machine, and a hash that cannot be + * computed means a user who cannot unlock. */ +#define ATT_YESCRYPT_COST 9 +#define ATT_SHA512_ROUNDS 600000 + +/* The hash covers "att_lock:v3::", not the bare pattern string, + * so that the same pattern on two accounts (or two machines) yields unrelated + * hashes and an attacker cannot amortise one cracking run across targets. + * + * Any change to that input invalidates every stored hash, so the file carries a + * format tag; auth_hash_state() reports a stale file and main() then forces the + * setup flow rather than silently rejecting every correct pattern. */ +#define ATT_HASH_MAGIC "att_lock-hash-v3" + +/* Build "$XDG_DATA_HOME/att_lock/pattern.hash" (falling back to + * "$HOME/.local/share/..."). Caller frees. */ +char *auth_hash_path(void) +{ + const char *xdg = getenv("XDG_DATA_HOME"); + char base[4096]; + + if (xdg && xdg[0] == '/') { + snprintf(base, sizeof(base), "%s/att_lock", xdg); + } else { + const char *home = getenv("HOME"); + if (!home || home[0] != '/') { + struct passwd *pw = getpwuid(getuid()); + home = pw ? pw->pw_dir : NULL; + } + if (!home) + return NULL; + snprintf(base, sizeof(base), "%s/.local/share/att_lock", home); + } + + size_t len = strlen(base) + strlen("/pattern.hash") + 1; + char *path = malloc(len); + if (!path) + return NULL; + snprintf(path, len, "%s/pattern.hash", base); + return path; +} + +/* Compose the string that actually gets hashed. Fails if the account has no + * name, since without one the hash would not be bound to a user at all. */ +static bool auth_input(const char *pattern_str, char *out, size_t n) +{ + struct passwd *pw = getpwuid(getuid()); + if (!pw || !pw->pw_name || !pw->pw_name[0]) + return false; + + int w = snprintf(out, n, "att_lock:v3:%s:%s", pw->pw_name, pattern_str); + return w > 0 && (size_t)w < n; +} + +/* Read the stored record and hand back its hash line, rejecting anything that + * does not carry the current format tag. */ +static bool read_hash(const char *path, char *out, size_t n) +{ + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return false; + + char buf[1024]; + ssize_t r = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (r <= 0) + return false; + buf[r] = '\0'; + + bool ok = false; + char *nl = strchr(buf, '\n'); + if (nl) { + *nl = '\0'; + char *hash = nl + 1; + hash[strcspn(hash, "\r\n")] = '\0'; + ok = strcmp(buf, ATT_HASH_MAGIC) == 0 && hash[0] != '\0' && + (size_t)snprintf(out, n, "%s", hash) < n; + } + + explicit_bzero(buf, sizeof(buf)); + return ok; +} + +enum auth_hash_state auth_hash_state(const char *path) +{ + struct stat st; + if (!path || stat(path, &st) != 0 || !S_ISREG(st.st_mode)) + return AUTH_HASH_NONE; + + char hash[512]; + bool ok = read_hash(path, hash, sizeof(hash)); + explicit_bzero(hash, sizeof(hash)); + return ok ? AUTH_HASH_OK : AUTH_HASH_STALE; +} + +/* mkdir -p on the directory portion of `path`, each component 0700. */ +static bool ensure_parent_dir(const char *path) +{ + char dir[4096]; + snprintf(dir, sizeof(dir), "%s", path); + char *slash = strrchr(dir, '/'); + if (!slash) + return true; + *slash = '\0'; + + for (char *p = dir + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(dir, 0700) != 0 && errno != EEXIST) + return false; + *p = '/'; + } + } + if (mkdir(dir, 0700) != 0 && errno != EEXIST) + return false; + return true; +} + +/* Generate a yescrypt (falling back to sha512crypt) salt with fresh entropy. */ +static bool make_salt(char *out, size_t outlen) +{ + unsigned char rnd[16]; + if (getrandom(rnd, sizeof(rnd), 0) != (ssize_t)sizeof(rnd)) + return false; + + if (crypt_gensalt_rn("$y$", ATT_YESCRYPT_COST, (const char *)rnd, + sizeof(rnd), out, (int)outlen)) + return true; + if (crypt_gensalt_rn("$6$", ATT_SHA512_ROUNDS, (const char *)rnd, + sizeof(rnd), out, (int)outlen)) + return true; + return false; +} + +bool auth_store(const char *path, const char *pattern_str) +{ + char input[512]; + if (!auth_input(pattern_str, input, sizeof(input))) + return false; + + char salt[CRYPT_GENSALT_OUTPUT_SIZE]; + if (!make_salt(salt, sizeof(salt))) { + explicit_bzero(input, sizeof(input)); + return false; + } + + struct crypt_data data; + memset(&data, 0, sizeof(data)); + char *hash = crypt_r(input, salt, &data); + explicit_bzero(input, sizeof(input)); + + char record[1024]; + int reclen = -1; + if (hash && hash[0] != '*') + reclen = snprintf(record, sizeof(record), "%s\n%s\n", + ATT_HASH_MAGIC, hash); + explicit_bzero(&data, sizeof(data)); + if (reclen <= 0 || (size_t)reclen >= sizeof(record)) + return false; + + if (!ensure_parent_dir(path)) { + explicit_bzero(record, sizeof(record)); + return false; + } + + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (fd < 0) { + explicit_bzero(record, sizeof(record)); + return false; + } + + bool ok = write(fd, record, (size_t)reclen) == (ssize_t)reclen; + if (fsync(fd) != 0) + ok = false; + close(fd); + + explicit_bzero(record, sizeof(record)); + return ok; +} + +static bool ct_streq(const char *a, const char *b) +{ + size_t la = strlen(a), lb = strlen(b); + unsigned char diff = (unsigned char)(la ^ lb); + size_t n = la < lb ? la : lb; + for (size_t i = 0; i < n; i++) + diff |= (unsigned char)(a[i] ^ b[i]); + return diff == 0; +} + +bool auth_verify(const char *path, const char *pattern_str) +{ + char stored[512]; + if (!read_hash(path, stored, sizeof(stored))) + return false; + + char input[512]; + if (!auth_input(pattern_str, input, sizeof(input))) { + explicit_bzero(stored, sizeof(stored)); + return false; + } + + struct crypt_data data; + memset(&data, 0, sizeof(data)); + char *hash = crypt_r(input, stored, &data); + bool ok = hash && hash[0] != '*' && ct_streq(hash, stored); + + explicit_bzero(input, sizeof(input)); + explicit_bzero(&data, sizeof(data)); + explicit_bzero(stored, sizeof(stored)); + return ok; +} + +/* --- PAM password fallback ------------------------------------------------ */ + +struct conv_ctx { + const char *password; +}; + +static int pam_conv_cb(int num_msg, const struct pam_message **msg, + struct pam_response **resp, void *appdata) +{ + if (num_msg <= 0 || num_msg > PAM_MAX_NUM_MSG) + return PAM_CONV_ERR; + + struct conv_ctx *ctx = appdata; + struct pam_response *replies = calloc(num_msg, sizeof(*replies)); + if (!replies) + return PAM_BUF_ERR; + + for (int i = 0; i < num_msg; i++) { + switch (msg[i]->msg_style) { + case PAM_PROMPT_ECHO_OFF: + case PAM_PROMPT_ECHO_ON: + replies[i].resp = strdup(ctx->password ? ctx->password : ""); + if (!replies[i].resp) { + for (int j = 0; j < i; j++) + free(replies[j].resp); + free(replies); + return PAM_BUF_ERR; + } + break; + default: + break; /* PAM_ERROR_MSG / PAM_TEXT_INFO: no reply */ + } + } + + *resp = replies; + return PAM_SUCCESS; +} + +bool auth_pam(const char *service, const char *password) +{ + struct passwd *pw = getpwuid(getuid()); + if (!pw || !pw->pw_name) + return false; + + struct conv_ctx ctx = { .password = password }; + struct pam_conv conv = { .conv = pam_conv_cb, .appdata_ptr = &ctx }; + pam_handle_t *pamh = NULL; + + if (pam_start(service, pw->pw_name, &conv, &pamh) != PAM_SUCCESS) + return false; + + int rc = pam_authenticate(pamh, 0); + if (rc == PAM_SUCCESS) + rc = pam_acct_mgmt(pamh, 0); + + pam_end(pamh, rc); + return rc == PAM_SUCCESS; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..4883c99 --- /dev/null +++ b/src/main.c @@ -0,0 +1,865 @@ +#include "att_lock.h" +#include "ext-session-lock-v1-client-protocol.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void schedule_redraw(struct att *app) +{ + app->dirty = true; +} + +/* --- failed-attempt throttling -------------------------------------------- */ + +/* The pattern is checked against a local hash and never goes through PAM, so it + * inherits none of pam_faildelay/pam_faillock's rate limiting -- without this a + * wrong guess costs only the ~0.22s hash. The password path shares the counter + * so that switching to it cannot dodge an active lockout. */ +#define FAIL_GRACE 4 /* wrong attempts allowed before throttling */ +#define LOCKOUT_BASE 15000 /* ms for the first lockout, doubling after */ +#define LOCKOUT_MAX 300000 /* ms ceiling */ + +/* CLOCK_MONOTONIC deliberately: it is immune to wall-clock changes, and it does + * not advance across suspend, so a lockout cannot be slept off. */ +int64_t att_now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +int att_lockout_secs(const struct att *app) +{ + if (!app->lockout_until) + return 0; + int64_t left = app->lockout_until - att_now_ms(); + return left > 0 ? (int)((left + 999) / 1000) : 0; +} + +static bool locked_out(struct att *app) +{ + if (att_lockout_secs(app) > 0) + return true; + app->lockout_until = 0; + return false; +} + +static void note_failure(struct att *app) +{ + app->fail_count++; + app->feedback = FB_ERROR; + + int steps = app->fail_count - FAIL_GRACE - 1; + if (steps < 0) + return; + if (steps > 20) + steps = 20; /* keep the shift well clear of overflow */ + int64_t ms = (int64_t)LOCKOUT_BASE << steps; + if (ms > LOCKOUT_MAX) + ms = LOCKOUT_MAX; + app->lockout_until = att_now_ms() + ms; +} + +/* --- unlock / setup logic ------------------------------------------------- */ + +static void do_unlock(struct att *app) +{ + if (app->lock) { + ext_session_lock_v1_unlock_and_destroy(app->lock); + app->lock = NULL; + wl_display_flush(app->display); + wl_display_roundtrip(app->display); + } + app->running = false; +} + +static void clear_password(struct att *app) +{ + explicit_bzero(app->password, sizeof(app->password)); + app->password_len = 0; +} + +static void begin_stroke(struct att *app, struct att_output *o, + double x, double y) +{ + if (app->mode == MODE_PASSWORD) + return; + if (locked_out(app)) { + schedule_redraw(app); /* keep the countdown text current */ + return; + } + pattern_reset(&app->pattern); + app->feedback = FB_NONE; + app->active_output = o; + app->pattern.drawing = true; + app->pattern.cur_x = x; + app->pattern.cur_y = y; + + struct grid gr; + att_grid(o, &gr); + pattern_add(&app->pattern, pattern_hit(&gr, x, y)); + schedule_redraw(app); +} + +static void move_stroke(struct att *app, struct att_output *o, + double x, double y) +{ + if (!app->pattern.drawing || o != app->active_output) + return; + app->pattern.cur_x = x; + app->pattern.cur_y = y; + + struct grid gr; + att_grid(o, &gr); + int idx = pattern_hit(&gr, x, y); + if (idx >= 0 && !app->pattern.visited[idx]) + pattern_add(&app->pattern, idx); + schedule_redraw(app); +} + +static void finish_stroke(struct att *app) +{ + struct att_pattern *p = &app->pattern; + if (!p->drawing) + return; + p->drawing = false; + + char str[128]; + if (pattern_to_string(p, str, sizeof(str)) < 0) { + pattern_reset(p); + schedule_redraw(app); + return; + } + + if (p->len < app->min_dots) { + app->feedback = p->len > 0 ? FB_ERROR : FB_NONE; + pattern_reset(p); + schedule_redraw(app); + return; + } + + switch (app->mode) { + case MODE_UNLOCK: + if (auth_verify(app->hash_path, str)) { + do_unlock(app); + } else { + note_failure(app); + pattern_reset(p); + } + break; + case MODE_SETUP_FIRST: + snprintf(app->first_entry, sizeof(app->first_entry), "%s", str); + app->mode = MODE_SETUP_CONFIRM; + app->feedback = FB_OK; + pattern_reset(p); + break; + case MODE_SETUP_CONFIRM: + if (strcmp(str, app->first_entry) == 0 && + auth_store(app->hash_path, str)) { + explicit_bzero(app->first_entry, sizeof(app->first_entry)); + do_unlock(app); + } else { + app->feedback = FB_ERROR; + app->mode = MODE_SETUP_FIRST; + explicit_bzero(app->first_entry, sizeof(app->first_entry)); + pattern_reset(p); + } + break; + case MODE_PASSWORD: + break; + } + + explicit_bzero(str, sizeof(str)); + schedule_redraw(app); +} + +static void submit_password(struct att *app) +{ + if (locked_out(app)) { + schedule_redraw(app); + return; + } + if (auth_pam(app->pam_service, app->password)) { + clear_password(app); + do_unlock(app); + } else { + clear_password(app); + note_failure(app); + } + schedule_redraw(app); +} + +/* --- keyboard ------------------------------------------------------------- */ + +static void handle_key(struct att *app, xkb_keysym_t sym, + const char *utf8, int utf8_len) +{ + switch (sym) { + case XKB_KEY_Escape: + if (app->mode == MODE_PASSWORD) { + clear_password(app); + app->mode = MODE_UNLOCK; + app->feedback = FB_NONE; + } else if (app->mode == MODE_UNLOCK) { + pattern_reset(&app->pattern); + app->feedback = FB_NONE; + } + schedule_redraw(app); + return; + case XKB_KEY_Return: + case XKB_KEY_KP_Enter: + if (app->mode == MODE_PASSWORD) + submit_password(app); + return; + case XKB_KEY_BackSpace: + if (app->mode == MODE_PASSWORD && app->password_len > 0) + app->password[--app->password_len] = '\0'; + schedule_redraw(app); + return; + default: + break; + } + + /* Printable input: switch to the PAM password fallback from unlock mode. + * Setup modes are pattern-only. */ + if (utf8_len > 0 && (unsigned char)utf8[0] >= 0x20) { + if (app->mode == MODE_UNLOCK) { + app->mode = MODE_PASSWORD; + app->feedback = FB_NONE; + pattern_reset(&app->pattern); + } + if (app->mode == MODE_PASSWORD && + app->password_len + utf8_len < (int)sizeof(app->password) - 1) { + memcpy(app->password + app->password_len, utf8, utf8_len); + app->password_len += utf8_len; + app->password[app->password_len] = '\0'; + } + schedule_redraw(app); + } +} + +static void kb_keymap(void *data, struct wl_keyboard *kb, uint32_t format, + int32_t fd, uint32_t size) +{ + (void)kb; + struct att_seat *seat = data; + if (format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1) { + close(fd); + return; + } + char *map = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + if (map == MAP_FAILED) + return; + + struct xkb_keymap *keymap = xkb_keymap_new_from_string( + seat->xkb_ctx, map, XKB_KEYMAP_FORMAT_TEXT_V1, + XKB_KEYMAP_COMPILE_NO_FLAGS); + munmap(map, size); + if (!keymap) + return; + + struct xkb_state *state = xkb_state_new(keymap); + if (!state) { + xkb_keymap_unref(keymap); + return; + } + if (seat->xkb_state) + xkb_state_unref(seat->xkb_state); + if (seat->xkb_keymap) + xkb_keymap_unref(seat->xkb_keymap); + seat->xkb_keymap = keymap; + seat->xkb_state = state; +} + +static void kb_enter(void *d, struct wl_keyboard *k, uint32_t s, + struct wl_surface *surf, struct wl_array *keys) +{ + (void)d; (void)k; (void)s; (void)surf; (void)keys; +} + +static void kb_leave(void *d, struct wl_keyboard *k, uint32_t s, + struct wl_surface *surf) +{ + (void)d; (void)k; (void)s; (void)surf; +} + +static void kb_key(void *data, struct wl_keyboard *k, uint32_t serial, + uint32_t time, uint32_t key, uint32_t state) +{ + (void)k; (void)serial; (void)time; + struct att_seat *seat = data; + if (!seat->xkb_state || state != WL_KEYBOARD_KEY_STATE_PRESSED) + return; + + xkb_keycode_t code = key + 8; + xkb_keysym_t sym = xkb_state_key_get_one_sym(seat->xkb_state, code); + char buf[16]; + int n = xkb_state_key_get_utf8(seat->xkb_state, code, buf, sizeof(buf)); + handle_key(seat->app, sym, buf, n); +} + +static void kb_modifiers(void *data, struct wl_keyboard *k, uint32_t serial, + uint32_t depressed, uint32_t latched, + uint32_t locked, uint32_t group) +{ + (void)k; (void)serial; + struct att_seat *seat = data; + if (seat->xkb_state) + xkb_state_update_mask(seat->xkb_state, depressed, latched, + locked, 0, 0, group); +} + +static void kb_repeat(void *d, struct wl_keyboard *k, int32_t rate, int32_t delay) +{ + (void)d; (void)k; (void)rate; (void)delay; +} + +static const struct wl_keyboard_listener keyboard_listener = { + .keymap = kb_keymap, + .enter = kb_enter, + .leave = kb_leave, + .key = kb_key, + .modifiers = kb_modifiers, + .repeat_info = kb_repeat, +}; + +/* --- pointer -------------------------------------------------------------- */ + +static struct att_output *output_from_surface(struct att *app, + struct wl_surface *surface) +{ + struct att_output *o; + wl_list_for_each(o, &app->outputs, link) + if (o->surface == surface) + return o; + return NULL; +} + +static void ptr_enter(void *data, struct wl_pointer *p, uint32_t serial, + struct wl_surface *surface, wl_fixed_t sx, wl_fixed_t sy) +{ + struct att_seat *seat = data; + /* Hide the cursor over the lock surface. */ + wl_pointer_set_cursor(p, serial, NULL, 0, 0); + seat->app->active_output = output_from_surface(seat->app, surface); + seat->px = wl_fixed_to_double(sx); + seat->py = wl_fixed_to_double(sy); +} + +static void ptr_leave(void *d, struct wl_pointer *p, uint32_t s, + struct wl_surface *surface) +{ + (void)d; (void)p; (void)s; (void)surface; +} + +static void ptr_motion(void *data, struct wl_pointer *p, uint32_t time, + wl_fixed_t sx, wl_fixed_t sy) +{ + (void)p; (void)time; + struct att_seat *seat = data; + seat->px = wl_fixed_to_double(sx); + seat->py = wl_fixed_to_double(sy); + if (seat->app->active_output) + move_stroke(seat->app, seat->app->active_output, + seat->px, seat->py); +} + +static void ptr_button(void *data, struct wl_pointer *p, uint32_t serial, + uint32_t time, uint32_t button, uint32_t state) +{ + (void)p; (void)serial; (void)time; + struct att_seat *seat = data; + struct att *app = seat->app; + if (button != BTN_LEFT || !app->active_output) + return; + + if (state == WL_POINTER_BUTTON_STATE_PRESSED) + begin_stroke(app, app->active_output, seat->px, seat->py); + else + finish_stroke(app); +} + +static void ptr_noop_axis(void *d, struct wl_pointer *p, uint32_t t, uint32_t a, + wl_fixed_t v) +{ (void)d; (void)p; (void)t; (void)a; (void)v; } +static void ptr_noop_frame(void *d, struct wl_pointer *p) { (void)d; (void)p; } +static void ptr_noop_axis_source(void *d, struct wl_pointer *p, uint32_t s) +{ (void)d; (void)p; (void)s; } +static void ptr_noop_axis_stop(void *d, struct wl_pointer *p, uint32_t t, uint32_t a) +{ (void)d; (void)p; (void)t; (void)a; } +static void ptr_noop_axis_disc(void *d, struct wl_pointer *p, uint32_t a, int32_t v) +{ (void)d; (void)p; (void)a; (void)v; } + +static const struct wl_pointer_listener pointer_listener = { + .enter = ptr_enter, + .leave = ptr_leave, + .motion = ptr_motion, + .button = ptr_button, + .axis = ptr_noop_axis, + .frame = ptr_noop_frame, + .axis_source = ptr_noop_axis_source, + .axis_stop = ptr_noop_axis_stop, + .axis_discrete = ptr_noop_axis_disc, +}; + +/* --- touch ---------------------------------------------------------------- */ + +static void touch_down(void *data, struct wl_touch *t, uint32_t serial, + uint32_t time, struct wl_surface *surface, int32_t id, + wl_fixed_t x, wl_fixed_t y) +{ + (void)t; (void)serial; (void)time; + struct att_seat *seat = data; + if (seat->touch_id != -1) + return; /* track a single finger */ + struct att_output *o = output_from_surface(seat->app, surface); + if (!o) + return; + seat->touch_id = id; + seat->app->active_output = o; + begin_stroke(seat->app, o, wl_fixed_to_double(x), wl_fixed_to_double(y)); +} + +static void touch_up(void *data, struct wl_touch *t, uint32_t serial, + uint32_t time, int32_t id) +{ + (void)t; (void)serial; (void)time; + struct att_seat *seat = data; + if (id != seat->touch_id) + return; + seat->touch_id = -1; + finish_stroke(seat->app); +} + +static void touch_motion(void *data, struct wl_touch *t, uint32_t time, + int32_t id, wl_fixed_t x, wl_fixed_t y) +{ + (void)t; (void)time; + struct att_seat *seat = data; + if (id != seat->touch_id || !seat->app->active_output) + return; + move_stroke(seat->app, seat->app->active_output, + wl_fixed_to_double(x), wl_fixed_to_double(y)); +} + +static void touch_frame(void *d, struct wl_touch *t) { (void)d; (void)t; } + +static void touch_cancel(void *data, struct wl_touch *t) +{ + (void)t; + struct att_seat *seat = data; + seat->touch_id = -1; + pattern_reset(&seat->app->pattern); + schedule_redraw(seat->app); +} + +static void touch_shape(void *d, struct wl_touch *t, int32_t id, + wl_fixed_t maj, wl_fixed_t min) +{ (void)d; (void)t; (void)id; (void)maj; (void)min; } +static void touch_orient(void *d, struct wl_touch *t, int32_t id, wl_fixed_t o) +{ (void)d; (void)t; (void)id; (void)o; } + +static const struct wl_touch_listener touch_listener = { + .down = touch_down, + .up = touch_up, + .motion = touch_motion, + .frame = touch_frame, + .cancel = touch_cancel, + .shape = touch_shape, + .orientation = touch_orient, +}; + +/* --- seat ----------------------------------------------------------------- */ + +static void seat_capabilities(void *data, struct wl_seat *wl_seat, uint32_t caps) +{ + struct att_seat *seat = data; + + if ((caps & WL_SEAT_CAPABILITY_POINTER) && !seat->pointer) { + seat->pointer = wl_seat_get_pointer(wl_seat); + wl_pointer_add_listener(seat->pointer, &pointer_listener, seat); + } else if (!(caps & WL_SEAT_CAPABILITY_POINTER) && seat->pointer) { + wl_pointer_release(seat->pointer); + seat->pointer = NULL; + } + + if ((caps & WL_SEAT_CAPABILITY_TOUCH) && !seat->touch) { + seat->touch = wl_seat_get_touch(wl_seat); + wl_touch_add_listener(seat->touch, &touch_listener, seat); + } else if (!(caps & WL_SEAT_CAPABILITY_TOUCH) && seat->touch) { + wl_touch_release(seat->touch); + seat->touch = NULL; + } + + if ((caps & WL_SEAT_CAPABILITY_KEYBOARD) && !seat->keyboard) { + seat->keyboard = wl_seat_get_keyboard(wl_seat); + wl_keyboard_add_listener(seat->keyboard, &keyboard_listener, seat); + } else if (!(caps & WL_SEAT_CAPABILITY_KEYBOARD) && seat->keyboard) { + wl_keyboard_release(seat->keyboard); + seat->keyboard = NULL; + } +} + +static void seat_name(void *d, struct wl_seat *s, const char *name) +{ (void)d; (void)s; (void)name; } + +static const struct wl_seat_listener seat_listener = { + .capabilities = seat_capabilities, + .name = seat_name, +}; + +/* --- output --------------------------------------------------------------- */ + +static void out_geometry(void *d, struct wl_output *o, int32_t x, int32_t y, + int32_t pw, int32_t ph, int32_t subpixel, + const char *make, const char *model, int32_t transform) +{ (void)d; (void)o; (void)x; (void)y; (void)pw; (void)ph; (void)subpixel; + (void)make; (void)model; (void)transform; } + +static void out_mode(void *d, struct wl_output *o, uint32_t flags, + int32_t w, int32_t h, int32_t refresh) +{ (void)d; (void)o; (void)flags; (void)w; (void)h; (void)refresh; } + +static void out_scale(void *data, struct wl_output *o, int32_t factor) +{ + (void)o; + struct att_output *out = data; + out->scale = factor > 0 ? factor : 1; +} + +static void out_done(void *d, struct wl_output *o) { (void)d; (void)o; } +static void out_name(void *d, struct wl_output *o, const char *name) +{ (void)d; (void)o; (void)name; } +static void out_description(void *d, struct wl_output *o, const char *desc) +{ (void)d; (void)o; (void)desc; } + +static const struct wl_output_listener output_listener = { + .geometry = out_geometry, + .mode = out_mode, + .done = out_done, + .scale = out_scale, + .name = out_name, + .description = out_description, +}; + +/* --- lock surface --------------------------------------------------------- */ + +static void lock_surface_configure(void *data, + struct ext_session_lock_surface_v1 *surf, + uint32_t serial, uint32_t width, uint32_t height) +{ + struct att_output *o = data; + ext_session_lock_surface_v1_ack_configure(surf, serial); + o->width = (int32_t)width; + o->height = (int32_t)height; + o->configured = true; + render_output(o); /* must commit a buffer for the compositor to lock */ +} + +static const struct ext_session_lock_surface_v1_listener lock_surface_listener = { + .configure = lock_surface_configure, +}; + +static void create_lock_surface(struct att *app, struct att_output *o) +{ + if (!app->lock || o->lock_surface) + return; + o->surface = wl_compositor_create_surface(app->compositor); + o->lock_surface = ext_session_lock_v1_get_lock_surface( + app->lock, o->surface, o->wl_output); + ext_session_lock_surface_v1_add_listener(o->lock_surface, + &lock_surface_listener, o); +} + +static void output_destroy(struct att_output *o) +{ + wl_list_remove(&o->link); + for (int i = 0; i < 2; i++) { + if (o->buffers[i].wl_buffer) + wl_buffer_destroy(o->buffers[i].wl_buffer); + if (o->buffers[i].data && o->buffers[i].data != MAP_FAILED) + munmap(o->buffers[i].data, o->buffers[i].size); + } + if (o->lock_surface) + ext_session_lock_surface_v1_destroy(o->lock_surface); + if (o->surface) + wl_surface_destroy(o->surface); + if (o->wl_output) + wl_output_destroy(o->wl_output); + if (o->app->active_output == o) + o->app->active_output = NULL; + free(o); +} + +/* --- lock object ---------------------------------------------------------- */ + +static void lock_locked(void *data, struct ext_session_lock_v1 *lock) +{ + (void)lock; + struct att *app = data; + app->locked = true; +} + +static void lock_finished(void *data, struct ext_session_lock_v1 *lock) +{ + (void)lock; + struct att *app = data; + /* The compositor refused or revoked the lock. Do NOT unlock; just exit + * with failure so the session stays in whatever state the compositor + * decided. */ + fprintf(stderr, "att_lock: session lock finished by compositor\n"); + app->running = false; + app->locked = false; +} + +static const struct ext_session_lock_v1_listener lock_listener = { + .locked = lock_locked, + .finished = lock_finished, +}; + +/* --- registry ------------------------------------------------------------- */ + +static void registry_global(void *data, struct wl_registry *reg, uint32_t name, + const char *iface, uint32_t version) +{ + struct att *app = data; + + if (strcmp(iface, wl_compositor_interface.name) == 0) { + app->compositor = wl_registry_bind(reg, name, + &wl_compositor_interface, version < 4 ? version : 4); + } else if (strcmp(iface, wl_shm_interface.name) == 0) { + app->shm = wl_registry_bind(reg, name, &wl_shm_interface, 1); + } else if (strcmp(iface, ext_session_lock_manager_v1_interface.name) == 0) { + app->lock_manager = wl_registry_bind(reg, name, + &ext_session_lock_manager_v1_interface, 1); + } else if (strcmp(iface, wl_seat_interface.name) == 0) { + struct att_seat *seat = calloc(1, sizeof(*seat)); + if (!seat) + return; + seat->app = app; + seat->global_name = name; + seat->touch_id = -1; + seat->xkb_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS); + seat->wl_seat = wl_registry_bind(reg, name, + &wl_seat_interface, version < 5 ? version : 5); + wl_seat_add_listener(seat->wl_seat, &seat_listener, seat); + wl_list_insert(&app->seats, &seat->link); + } else if (strcmp(iface, wl_output_interface.name) == 0) { + struct att_output *o = calloc(1, sizeof(*o)); + if (!o) + return; + o->app = app; + o->global_name = name; + o->scale = 1; + o->wl_output = wl_registry_bind(reg, name, + &wl_output_interface, version < 4 ? version : 4); + wl_output_add_listener(o->wl_output, &output_listener, o); + wl_list_insert(&app->outputs, &o->link); + create_lock_surface(app, o); /* no-op until the lock exists */ + } +} + +static void registry_global_remove(void *data, struct wl_registry *reg, + uint32_t name) +{ + (void)reg; + struct att *app = data; + struct att_output *o, *otmp; + wl_list_for_each_safe(o, otmp, &app->outputs, link) { + if (o->global_name == name) { + output_destroy(o); + return; + } + } +} + +static const struct wl_registry_listener registry_listener = { + .global = registry_global, + .global_remove = registry_global_remove, +}; + +/* --- setup / teardown ----------------------------------------------------- */ + +static void usage(const char *argv0) +{ + fprintf(stderr, +"Usage: %s [options] [IMAGE]\n" +"\n" +"Android-style 4x4 pattern screen locker for wlroots compositors.\n" +"\n" +"Options:\n" +" -i, --image PATH Background image (PNG), also accepted positionally\n" +" -p, --pam-service NAME PAM service for the password fallback (default: login)\n" +" -m, --min-dots N Minimum dots in a pattern (default: 4)\n" +" --setup Force (re)creation of the stored pattern\n" +" -h, --help Show this help\n" +"\n" +"On first run (or with --setup) you draw the pattern twice to store it as a\n" +"salted hash under $XDG_DATA_HOME/att_lock/pattern.hash. Start typing at the\n" +"unlock screen to fall back to PAM password authentication.\n", + argv0); +} + +int main(int argc, char **argv) +{ + struct att app = {0}; + app.running = true; + app.pam_service = "login"; + app.min_dots = 4; + wl_list_init(&app.outputs); + wl_list_init(&app.seats); + + bool force_setup = false; + static const struct option opts[] = { + {"image", required_argument, 0, 'i'}, + {"pam-service", required_argument, 0, 'p'}, + {"min-dots", required_argument, 0, 'm'}, + {"setup", no_argument, 0, 's'}, + {"help", no_argument, 0, 'h'}, + {0, 0, 0, 0}, + }; + int c; + while ((c = getopt_long(argc, argv, "i:p:m:h", opts, NULL)) != -1) { + switch (c) { + case 'i': app.image_path = optarg; break; + case 'p': app.pam_service = optarg; break; + case 'm': app.min_dots = atoi(optarg); break; + case 's': force_setup = true; break; + case 'h': usage(argv[0]); return 0; + default: usage(argv[0]); return 2; + } + } + if (optind < argc && !app.image_path) + app.image_path = argv[optind]; + if (app.min_dots < 2) + app.min_dots = 2; + if (app.min_dots > DOT_COUNT) + app.min_dots = DOT_COUNT; + + app.hash_path = auth_hash_path(); + if (!app.hash_path) { + fprintf(stderr, "att_lock: cannot determine data directory\n"); + return 1; + } + enum auth_hash_state hstate = auth_hash_state(app.hash_path); + if (hstate == AUTH_HASH_STALE) + fprintf(stderr, "att_lock: stored pattern is unreadable or was " + "written by an older version; a new one must be drawn\n"); + app.mode = (force_setup || hstate != AUTH_HASH_OK) + ? MODE_SETUP_FIRST : MODE_UNLOCK; + + if (app.image_path) { + cairo_surface_t *bg = + cairo_image_surface_create_from_png(app.image_path); + if (cairo_surface_status(bg) == CAIRO_STATUS_SUCCESS) { + app.bg_surface = bg; + } else { + fprintf(stderr, "att_lock: could not load image '%s' " + "(PNG only): %s\n", app.image_path, + cairo_status_to_string(cairo_surface_status(bg))); + cairo_surface_destroy(bg); + } + } + + app.display = wl_display_connect(NULL); + if (!app.display) { + fprintf(stderr, "att_lock: failed to connect to Wayland display\n"); + return 1; + } + app.registry = wl_display_get_registry(app.display); + wl_registry_add_listener(app.registry, ®istry_listener, &app); + wl_display_roundtrip(app.display); /* bind globals */ + wl_display_roundtrip(app.display); /* settle seat caps / output scale */ + + if (!app.compositor || !app.shm || !app.lock_manager) { + fprintf(stderr, "att_lock: compositor lacks required globals " + "(ext-session-lock-v1 support needed)\n"); + return 1; + } + + app.lock = ext_session_lock_manager_v1_lock(app.lock_manager); + ext_session_lock_v1_add_listener(app.lock, &lock_listener, &app); + + struct att_output *o; + wl_list_for_each(o, &app.outputs, link) + create_lock_surface(&app, o); + + /* Poll rather than wl_display_dispatch() so a running lockout can wake us + * to repaint its countdown; without a timeout we would sleep until the + * next input event and the displayed seconds would stall. */ + struct pollfd pfd = { .fd = wl_display_get_fd(app.display), .events = POLLIN }; + + while (app.running) { + int timeout = -1; + if (app.lockout_until) { + int64_t left = app.lockout_until - att_now_ms(); + if (left > 0) { + /* Wake exactly when the displayed second changes. */ + timeout = (int)((left - 1) % 1000) + 1; + } else { + app.lockout_until = 0; + schedule_redraw(&app); + } + } + + if (app.dirty) { + app.dirty = false; + render_all(&app); + } + + while (wl_display_prepare_read(app.display) != 0) + wl_display_dispatch_pending(app.display); + if (wl_display_flush(app.display) < 0 && errno != EAGAIN) { + wl_display_cancel_read(app.display); + break; + } + + int ret = poll(&pfd, 1, timeout); + if (ret < 0) { + wl_display_cancel_read(app.display); + if (errno == EINTR) + continue; + break; + } + if (ret > 0 && (pfd.revents & (POLLERR | POLLHUP))) { + wl_display_cancel_read(app.display); + break; + } + if (ret > 0 && (pfd.revents & POLLIN)) { + if (wl_display_read_events(app.display) < 0) + break; + } else { + wl_display_cancel_read(app.display); + schedule_redraw(&app); /* timed out: countdown tick */ + } + if (wl_display_dispatch_pending(app.display) < 0) + break; + } + + /* cleanup */ + if (app.bg_surface) + cairo_surface_destroy(app.bg_surface); + clear_password(&app); + free(app.hash_path); + + /* If the lock still exists we exited without unlocking (e.g. the + * compositor sent `finished`): report failure. */ + int rc = app.lock ? 1 : 0; + if (app.lock) + ext_session_lock_v1_destroy(app.lock); + wl_display_flush(app.display); + wl_display_disconnect(app.display); + return rc; +} diff --git a/src/pattern.c b/src/pattern.c new file mode 100644 index 0000000..28c89a3 --- /dev/null +++ b/src/pattern.c @@ -0,0 +1,102 @@ +#include "att_lock.h" + +#include +#include +#include + +static int igcd(int a, int b) +{ + a = a < 0 ? -a : a; + b = b < 0 ? -b : b; + while (b) { + int t = a % b; + a = b; + b = t; + } + return a; +} + +/* Compute the grid layout for an output. The grid is a centred square whose + * side is 70% of the shorter output dimension, so it adapts to rotation and + * aspect ratio automatically. */ +void att_grid(const struct att_output *o, struct grid *g) +{ + double w = o->width, h = o->height; + double side = (w < h ? w : h) * 0.7; + g->cell = side / GRID_N; + g->ox = (w - side) / 2.0; + g->oy = (h - side) / 2.0; + g->radius = g->cell * 0.10; + g->hit = g->cell * 0.33; +} + +void pattern_reset(struct att_pattern *p) +{ + p->len = 0; + p->drawing = false; + memset(p->visited, 0, sizeof(p->visited)); +} + +/* Return the dot index whose centre is within the hit radius of (x, y), or -1. */ +int pattern_hit(const struct grid *g, double x, double y) +{ + for (int i = 0; i < DOT_COUNT; i++) { + int row = i / GRID_N; + int col = i % GRID_N; + double cx = g->ox + g->cell * (col + 0.5); + double cy = g->oy + g->cell * (row + 0.5); + double dx = x - cx; + double dy = y - cy; + if (dx * dx + dy * dy <= g->hit * g->hit) + return i; + } + return -1; +} + +/* Append a dot to the sequence. As on Android, any not-yet-visited dots lying + * exactly on the straight line between the previous dot and the new one are + * selected first (e.g. connecting 0 -> 2 also captures 1). */ +void pattern_add(struct att_pattern *p, int idx) +{ + if (idx < 0 || idx >= DOT_COUNT || p->visited[idx]) + return; + + if (p->len > 0) { + int last = p->seq[p->len - 1]; + int r0 = last / GRID_N, c0 = last % GRID_N; + int r1 = idx / GRID_N, c1 = idx % GRID_N; + int dr = r1 - r0, dc = c1 - c0; + int g = igcd(dr, dc); + if (g > 1) { + int sr = dr / g, sc = dc / g; + for (int k = 1; k < g; k++) { + int mid = (r0 + sr * k) * GRID_N + (c0 + sc * k); + if (!p->visited[mid] && p->len < MAX_SEQ) { + p->visited[mid] = true; + p->seq[p->len++] = mid; + } + } + } + } + + if (!p->visited[idx] && p->len < MAX_SEQ) { + p->visited[idx] = true; + p->seq[p->len++] = idx; + } +} + +/* Serialise the sequence to a stable canonical string, e.g. "0-1-2-5". */ +int pattern_to_string(const struct att_pattern *p, char *buf, size_t n) +{ + size_t off = 0; + for (int i = 0; i < p->len; i++) { + int w = snprintf(buf + off, n - off, "%s%d", + i ? "-" : "", p->seq[i]); + if (w < 0 || (size_t)w >= n - off) + return -1; + off += (size_t)w; + } + if (off == 0 && n > 0) + buf[0] = '\0'; + return (int)off; +} diff --git a/src/render.c b/src/render.c new file mode 100644 index 0000000..bcdce31 --- /dev/null +++ b/src/render.c @@ -0,0 +1,286 @@ +#include "att_lock.h" + +#include +#include +#include +#include +#include +#include +#include + +/* --- shm buffer pool ------------------------------------------------------ */ + +static void buffer_release(void *data, struct wl_buffer *wl_buffer) +{ + (void)wl_buffer; + struct att_buffer *b = data; + b->busy = false; +} + +static const struct wl_buffer_listener buffer_listener = { + .release = buffer_release, +}; + +static void buffer_destroy(struct att_buffer *b) +{ + if (b->wl_buffer) + wl_buffer_destroy(b->wl_buffer); + if (b->data && b->data != MAP_FAILED) + munmap(b->data, b->size); + memset(b, 0, sizeof(*b)); +} + +static bool buffer_create(struct att *app, struct att_buffer *b, int w, int h) +{ + int stride = w * 4; + size_t size = (size_t)stride * h; + + int fd = memfd_create("att_lock-shm", MFD_CLOEXEC | MFD_ALLOW_SEALING); + if (fd < 0) + return false; + if (ftruncate(fd, size) < 0) { + close(fd); + return false; + } + + void *data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (data == MAP_FAILED) { + close(fd); + return false; + } + + struct wl_shm_pool *pool = wl_shm_create_pool(app->shm, fd, size); + struct wl_buffer *wl_buffer = wl_shm_pool_create_buffer( + pool, 0, w, h, stride, WL_SHM_FORMAT_ARGB8888); + wl_shm_pool_destroy(pool); + close(fd); + + b->wl_buffer = wl_buffer; + b->data = data; + b->size = size; + b->width = w; + b->height = h; + b->busy = false; + wl_buffer_add_listener(wl_buffer, &buffer_listener, b); + return true; +} + +/* Return a non-busy buffer sized w*h, (re)creating as needed, or NULL. */ +static struct att_buffer *buffer_get(struct att *app, struct att_output *o, + int w, int h) +{ + for (int i = 0; i < 2; i++) { + struct att_buffer *b = &o->buffers[i]; + if (b->busy) + continue; + if (b->wl_buffer && (b->width != w || b->height != h)) + buffer_destroy(b); + if (!b->wl_buffer && !buffer_create(app, b, w, h)) + return NULL; + return b; + } + return NULL; +} + +/* --- drawing -------------------------------------------------------------- */ + +static void feedback_color(enum att_feedback fb, double *r, double *g, double *b) +{ + switch (fb) { + case FB_ERROR: *r = 0.90; *g = 0.25; *b = 0.25; break; + case FB_OK: *r = 0.30; *g = 0.80; *b = 0.45; break; + default: *r = 0.30; *g = 0.62; *b = 0.98; break; + } +} + +static void dot_center(const struct grid *gr, int idx, double *cx, double *cy) +{ + int row = idx / GRID_N, col = idx % GRID_N; + *cx = gr->ox + gr->cell * (col + 0.5); + *cy = gr->oy + gr->cell * (row + 0.5); +} + +static void draw_background(cairo_t *cr, struct att *app, int w, int h) +{ + cairo_surface_t *bg = app->bg_surface; + if (bg) { + int iw = cairo_image_surface_get_width(bg); + int ih = cairo_image_surface_get_height(bg); + if (iw > 0 && ih > 0) { + double s = fmax((double)w / iw, (double)h / ih); + cairo_save(cr); + cairo_translate(cr, (w - iw * s) / 2.0, (h - ih * s) / 2.0); + cairo_scale(cr, s, s); + cairo_set_source_surface(cr, bg, 0, 0); + cairo_paint(cr); + cairo_restore(cr); + } + } else { + cairo_set_source_rgb(cr, 0.05, 0.05, 0.08); + cairo_paint(cr); + } + /* Dim overlay so the grid stays legible over any wallpaper. */ + cairo_set_source_rgba(cr, 0.0, 0.0, 0.0, 0.45); + cairo_paint(cr); +} + +static void draw_text(cairo_t *cr, int w, int h, const char *text, double cy_frac) +{ + if (!text || !text[0]) + return; + cairo_select_font_face(cr, "sans-serif", + CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); + double size = (w < h ? w : h) * 0.045; + cairo_set_font_size(cr, size); + cairo_text_extents_t ext; + cairo_text_extents(cr, text, &ext); + cairo_move_to(cr, (w - ext.width) / 2.0 - ext.x_bearing, h * cy_frac); + cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 0.92); + cairo_show_text(cr, text); +} + +static void prompt_text(struct att *app, char *buf, size_t n) +{ + int secs = att_lockout_secs(app); + if (secs > 0) { + if (secs >= 60) + snprintf(buf, n, "Too many attempts — try again in %d:%02d", + secs / 60, secs % 60); + else + snprintf(buf, n, "Too many attempts — try again in %ds", secs); + return; + } + + const char *s; + switch (app->mode) { + case MODE_SETUP_FIRST: + s = app->feedback == FB_ERROR ? "Patterns didn't match — draw a new pattern" + : "Draw a new unlock pattern"; + break; + case MODE_SETUP_CONFIRM: + s = "Draw the pattern again to confirm"; + break; + case MODE_PASSWORD: + s = app->feedback == FB_ERROR ? "Authentication failed — type password, Enter to submit" + : "Password (Enter to submit, Esc to cancel)"; + break; + case MODE_UNLOCK: + default: + s = app->feedback == FB_ERROR ? "Wrong pattern — try again" + : "Draw pattern to unlock"; + break; + } + snprintf(buf, n, "%s", s); +} + +static void draw_password(cairo_t *cr, struct att *app, int w, int h) +{ + int shown = app->password_len; + if (shown > 16) + shown = 16; + double r = (w < h ? w : h) * 0.012; + double gap = r * 3.0; + double total = shown > 0 ? (shown - 1) * gap : 0; + double x = w / 2.0 - total / 2.0; + double y = h / 2.0; + cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 0.9); + for (int i = 0; i < shown; i++) { + cairo_arc(cr, x + i * gap, y, r, 0, 2 * M_PI); + cairo_fill(cr); + } +} + +static void draw_pattern(cairo_t *cr, struct att *app, struct att_output *o, + const struct grid *gr) +{ + struct att_pattern *p = &app->pattern; + double r, g, b; + feedback_color(app->feedback, &r, &g, &b); + + /* connecting lines */ + if (p->len > 0) { + cairo_set_line_width(cr, gr->cell * 0.06); + cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND); + cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND); + cairo_set_source_rgba(cr, r, g, b, 0.85); + + double cx, cy; + dot_center(gr, p->seq[0], &cx, &cy); + cairo_move_to(cr, cx, cy); + for (int i = 1; i < p->len; i++) { + dot_center(gr, p->seq[i], &cx, &cy); + cairo_line_to(cr, cx, cy); + } + /* live segment to the fingertip, only on the active output */ + if (p->drawing && o == app->active_output) + cairo_line_to(cr, p->cur_x, p->cur_y); + cairo_stroke(cr); + } + + /* dots */ + for (int i = 0; i < DOT_COUNT; i++) { + double cx, cy; + dot_center(gr, i, &cx, &cy); + + cairo_set_source_rgba(cr, 1.0, 1.0, 1.0, 0.30); + cairo_arc(cr, cx, cy, gr->radius, 0, 2 * M_PI); + cairo_fill(cr); + + if (p->visited[i]) { + cairo_set_source_rgba(cr, r, g, b, 1.0); + cairo_arc(cr, cx, cy, gr->radius * 1.9, 0, 2 * M_PI); + cairo_fill(cr); + } + } +} + +void render_output(struct att_output *o) +{ + struct att *app = o->app; + if (!o->configured || o->width <= 0 || o->height <= 0) + return; + + int scale = o->scale > 0 ? o->scale : 1; + int pw = o->width * scale; + int ph = o->height * scale; + + struct att_buffer *b = buffer_get(app, o, pw, ph); + if (!b) + return; + + cairo_surface_t *surface = cairo_image_surface_create_for_data( + b->data, CAIRO_FORMAT_ARGB32, pw, ph, pw * 4); + cairo_t *cr = cairo_create(surface); + cairo_scale(cr, scale, scale); /* draw in logical coordinates */ + + draw_background(cr, app, o->width, o->height); + + if (app->mode == MODE_PASSWORD) { + draw_password(cr, app, o->width, o->height); + } else { + struct grid gr; + att_grid(o, &gr); + draw_pattern(cr, app, o, &gr); + } + + char prompt[128]; + prompt_text(app, prompt, sizeof(prompt)); + draw_text(cr, o->width, o->height, prompt, 0.15); + + cairo_destroy(cr); + cairo_surface_flush(surface); + cairo_surface_destroy(surface); + + b->busy = true; + wl_surface_set_buffer_scale(o->surface, scale); + wl_surface_attach(o->surface, b->wl_buffer, 0, 0); + wl_surface_damage_buffer(o->surface, 0, 0, pw, ph); + wl_surface_commit(o->surface); +} + +void render_all(struct att *app) +{ + struct att_output *o; + wl_list_for_each(o, &app->outputs, link) + render_output(o); +}