@tikoci/centrs 0.1.9-next.24 → 0.1.9-next.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/GLOSSARY.txt +9 -1
  3. package/README.md +28 -16
  4. package/commands/api/AGENTS.md +3 -2
  5. package/commands/api/README.md +8 -1
  6. package/commands/btest/README.md +9 -0
  7. package/commands/btest/examples.md +7 -0
  8. package/commands/devices/README.md +72 -3
  9. package/commands/devices/examples.md +56 -1
  10. package/commands/execute/README.md +11 -0
  11. package/commands/explain/README.md +223 -3
  12. package/commands/explain/examples.md +8 -1
  13. package/commands/retrieve/README.md +22 -1
  14. package/commands/retrieve/examples.md +84 -0
  15. package/commands/transfer/README.md +25 -0
  16. package/commands/transfer/examples.md +46 -0
  17. package/docs/CLI.md +11 -3
  18. package/docs/MATRIX.md +5 -3
  19. package/docs/errors/cdb/encrypt-failed.md +10 -0
  20. package/docs/errors/cdb/password-required.md +5 -4
  21. package/docs/errors/cdb/plaintext-backups.md +11 -0
  22. package/docs/errors/input/invalid-routeros-path.md +25 -3
  23. package/package.json +8 -4
  24. package/src/api-fanout.ts +2 -0
  25. package/src/api.ts +12 -0
  26. package/src/btest.ts +7 -0
  27. package/src/cli/api.ts +18 -1
  28. package/src/cli/btest.ts +9 -0
  29. package/src/cli/devices.ts +76 -8
  30. package/src/cli/execute.ts +9 -0
  31. package/src/cli/retrieve.ts +9 -0
  32. package/src/cli/terminal.ts +9 -0
  33. package/src/cli/transfer.ts +10 -1
  34. package/src/core/error-catalog.ts +13 -2
  35. package/src/core/menu-path.ts +89 -0
  36. package/src/devices.ts +154 -9
  37. package/src/execute-fanout.ts +2 -0
  38. package/src/execute.ts +7 -0
  39. package/src/explain/blocks.ts +2 -2
  40. package/src/explain/catalog.ts +22 -12
  41. package/src/explain/pasted-prompt.ts +198 -0
  42. package/src/explain/pathresolve.ts +2 -2
  43. package/src/explain/segment.ts +2 -2
  44. package/src/explain/symbols.ts +2 -2
  45. package/src/explain/verbsplit.ts +2 -2
  46. package/src/explain/write.ts +2 -2
  47. package/src/explain.ts +2 -0
  48. package/src/index.ts +4 -0
  49. package/src/protocols/adapter.ts +2 -0
  50. package/src/protocols/btest-session.ts +10 -30
  51. package/src/protocols/ec-srp5.ts +19 -10
  52. package/src/protocols/mac-telnet-console.ts +2 -2
  53. package/src/resolver/cdb.ts +58 -2
  54. package/src/resolver/index.ts +1 -0
  55. package/src/resolver/quickchr-provider.ts +5 -0
  56. package/src/resolver/selection.ts +11 -0
  57. package/src/retrieve-fanout.ts +2 -0
  58. package/src/retrieve.ts +11 -0
  59. package/src/settings.ts +1 -0
  60. package/src/terminal.ts +7 -0
  61. package/src/transfer-fanout.ts +2 -0
  62. package/src/transfer.ts +79 -14
package/CHANGELOG.md CHANGED
@@ -17,6 +17,18 @@ part of this section.
17
17
 
18
18
  ### Added
19
19
 
20
+ - `explain` offers a non-applied repair tip for pasted console prompts
21
+ (#370, #567, #578), including `[user@host] /menu>` and HotLock `>>` forms.
22
+ The candidate preserves command separators and names the menu context;
23
+ multiline repairs render as fenced code. The original analysis stays intact.
24
+ - `--match user=<name>|target=<addr>|<record-type>` on `retrieve`, `execute`,
25
+ `api`, `transfer`, `terminal` and `btest client` picks one of several CDB
26
+ records saved for the same `<router>` (#583), as `devices show --match`
27
+ does. One address saved under two users was `identity/ambiguous` on every
28
+ command, and the error named a `--match` they rejected. The flag is
29
+ single-target: with a fan-out selection or `--quickchr` it is
30
+ `usage/conflicting-flags`, and with no CDB record in play it is
31
+ `identity/no-match` rather than ignored.
20
32
  - **Live `explain` over rest-api + native-api is `CHR-passed`** (#236).
21
33
  Examples 7–16, 19, 24, 48 and 52–53 pass on both transports on stable
22
34
  7.24.5 and long-term 7.23.8. `runtimeAcceptance` stays `not-proven`.
@@ -92,6 +104,16 @@ part of this section.
92
104
  client, its controller.
93
105
  - `execute --cmr` accepts a command that opens with a relative menu change
94
106
  (`/system; identity/print`); its `:parse` return was read as incomplete.
107
+ - CDB encryption lifecycle (#558). `devices init --encrypt` creates an
108
+ encrypted empty CDB; `devices encrypt` converts an existing open CDB in place
109
+ with a fresh salt, keeping its records, unknown fields, and file mode. Both
110
+ need a non-empty `--cdb-password` / `CENTRS_CDB_PASSWORD`; a password alone
111
+ still never converts. Conversion keeps every backup and writes a plaintext
112
+ snapshot unless `--no-backup` is passed. It warns with `cdb/plaintext-backups`.
113
+ An already-encrypted file is verified and left unchanged. Library:
114
+ `initCdb({ encrypt })`, `encryptCdb({ skipBackup })`. `init` results now
115
+ include `encrypted`. `init` and `encrypt` refuse flags they would otherwise
116
+ ignore (`--user`, `--group`, `--via`, …) with `input/invalid-command`.
95
117
 
96
118
  ### Changed
97
119
 
@@ -201,6 +223,35 @@ part of this section.
201
223
 
202
224
  ### Fixed
203
225
 
226
+ - **`retrieve` and `api` can no longer send a non-read** (#586). A `#` or
227
+ `?` in the path cut the appended `/print` off the REST URL, so
228
+ `retrieve '/system/reboot#' --no-validate --attribute uptime` posted
229
+ `/rest/system/reboot` and rebooted CHR 7.24.5 without `--yes`; `api` with a
230
+ projection did the same. Both now require every path segment to be a
231
+ RouterOS menu name (letters, digits, hyphens; case folds) on every
232
+ transport, even with `--validate=false`, failing
233
+ `input/invalid-routeros-path` before resolution. The REST adapter also
234
+ refuses any request path containing `?`, `#`, `\`, control characters or a
235
+ dot segment (including `%2e%2e`), for every command. Reported by
236
+ routeros-muse.
237
+ - `devices set <target> key=` removes the comment token instead of storing an
238
+ empty value, as documented (#590). Removing an absent token is a no-op;
239
+ `--password ""` still clears the password, and bare `password=`/`user=`
240
+ stay `cdb/reserved-key`.
241
+ - `transfer` honors a CDB record's `via=` preference (#591). A record with
242
+ `via=ssh` drove `execute` over SSH but transfer ignored it and needed
243
+ `--via sftp`. Transfer now maps the record's transport to a method
244
+ (`ssh`→`sftp`, `rest-api`→`rest`, `native-api`→`native`) after `--via` and
245
+ `CENTRS_TRANSFER_VIA`, and accepts `--via ssh` as an alias of `sftp`. A
246
+ record transport that cannot carry files fails `settings/invalid-via`
247
+ naming the record; a record-pinned REST upload over 60 KB names the record
248
+ in its `transport/unsupported-operation` remediation.
249
+ - The `explain` catalog generator skips the CLI Reference's new
250
+ `compare-versions` widget page (2026-10-30). The page carries no command
251
+ entry, and it aborted the catalog drift report. The refreshed catalog adds
252
+ 10 published paths (`/interface/wifi/easymesh/*`,
253
+ `/app/settings/remove-certificate`, three `/system/resource/hardware`
254
+ commands), and the `/cmr/client` gates drop `!powerpc`.
204
255
  - `retrieve --sample`, `--follow` and `--wait` no longer report a validation
205
256
  verdict the gate never gave (#537). A cancellation before the gate ran now
206
257
  reports the device stage `not reached` (`interrupted` for `--sample` and
@@ -277,6 +328,12 @@ part of this section.
277
328
  `:for from=1 counter=i to=2` binds `i`, so later `$` references link;
278
329
  previously the argument name was declared and a reordered `counter=` left
279
330
  `$i` unbound.
331
+ - EC-SRP5 hashes shared `z` and the REDP1 loop seed minimal-length, as
332
+ RouterOS does (#565). centrs padded both to 32 bytes, following `mtwei.c`.
333
+ As a result, about 1 in 256 MAC-Telnet logins failed on a short `z`. On
334
+ both MAC-Telnet and btest, every login failed for an account whose salt hit
335
+ the REDP1 edge, about 1 in 317 password changes. btest's `z`-only workaround
336
+ (#516) is replaced by the shared fix.
280
337
  - mac-telnet with an explicit directed-broadcast `--host` (`192.168.74.255`)
281
338
  sends the owning interface's real MAC (#546, #547). On Linux, `connect()` to
282
339
  a broadcast reports `0.0.0.0`, so the session fell back to a synthetic
package/GLOSSARY.txt CHANGED
@@ -94,6 +94,7 @@ cwd
94
94
  datagram
95
95
  davidanson
96
96
  DDNS
97
+ dsv # `:serialize to=dsv` delimiter-separated-values format (RouterOS 7.x); the #374 comma-value control pins offline acceptance of a `delimiter=,` value
97
98
  dialable # an endpoint a client can actually connect to (advertised AND reachable)
98
99
  defconf
99
100
  demux
@@ -116,6 +117,7 @@ fat-chr
116
117
  figcaption
117
118
  FlaUI
118
119
  frontends
120
+ frontmatter # YAML metadata block at the top of a Markdown file (e.g. instruction applyTo headers)
119
121
  haakonnessjoen
120
122
  Hammerspoon
121
123
  HMAC
@@ -373,6 +375,8 @@ underflows
373
375
  Werror
374
376
 
375
377
  # MTWEI / EC-SRP vocabulary (src/protocols/mtwei.ts, mac-telnet auth)
378
+ # libucrypto: RouterOS's shared cryptography library; EC-SRP5 encoding source.
379
+ libucrypto
376
380
  # docrypto: mtwei.c's client EC-SRP proof function (mtwei_docrypto), ported to TS.
377
381
  docrypto
378
382
  # redp: REDP1 (IEEE P1363.2) random-point derivation used in the MTWEI tangle.
@@ -721,8 +725,11 @@ remoteap # caps-man definition module for `/caps-man/remote-cap`
721
725
  seccfg # caps-man definition module for `/caps-man/security`
722
726
  ifaces # CLI-Reference definition module for `/tool/graphing/interface`
723
727
  datapath # `/caps-man/datapath` — the real CLI menu behind `dpathcfg`
724
- easymesh # `/interface/wifi/easymesh` — the doc spelling of `/interface/wifi/wps-push-button`
728
+ easymesh # `/interface/wifi/easymesh` — once a doc spelling of `/interface/wifi/wps-push-button`; published as its own ungated menu since 2026-10-30, in no CHR tree through 7.26beta3
725
729
  wps # Wi-Fi Protected Setup — `/interface/wifi/wps-push-button`
730
+ bss # Wi-Fi basic service set — `/interface/wifi/easymesh/bss`
731
+ dpp # Wi-Fi Device Provisioning Protocol — `/interface/wifi/easymesh/dpp-trigger`
732
+ overcurrent # `/system/resource/hardware/reset-overcurrent`, gated by syscap `usb-hub`
726
733
  qos # switch-chip quality of service — `/interface/ethernet/switch/qos`, a real menu segment R1 must never drop
727
734
  qca # Qualcomm Atheros — the `wireless-qca` package gate
728
735
  multiswitch # syscap gate: more than one switch chip
@@ -807,6 +814,7 @@ zzznotamenu # a menu name no RouterOS build has — `/ip { zzznotamenu { print }
807
814
  # #361 absent-menu controls. Deliberate NON-words: each names a path no RouterOS
808
815
  # build has, or quotes a published gate verbatim, so the spelling is load-bearing.
809
816
  nosuch # a top-level menu no RouterOS build has — `/nosuch` is `bad command name nosuch` on CHR 7.23.7, the `validation/unknown-path` control (#361)
817
+ nosuchcmd # a root command no RouterOS build has — `:local varID [nosuchcmd where name=x]` is `bad command name nosuchcmd (line 1 column 15)` on CHR 7.25 and 7.23.8, the #374 unknown-command control
810
818
  nosuchmenu # a submenu no RouterOS build has — `/ip/nosuchmenu/print` is `syntax error (line 1 column 15)` on CHR 7.23.7, pointing at the `/` after it (#361)
811
819
  nosuchverb # a command no RouterOS build has under a real menu — `/ip/address/nosuchverb` is `bad command name nosuchverb` on CHR 7.23.7 (#361)
812
820
  qqq # the second segment of the parse-oracle row `/zzz qqq print`, captured on CHR 7.23.3 (GH#230)
package/README.md CHANGED
@@ -40,7 +40,8 @@ product; without them this would just be a worse `curl`.
40
40
  > Check current dist-tags with
41
41
  > `npm view @tikoci/centrs dist-tags --json`. The library, device registry, and
42
42
  > first MCP server phases are CHR-verified; the CLI is wired (`coded`);
43
- > encrypted-CDB writes are still blocked, and the TUI/proxy frontends are planned.
43
+ > encrypted-CDB reads and writes are supported, and the TUI/proxy frontends
44
+ > are planned.
44
45
  > `docs/MATRIX.md` is the single source of truth for what works today — treat
45
46
  > anything not green there as not-yet-shipped.
46
47
 
@@ -49,9 +50,10 @@ product; without them this would just be a worse `curl`.
49
50
  - **Frontends:** TypeScript API, CLI, MCP server (Phase 1 + first Phase 2 CDB
50
51
  mutation `CHR-passed` — see `commands/mcp/`), TUI and HTTP proxy/daemon
51
52
  (future).
52
- - **Protocols:** REST API (preferred), native API, and SNMP for reads;
53
- SSH / MAC-Telnet / RoMON / WinBox Terminal for interactive and L2 execute;
54
- MNDP for discovery. centrs picks the protocol for you (and lets you pin one
53
+ - **Protocols:** REST API and native API for reads and writes; SSH / MAC-Telnet
54
+ for execution and interactive terminals; SFTP for file transfer; MNDP for
55
+ discovery; bandwidth test for peer measurement. SNMP, RoMON and WinBox
56
+ Terminal are planned. centrs picks the protocol for you (and lets you pin one
55
57
  with `via=` or `--via`).
56
58
  - **Commands:** every router-facing command resolves a *target* — a router you
57
59
  name on the CLI — to a real address, credentials, and a protocol, then runs.
@@ -60,12 +62,14 @@ product; without them this would just be a worse `curl`.
60
62
 
61
63
  | Command | Purpose |
62
64
  | ---------- | ------- |
63
- | `retrieve` | Read RouterOS state over REST/native API and SNMP OID/MIB values. `--follow` keeps a menu's rows current over native API (snapshot, then a line per change; NDJSON); `--sample <interval>` re-reads it on a timer for counters; `--query` filters on the router with the `print where` grammar; `--wait` retries until a read succeeds (or an optional `--until` condition holds), up to a deadline. |
65
+ | `retrieve` | Read RouterOS state over REST/native API. `--follow` keeps a menu's rows current over native API (snapshot, then a line per change; NDJSON); `--sample <interval>` re-reads it on a timer for counters; `--query` filters on the router with the `print where` grammar; `--wait` retries until a read succeeds (or an optional `--until` condition holds), up to a deadline. SNMP OID/MIB reads are planned. |
64
66
  | `execute` | Run RouterOS CLI-shaped read/write commands (add/set/remove) over native API/REST/L2 surfaces. |
65
67
  | `api` | Structured RouterOS API passthrough (gh-api style): one command per operation, structured in/out, can write, over REST or native API. Follow changes with `api … <menu>/listen`; `--stream` streams any command's replies (native API; NDJSON). |
66
68
  | `terminal` | Open an interactive console, primarily SSH or MAC-Telnet. |
69
+ | `transfer` | Upload, download and manage device files over REST/native API or SFTP; `upload` and `download` are shortcuts. |
70
+ | `btest` | Run a MikroTik bandwidth-test client or server over TCP/UDP. |
67
71
  | `check` | Probe reachability and management protocol availability. **Designed, not implemented** — see `docs/MATRIX.md`. |
68
- | `explain` | Analyze a RouterOS command before running it: canonical form, syntax diagnostics, and what is valid at a path. Offline analysis runs today; the live-device probes are designed. |
72
+ | `explain` | Analyze RouterOS source offline, or add read-only device evidence over REST/native API, including path schemas, completion candidates and value types. Runtime acceptance remains unproven. |
69
73
  | `devices` | View and maintain the CDB-backed device registry (the only writer). |
70
74
  | `discover` | Discover MNDP neighbors and optionally save them into the CDB. |
71
75
  | `settings` | Manage centrs's own settings (`centrs.env`, `__default__`) and run first-time setup. |
@@ -168,18 +172,24 @@ so you can always see *why* a given port or transport was chosen.
168
172
 
169
173
  `devices` is the only command that writes the CDB, and it does so safely
170
174
  (timestamped backup, atomic rename, unknown WinBox fields preserved verbatim).
171
- Encrypted-CDB **reads** work with `--cdb-password` / `CENTRS_CDB_PASSWORD`;
172
- encrypted **writes** are currently blocked pending a verified WinBox round-trip.
175
+ Encrypted-CDB reads and writes work with `--cdb-password` /
176
+ `CENTRS_CDB_PASSWORD`; writes decrypt, mutate and re-encrypt with a fresh salt.
177
+ `devices init --encrypt` creates an encrypted registry, and `devices encrypt`
178
+ explicitly converts an existing open CDB. Conversion retains existing backups
179
+ and warns that plaintext copies remain. The manual WinBox round-trip is still
180
+ pending; see the per-command contract for the current evidence and limits.
173
181
  The full contract — identity resolution, ambiguity handling, write strategy,
174
182
  and the comment grammar — lives in `commands/devices/README.md` and
175
183
  `docs/CONSTITUTION.md` (Identity and CDB).
176
184
 
177
185
  ## The result envelope, validation, and errors
178
186
 
179
- - **Validation is on by default** (`validate=true`). Reads are checked through
180
- `/console/inspect`; CLI-shaped `execute` commands through `[:parse]`. Set
181
- `validate=false` (per device or per call) only as a deliberate escape hatch.
182
- - **Every call returns one envelope:** `{ ok, data?, warnings?, error?, meta }`,
187
+ - **Validation is on by default** (`validate=true`). CLI-shaped `execute` and
188
+ `api /execute` scripts pass the offline analyzer before device validation.
189
+ Reads are checked through `/console/inspect`; CLI-shaped commands through
190
+ `[:parse]`. Set `validate=false` (per device or per call) only as a deliberate
191
+ escape hatch.
192
+ - **Every call returns one envelope:** `{ ok, data?, warnings, tips, error?, meta }`,
183
193
  with provenance and source-of-truth reporting in `meta`.
184
194
  - **Errors are typed values**, not thrown strings, with slash-namespaced codes
185
195
  (`routeros/…`, `transport/…`, `validation/…`, `cdb/…`) and a `details_url`
@@ -250,7 +260,9 @@ bun run explain:value-census # value-surface census (#225)
250
260
 
251
261
  ## Done definition (short version)
252
262
 
253
- A feature is done when every line in `commands/<name>/examples.md` is green on
254
- real CHR via `bun run test:integration`. Code existing is not done. Unit tests
255
- passing is not done. Full rule:
256
- `.github/instructions/done-definition.instructions.md`.
263
+ Live capabilities require every applicable example in `commands/<name>/examples.md`
264
+ green on real CHR via `bun run test:integration`. Offline analysis requires its
265
+ command README's full baseline acceptance criteria, including committed
266
+ CHR-grounded semantic evidence. The fixture-backed `devices` and `settings`
267
+ integration suites retain their command-specific contracts. Full rule:
268
+ [`docs/CONSTITUTION.md` → Done definition](docs/CONSTITUTION.md#done-definition).
@@ -87,8 +87,9 @@ stack-language access without centrs modelling the whole query grammar.
87
87
 
88
88
  ## CONFIRMED ON CHR (Phase 0 grounding spikes — CHR 7.23.1, 2026-06-29)
89
89
 
90
- Probes: scratchpad `api-grounding-spike.ts` (listen/CRUD/execute) and
91
- `api-grounding-spike2.ts` (duration/monitor + query forms + inspect path). Raw
90
+ Probes (phase-0 lab history, not runnable methods): `api-grounding-spike.ts`
91
+ (listen/CRUD/execute) and `api-grounding-spike2.ts` (duration/monitor + query
92
+ forms + inspect path). Raw
92
93
  native codec for the listen probe; `fetch` for REST; `connectNativeApi().talk()`
93
94
  for native.
94
95
 
@@ -51,7 +51,14 @@ centrs api <router> <endpoint> [flags]
51
51
  segment (`ip/address/*1`) addresses one row for GET/PATCH/DELETE; PUT and
52
52
  POST reject it with `input/invalid-path`. A trailing `/listen` segment is
53
53
  the menu's `listen` command, which implies `--stream` + `--via native-api`
54
- and requires GET (`ip/address/*1/listen` follows one row).
54
+ and requires GET (`ip/address/*1/listen` follows one row). After that
55
+ normalization every menu segment must be a RouterOS menu name (letters,
56
+ digits, hyphens; case folds), or the request fails with
57
+ `input/invalid-routeros-path` before any transport is chosen, whatever
58
+ `--validate` says. A `#` or `?` once cut `/print` off a projected GET's
59
+ REST URL and posted the bare command (`/system/reboot#` rebooted CHR 7.24.5,
60
+ #586); the grammar and retrieve's REST guard are described in
61
+ [retrieve → Validation](../retrieve/README.md#validation).
55
62
 
56
63
  The HTTP method (`-X`, default `GET`) is honored **literally** against RouterOS's
57
64
  REST mapping — `GET`→print/get, **`PUT`→add**, `PATCH`→set, `DELETE`→remove,
@@ -139,6 +139,15 @@ is net-new — `mtwei.ts` only implements the client. A bad proof (or a client t
139
139
  will not offer EC-SRP5 against an `authenticate=yes` server) maps to
140
140
  `transport/auth-failed`. Legacy pre-6.43 double-MD5 is out of scope for v1.
141
141
 
142
+ The shared core hashes minimal-length big-endian integers in the REDP1 loop
143
+ and for shared-point `z`; public keys and the REDP1 input keep their fixed
144
+ 32-byte X encoding (#565). The distinction is grounded in RouterOS
145
+ `libucrypto.so` 6.49.23/7.25 and `tikoci/m2ir`'s live WinBox discriminators.
146
+ `test/fixtures/ec-srp5.json` pins calculated regression transcripts;
147
+ `test/integration/ec-srp5.test.ts` forces the encoding edges against a real
148
+ CHR btest client, including a short `z` and a leading-zero REDP1 input
149
+ (confirmed on CHR 7.23.8 and 7.25).
150
+
142
151
  ## Output
143
152
 
144
153
  btest runs over time, so it emits **live records** during the run plus a final
@@ -99,6 +99,13 @@ and non-zero throughput. This is the example that exercises the net-new
99
99
  server-side EC-SRP5 verifier against real RouterOS (TCP keeps the gated path off
100
100
  the SLIRP UDP-reverse-NAT question).
101
101
 
102
+ `test/integration/ec-srp5.test.ts` also exercises this exchange with chosen
103
+ test salts and ephemeral scalars, one edge per pass: a short REDP1 loop hash
104
+ input, a REDP1 input whose leading zero must stay, and that input again with a
105
+ forced short shared `z`. RouterOS's client proof must match the minimal-length
106
+ derivation. This discriminator stops at the
107
+ proof; the normal btest suites cover completed sessions and throughput (#565).
108
+
102
109
  ### 4. Wrong credentials are rejected
103
110
 
104
111
  > *Designed, not yet gated* — the auth-reject path is asserted by the loopback
@@ -89,6 +89,17 @@ or a duplicated `identity=`):
89
89
  lists all matching entries (by `cdbRecordIndex`, `target`, `user`, and
90
90
  `recordType`). The caller pins the choice with `--match` by `user`
91
91
  (`--match user=admin`), by record-type (`--match ipUser`), or by exact target.
92
+ `--match` takes the same selector on `devices show`/`set`/`remove` and on
93
+ every command that resolves a `<router>` from the CDB: `retrieve`,
94
+ `execute`, `api`, `transfer`, `terminal` and `btest client` (#583). It is a
95
+ single-target flag: with a fan-out selection or `--quickchr` it is
96
+ `usage/conflicting-flags`, and when no CDB record is in play (no CDB, an
97
+ unregistered literal host, a `__default__`-only fallback, or `--host`
98
+ without `<router>`) it fails `identity/no-match` rather than being ignored.
99
+ With no target at all (no `<router>`, no `--host`) the CLI still reports
100
+ the missing-target usage error first.
101
+ An ambiguous positional inside a fan-out names the single-target
102
+ `--match` re-run, since the fan-out itself refuses the flag.
92
103
 
93
104
  `--prefer-family=ipv4|ipv6` is a non-interactive way to break v4/v6 ties
94
105
  deterministically; with it set, no prompt is raised. It emits a warning
@@ -141,6 +152,15 @@ Rules:
141
152
  - An **empty value removes** the token: `devices set <t> via=` deletes the `via`
142
153
  override (matching the internal upsert/remove model in `comment-kv.ts`). There
143
154
  is no current need to store a literally-empty value; revisit if one arises.
155
+ Removing a token that is already absent is a no-op success — the comment is
156
+ left byte-for-byte identical.
157
+ - First-class fields have their own empty-value rules, distinct from token
158
+ removal: `--password ""` clears the stored password (and its saved-password
159
+ flag); `--user ""` writes an empty user. Bare `user=` / `password=` /
160
+ `group=` / `profile=` / `session=` positionals never reach those fields —
161
+ they are refused with `cdb/reserved-key` — so comment-token removal (`key=`
162
+ deletes the token) and credential clearing (`--password ""` empties the
163
+ password) are deliberately different operations.
144
164
  - `commentMirror` is kept in sync with `comment` on every write.
145
165
 
146
166
  Precedence (lowest → highest): built-in defaults → user config file
@@ -340,7 +360,8 @@ runs produce stable diffs.
340
360
  ## Subcommands
341
361
 
342
362
  ```text
343
- centrs devices init # create an empty CDB at the resolved path
363
+ centrs devices init [--encrypt] # create an empty CDB at the resolved path
364
+ centrs devices encrypt [--no-backup] # explicitly encrypt an existing open CDB
344
365
  centrs devices list [--group G] [--where attr=value]
345
366
  [--near lat,lon,radius] [--bbox south,west,north,east]
346
367
  [--format json|yaml|text]
@@ -370,9 +391,11 @@ clack-style prompt used by `@tikoci/quickchr`); it is **not** a separate
370
391
  field-editing verb, and invoking it today returns `usage/not-implemented`. There
371
392
  is no `update`.
372
393
 
373
- - `init` creates an empty open CDB at the resolved path (`--cdb-file`,
394
+ - `init` creates an empty CDB (open by default, encrypted with `--encrypt`)
395
+ at the resolved path (`--cdb-file`,
374
396
  `CENTRS_CDB_FILE`, or the default), with parent directories and mode `0600`.
375
- It never overwrites: on an existing, readable CDB it succeeds with a
397
+ It never overwrites: on an existing, readable CDB matching the encryption
398
+ requirement it succeeds with a
376
399
  `cdb/file-exists` warning and writes nothing. An explicit path is created only
377
400
  by `init`; every other subcommand reports `cdb/not-found` for it (typo
378
401
  protection), and that error names `init`.
@@ -437,6 +460,52 @@ can edit it too. Precedence is per-field: args → env → device record →
437
460
  **MCP** it never widens the allowlist — an unregistered target is still
438
461
  rejected; `__default__` only fills missing creds for a registered device.
439
462
 
463
+ ## CDB encryption lifecycle (#558)
464
+
465
+ Start encrypted with `devices init --encrypt`, or start open for a lab and run
466
+ `devices encrypt` when moving to production. Both require a non-empty
467
+ `--cdb-password` / `CENTRS_CDB_PASSWORD`. A password alone does not change the
468
+ mode of initialization or ordinary writes. The library exports
469
+ `initCdb({ encrypt: true, cdbPassword, cdbFile })` and
470
+ `encryptCdb({ cdbPassword, cdbFile })`; both use the shared settings precedence.
471
+
472
+ Encrypted initialization keeps atomic no-clobber creation and mode `0600`.
473
+ Initialization returns `data.encrypted` for both newly created and existing
474
+ files. Competing encrypted initializers with different passwords fail closed:
475
+ the loser gets `cdb/decrypt-failed` when it cannot unlock the winner's file.
476
+ An existing open CDB refuses `init --encrypt`; conversion must be explicit.
477
+ An existing encrypted CDB is verified with the supplied password and left alone.
478
+ Conversion requires an existing readable CDB, preserves its records and unknown
479
+ fields, uses a fresh salt and atomic replacement, and keeps its file mode.
480
+ `data` reports `cdbFile`, `encrypted: true`, `changed`, `recordCount`, and the
481
+ new `backupPath` when conversion occurred. Default text output reports
482
+ `encrypted` or `verified encrypted ... (unchanged)`, the record count, and
483
+ the backup path when created, followed by the plaintext-backup warning.
484
+ Already-encrypted conversion verifies
485
+ the password and returns `changed: false` without rewriting or rotating it.
486
+
487
+ **Backups remain sensitive.** Conversion retains every existing backup and
488
+ creates a plaintext snapshot of the original; it does not run backup retention.
489
+ The `cdb/plaintext-backups` warning accompanies conversion, including no-op runs:
490
+ only the active file is protected. Handle backups according to your own retention
491
+ policy; centrs neither encrypts nor deletes them during conversion. Subsequent
492
+ ordinary writes still use the normal five-backup retention below.
493
+ `--no-backup` (library: `skipBackup: true`) skips the new plaintext snapshot, so
494
+ no fresh plaintext copy is written; earlier backups are still retained and the
495
+ warning still applies to them. Without the snapshot, a forgotten password cannot
496
+ be recovered.
497
+
498
+ `init` and `encrypt` act on the CDB file, not on records: they accept only
499
+ `--cdb-file`, `--cdb-password`, `--format`/`--json`, plus `--encrypt` (`init`)
500
+ or `--no-backup` (`encrypt`). Any other flag is `input/invalid-command`
501
+ instead of being silently ignored.
502
+
503
+ Close WinBox and other CDB writers before conversion. If they share this path,
504
+ they must reopen it using the CDB password; a stale writer can overwrite the
505
+ conversion. Atomic replacement does not provide concurrent-writer transactions
506
+ (#581). Password rotation and decryption are outside this slice. WinBox manual
507
+ interoperability verification remains open (see Residual risks).
508
+
440
509
  ## CDB write strategy
441
510
 
442
511
  All writes go through one helper (`writeWinBoxCdb`) that:
@@ -651,7 +651,7 @@ centrs devices add 192.0.2.1 --user robot-reader --cdb-file ./lab/winbox.cdb --j
651
651
  ```
652
652
 
653
653
  Creates missing parent directories and an empty open CDB with mode `0600`.
654
- `data` is `{ cdbFile, created: true, recordCount: 0 }`. The `add` then succeeds.
654
+ `data` is `{ cdbFile, created: true, encrypted: false, recordCount: 0 }`. The `add` then succeeds.
655
655
 
656
656
  ### 55. `init` never clobbers
657
657
 
@@ -660,8 +660,63 @@ centrs devices init --cdb-file ./lab/winbox.cdb --json
660
660
  ```
661
661
 
662
662
  On an existing, readable CDB: `ok: true`, `data.created: false`,
663
+ `data.encrypted` reports the actual encryption mode,
663
664
  `data.recordCount` is the current count, and a `cdb/file-exists` warning says
664
665
  nothing was written. As with `git init`, re-running is safe. An existing file that
665
666
  does not load fails with that load error (`cdb/parse-failed`,
666
667
  `cdb/password-required`) and is left untouched. A location that cannot be
667
668
  written fails with `cdb/create-failed`.
669
+
670
+ ### 56. Initialize encrypted from the start
671
+
672
+ ```bash
673
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices init --encrypt --cdb-file ./prod.cdb --json
674
+ ```
675
+
676
+ Creates a complete encrypted empty CDB with mode `0600`; subsequent loads require
677
+ that password. `data.encrypted` is `true`. Re-running verifies the password
678
+ and reports `created: false`, `encrypted: true`.
679
+ Test: `centrs devices encryption (#558)` in `test/integration/devices.test.ts`.
680
+
681
+ ### 57. Convert a lab CDB to encrypted
682
+
683
+ ```bash
684
+ centrs devices init --cdb-file ./lab.cdb
685
+ centrs devices add 192.0.2.1 --user reader --cdb-file ./lab.cdb
686
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices encrypt --cdb-file ./lab.cdb --json
687
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices list --cdb-file ./lab.cdb --json
688
+ ```
689
+
690
+ Conversion reports `encrypted: true`, `changed: true`, `recordCount: 1`, and
691
+ `backupPath`. Records survive a password-protected reload; loading without a
692
+ password fails. All backups remain, including the plaintext pre-conversion file;
693
+ `cdb/plaintext-backups` warns explicitly. Repeating with the same password returns
694
+ `changed: false`; a wrong password fails without writing. Without `--json`,
695
+ text reports conversion or verified unchanged, the record count, and the
696
+ plaintext snapshot path when created; the warning remains visible.
697
+ Test: `centrs devices encryption (#558)` in `test/integration/devices.test.ts`.
698
+
699
+ ### 58. Encryption intent fails closed
700
+
701
+ `init --encrypt` and `encrypt` refuse a missing or empty CDB password before
702
+ creating anything. `init --encrypt` refuses an existing open CDB without changing
703
+ it. `--encrypt` on other subcommands and positional targets on `encrypt` are
704
+ invalid commands. Record, selection, and provenance flags (`--user`, `--group`,
705
+ `--password`, `--via`, …) on `init`/`encrypt`, and `--no-backup` on anything but
706
+ `encrypt`, are `input/invalid-command` rather than silently ignored. Test:
707
+ `centrs devices encryption (#558)` in `test/integration/devices.test.ts`.
708
+
709
+ ### 59. Convert without a plaintext snapshot
710
+
711
+ ```bash
712
+ centrs devices init --cdb-file ./lab-fresh.cdb
713
+ centrs devices add 192.0.2.1 --user reader --cdb-file ./lab-fresh.cdb
714
+ CENTRS_CDB_PASSWORD="$CDB_SECRET" centrs devices encrypt --no-backup --cdb-file ./lab-fresh.cdb --json
715
+ ```
716
+
717
+ A fresh path, so it does not depend on example 57 having already converted
718
+ `./lab.cdb`. Conversion reports `changed: true`, `recordCount: 1`, and no
719
+ `backupPath`, and no new `.bak.*` file appears. The backup the `add` wrote
720
+ is retained, so
721
+ `cdb/plaintext-backups` still warns. Test: `centrs devices encryption (#558)`
722
+ in `test/integration/devices.test.ts`.
@@ -529,6 +529,17 @@ How it is wired:
529
529
  but rejects the MD5 proof, so centrs advertises MTWEI (`src/protocols/mtwei.ts`)
530
530
  by default. `END_AUTH` ≠ success (a failed login emits `END_AUTH` then "Login
531
531
  failed" + `END`) → `transport/auth-failed`.
532
+ - **MTWEI hash encoding (grounded, #565):** RouterOS hashes shared `z` and the
533
+ REDP1 loop seed minimal-length; `haakonnessjoen/MAC-Telnet`'s `mtwei.c` pads
534
+ both to 32 bytes. When they differ, the padded proof fails. On CHR 7.25 over
535
+ the L2 harness (2026-10-31): short-`z` logins accepted the minimal proof 8 of
536
+ 8 and rejected the padded proof 1 of 1 (about 700 logins). The `z` edge hits
537
+ about 1 in 256 logins. The REDP1 edge depends on the salt RouterOS generates
538
+ at each password change, about 1 in 317 changes; it fails every login until
539
+ the next change. A password-rotation scan over raw handshakes to PASSSALT
540
+ found divergent salts, and on one the minimal proof was accepted 3 of 3 while
541
+ the padded proof was rejected 3 of 3, all with a clean 32-byte `z` to isolate
542
+ the edge. The shared core is `src/protocols/ec-srp5.ts`.
532
543
  - **Console quirks to know:** a successful write **prints no output** (no `.id`,
533
544
  unlike REST/native — `data.ret` is empty on a successful add); and there is a
534
545
  ~10s terminal-negotiation stall on every login (a latency cost, not a