initial commit
This commit is contained in:
@@ -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:<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.
|
||||
Reference in New Issue
Block a user