117 lines
7.2 KiB
Markdown
117 lines
7.2 KiB
Markdown
# 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:<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. 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.
|