Files
att_lock/CLAUDE.md
T
2026-08-02 22:00:22 +02:00

160 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, ~2000 LOC across five `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:** the repo is a git repo now, so `nix build` only sees **git-tracked** files — a new
`src/*.c` has to be `git add`ed before it will build, or meson fails with `File src/... does not
exist` while `ninja -C build` keeps working fine. `flake.nix` additionally filters `build` and
`result*` out via `lib.cleanSourceWith`; that filter predates the git repo (it kept a plain
`src = ./.` from vacuuming a stale pre-configured `build/` into the sandbox) and is harmless to keep.
## 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.
**Output resize / rotation** — the surface size comes from `configure` and *only* from `configure`:
ext-session-lock makes a buffer that does not match the acked size a `dimensions_mismatch` error,
which kills the client and leaves the session locked with nothing drawing on it. So the locker can
never resize itself on its own initiative.
That matters because not every compositor reconfigures. River 0.4.5 configures a lock surface once,
when it is created, and never again (`LockSurface.create()` is the only `configure()` call site, and
nothing in `Output.zig` re-issues one), so rotating or re-moding an output used to leave the locker
painting at the old size, clipped to the overlap. `att_lock` therefore watches `wl_output`
(`mode`/`geometry`/`scale`), and if the output changed shape but no configure arrives within
`RESIZE_GRACE_MS`, `recreate_lock_surface()` destroys the lock surface and makes a new one for the
same output, which forces a fresh configure at the current size. The lock object is untouched, so
the session stays locked across the swap.
Two guards keep this from misfiring, and both matter:
- The change detector compares `wl_output` values against a snapshot of `wl_output` values taken at
the last configure (`cfg_*`), never against the configured surface size. On a fractionally scaled
output the integer `wl_output.scale` cannot reproduce the logical size, so those two would never
match and every idle moment would look like a pending resize.
- `retry_*` records the geometry a recreate was already attempted for, so a compositor that ignores
the recreate is nagged once instead of being put in a destroy/create loop.
A compositor that does send a configure never reaches any of this: by the time the grace period
expires the snapshot matches again and `outputs_recheck_geometry()` does nothing.
**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:<user>:<pattern>`, 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. The background
is drawn cover-fit (`fmax` of the two axis ratios), so it follows a rotation with no extra work.
**Background images** (`src/image.c`) — `image_load()` takes PNG or PPM (both `P6` binary and `P3`
ASCII, 8- or 16-bit samples), by path or `-` for stdin: `image_gen | att_lock -i -`. Cairo only
decodes PNG and only from a file or stream, so the input is always buffered whole and then sniffed by
magic — which is also what lets a pipe work, since it can be neither reopened nor seeked. PPM decodes
straight into `CAIRO_FORMAT_RGB24`, a native-endian `0x00RRGGBB` word per pixel (write through a
`uint32_t*`, don't assemble bytes by hand, or the channels swap on the way out).
Two things here are deliberate:
- **Every failure returns NULL and is non-fatal.** A bad image must degrade to the plain dark
background, never to a locked session with nothing drawn on it. Decoding happens in `main()` before
`wl_display_connect()`, which also means `-i -` drains the pipe *before* taking the lock rather
than holding a blank screen for as long as the producer runs.
- **`-i -` refuses an interactive stdin** (`isatty`), because `att_lock -i -` typed at a shell would
otherwise block on the terminal forever with the session not yet locked.
## 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.