diff --git a/.trinity/seals/t27os_T27osKeys.json b/.trinity/seals/t27os_T27osKeys.json index 6ed0c41c88..07f7a832f9 100644 --- a/.trinity/seals/t27os_T27osKeys.json +++ b/.trinity/seals/t27os_T27osKeys.json @@ -1,33 +1,37 @@ { - "built_by": "t27c-bootstrap@0.5.2+ef2e7ee45", + "built_by": "t27c-bootstrap@0.5.2+e3bb9ffec", "closure": [ { "spec": "specs/t27os/agents.t27", "spec_hash": "sha256:c4b89c1bd1dcb751fada2ae1f0330e250d17c5085ea73acdc38fac13548277ee" }, + { + "spec": "specs/t27os/editors.t27", + "spec_hash": "sha256:fc3608c5131f7fae21d6084102a019fc0425117ecf7544ec1eebfa8155d74f72" + }, { "spec": "specs/t27os/session_store.t27", "spec_hash": "sha256:7d05d7dda14587458a77291c3d587e3f08516d8e678f0a4bd06dcaab7f6d1f2f" } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:1c51eed7ba8145e217666faa01f365f1742cdeeb71517ffabc8239d1cd08aff3", - "gen_hash_rust": "sha256:65387d614d4714c7dff0227fb378c33bc7886c695cc42f4ea24458223de573c8", - "gen_hash_verilog": "sha256:9e29c63b98f00cd92e6b0466d356a9d3911bf444f26a652d5e2e6e6b576c6ade", - "gen_hash_zig": "sha256:d244fcdfb0f95fcf8a2742598dbc84d7e66c6c3aec8b4bc632483753564f8230", + "gen_hash_c": "sha256:4da05e755130405b7f85bda4fc5d3456c3638bf567211955a00c2b35fb3448af", + "gen_hash_rust": "sha256:51146ffb9d4d9e2fefc266395eb6ed161971ab4dc9ca949b0650cd008c19e9ce", + "gen_hash_verilog": "sha256:2f1f76f240eb66cd9bee6e5718325f36892e3721ec4d848a670a50c034a484de", + "gen_hash_zig": "sha256:b6aa8439522547b063b1468735ade4eeda04afb2f5624f8a27e59857b8030867", "module": "T27osKeys", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-20T07:11:32Z", + "sealed_at": "2026-10-20T07:50:02Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:bc12487689ca25d46a0155a6cbb30e36983c4898a7e28486266d084c570f8d43", + "spec_hash": "sha256:35efa3efc95753da265df018e8931d7de5366081a54649e153e074dfd1bae697", "spec_path": "specs/t27os/keys.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 63, - "total": 63, + "passed": 81, + "total": 81, "vacuous": 0 }, "toolchain": { diff --git a/.trinity/seals/t27os_T27osPtyLogic.json b/.trinity/seals/t27os_T27osPtyLogic.json index f6d4472c48..19f1f324e7 100644 --- a/.trinity/seals/t27os_T27osPtyLogic.json +++ b/.trinity/seals/t27os_T27osPtyLogic.json @@ -1,5 +1,5 @@ { - "built_by": "t27c-bootstrap@0.5.2+ef2e7ee45", + "built_by": "t27c-bootstrap@0.5.2+e3bb9ffec", "closure": [ { "spec": "specs/t27os/agents.t27", @@ -7,11 +7,11 @@ }, { "spec": "specs/t27os/editors.t27", - "spec_hash": "sha256:9b4e3c083e7aa0d3d3b42ac77f6349820e123d34617abbdfc85358cd22420ede" + "spec_hash": "sha256:fc3608c5131f7fae21d6084102a019fc0425117ecf7544ec1eebfa8155d74f72" }, { "spec": "specs/t27os/keys.t27", - "spec_hash": "sha256:bc12487689ca25d46a0155a6cbb30e36983c4898a7e28486266d084c570f8d43" + "spec_hash": "sha256:35efa3efc95753da265df018e8931d7de5366081a54649e153e074dfd1bae697" }, { "spec": "specs/t27os/pane_state.t27", @@ -27,16 +27,16 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:2cd657507566102e43169919e53ce6aa267cba35b7315127d98321a1f1143911", - "gen_hash_rust": "sha256:b1f9544872abbca303b2b4565a357e4134da53bf232b7efcf2d729d13c3a987a", - "gen_hash_verilog": "sha256:9c28b828e7de42515be178c72ec47fea0b3a20cb6840e9c1a4fbf8085862e08d", - "gen_hash_zig": "sha256:39ac7d141b3d9832cc4ec5e92e9f73e2cf030c4379c4f3253696d46f159efb62", + "gen_hash_c": "sha256:efb132b84e42963b545ad4950c81cd65a3bb9fd74ece35db758f4af5522e5351", + "gen_hash_rust": "sha256:c5e7da1a403caadc39e1f8a6065e45da5021ebb865d7f42de658aa0d6019ab5e", + "gen_hash_verilog": "sha256:6cd1f9c0f7b5b571e5b1a93641bc68ea44b24634a3652abda34de4ec4c5de93f", + "gen_hash_zig": "sha256:ce84121284bc0007cad586e55c9bfc00b6cb072c3a374a300095ef52476b3aab", "module": "T27osPtyLogic", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-20T07:11:37Z", + "sealed_at": "2026-10-20T07:50:06Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:401e66c84055044c98546c093cbdb677ae5cb5203d524b01e172b37a7afc1e33", + "spec_hash": "sha256:8b07d4889aa1226c25eda31972d98cde7b8a8271b2f419f1a78c0a320089f4af", "spec_path": "specs/t27os/pty_logic.t27", "tests": { "failed": 0, diff --git a/docs/t27os/keys-orchestration.md b/docs/t27os/keys-orchestration.md index 7757e6090a..10b895d3d7 100644 --- a/docs/t27os/keys-orchestration.md +++ b/docs/t27os/keys-orchestration.md @@ -49,13 +49,13 @@ No proxy is involved. | Per-tier overrides (opus, sonnet, haiku) | Yes, as data, one column per tier; an empty field falls back to the default; the small-fast variable follows haiku | `MODELS_OPUS/SONNET/HAIKU`, `tier_model`, `profile_var_name/text` | A fourth tier (the one the OpenRouter and Kimi pages call fable) is not mapped; changing a tier is a data edit, not a screen | | `provider/model` syntax | A profile is a provider; its models sit in the tier table | `PROFILE_NAMES`, `PROFILE_PROVIDER` | The combined one-line syntax; it exists in Free Claude Code because its proxy picks the upstream per request | | Many providers | Four profiles today: z.ai, Anthropic direct, OpenRouter, Ollama cloud, over the nine key rows | `PROFILE_PROVIDER`, `PROFILE_BASE_URLS`, `PROFILE_KEY_VARS` | A provider that does not speak the Anthropic protocol needs the local router and `specs/provider/transform.t27` (#10902). DeepSeek, Moonshot and MiniMax speak it but need key rows of their own (see below) | -| Numbered keys, key 1 first | Naming and choice are decided: key 1 keeps the existing Keychain item, keys 2 and 3 add `#2`, `#3`; least-recently-used rotation with a rest time | `keychain_label_n`, `MAX_KEYS`, `rotate_pick`, `rotate_pick_except` | The KEYS tab still shows one row per provider and the host reads only key 1; storing and listing keys 2 and 3 is host work | +| Numbered keys, key 1 first | Naming and choice are decided: key 1 keeps the existing Keychain item, keys 2 to 8 add `#2` to `#8`; least-recently-used rotation with a rest time | `keychain_label_n`, `MAX_KEYS`, `rotate_pick`, `rotate_pick_except` | Built: the host reads every numbered key, up to 8 per provider (`MAX_KEYS`), only through `/usr/bin/security`, never through SecItem; the KEYS tab adds the next free number, deletes the newest key and checks every key (`next_free_key`, `newest_key`, `provider_status`) | | A fallback entry names a key number | Decided: a throttled key (429) with a spare key swaps the key and keeps the profile | `fo_action` = `FA_RESPAWN_KEY`, `fo_target` | The host does not pass the key number to the respawn yet | | Ordered fallback list after retries | Yes, per pane: the profile list is walked forward only | `next_profile`, `fo_action` = `FA_RESPAWN_NEXT`, `MAX_FAILOVERS` | Per request, inside one conversation turn: needs the local router. A respawn does not replay the request that failed; the person continues in the resumed session | | Retries before the fallback | Not ours: Claude Code retries by itself, and the failover starts when the error text reaches the screen | `classify_line` | Nothing to build | | Reasoning effort per route | No | none | Out of scope; not set by any profile | | Bearer token for the local proxy | Not needed, there is no proxy | none | Needed with the router (it binds 127.0.0.1, `ROUTER_BIND`) | -| Admin page for keys | Yes: the KEYS tab, Keychain storage, masked display, a check button, a circuit breaker | `ky_*`, `mask_key`, `status_after_probe`, `circuit_fails`, `bad_mask` | The signed-in account keys on app.t27.ai cannot be spent from a pane: no proxy route exists server-side (see the MAP block in `keys.t27`) | +| Admin page for keys | Yes: the KEYS tab, Keychain storage through `/usr/bin/security`, masked display of the newest key, a check button, a circuit breaker | `ky_*`, `mask_key`, `status_after_probe`, `circuit_fails`, `bad_mask` | The signed-in account keys on app.t27.ai cannot be spent from a pane: no proxy route exists server-side (see the MAP block in `keys.t27`) | | Local models (LM Studio, llama.cpp, Ollama) | Ollama cloud only | profile `ollama-cloud` | Local Ollama needs no key, so the stored-keys mask cannot say it is available; a host fact (is the server up) must join the mask first | | OAuth sign-in for some providers | No | none | Out of scope | diff --git a/docs/t27os/run-local.md b/docs/t27os/run-local.md index 2787e81255..908830a5d8 100644 --- a/docs/t27os/run-local.md +++ b/docs/t27os/run-local.md @@ -21,6 +21,19 @@ The window does not start the server and does not bundle it. They meet on one Un `glass_window.t27`), the server from `$HOME` (`socket_path` in `pty_proto.t27`). With no server the Claude Code pane in the window says "offline" and echoes keys. +Provider keys (the KEYS tab, up to 8 per provider) live in the macOS Keychain, service `ai.t27.os`, account +``, then `#2` .. `#8`. The window reaches them only by running `/usr/bin/security` +(`specs/t27os/keys_host.t27`: posix_spawn, pipes, poll, waitpid), never through the SecItem calls, so +**Security.framework is not linked** and the link line in section 3 is complete (measured 2026-10-20: the +SecItem build did not link without `-framework Security`; this one does). The reason: the window is signed +ad hoc, every rebuild is a new code identity to macOS, and each SecItem read asked for the login password +again. `/usr/bin/security` is signed by Apple and never changes, so a read of an item it created shows no +dialog across rebuilds. A key is never put on argv (`ps` shows it): an add writes one line to the stdin of +`security -i`, and refuses a key with white space, a quote or a backslash. An add never replaces a key +(the account must be free); a delete removes the newest key only. Items that an older SecItem build made +trust that build, not `security`, so a read of one may still ask once: remove it in Keychain Access and add +the key again in the KEYS tab. + ## 2. Prerequisites - Apple silicon, Xcode installed and selected (`xcode-select -p`): `ld`, the SDK and `codesign`. Command Line diff --git a/specs/t27os/glass_window.t27 b/specs/t27os/glass_window.t27 index abf38dd2b3..d4a97e9159 100644 --- a/specs/t27os/glass_window.t27 +++ b/specs/t27os/glass_window.t27 @@ -94,7 +94,6 @@ use t27os::keys::KE_CHAR; use t27os::keys::ST_STORED; use t27os::keys::ST_VALID; use t27os::keys::ST_MISSING; -use t27os::keys::STATUS_NAMES; use t27os::keys::ky_row_top; use t27os::keys::ky_btn_x; use t27os::keys::ky_hit; @@ -105,19 +104,27 @@ use t27os::keys::on_keys_link; use t27os::keys::CIRCUIT_FAILS; use t27os::keys::trim_key; use t27os::keys::mask_key; -use t27os::keys::key_fits; +use t27os::keys::sec_key_safe; +use t27os::keys::key_index; +use t27os::keys::slot_count; +use t27os::keys::next_free_key; +use t27os::keys::newest_key; +use t27os::keys::ky_set_word; +use t27os::keys::provider_status; +use t27os::keys::key_withheld; +use t27os::keys::key_status_text; +use t27os::keys::scan_goes_on; use t27os::keys::probe_has; -use t27os::keys::bad_mask; +use t27os::keys::bad_mask_keys; use t27os::keys::profile_why; use t27os::keys::PROFILE_PROVIDER; use t27os::keys::provider_name; use t27os::keys::status_after_probe; use t27os::keys::circuit_fails; use t27os::keys::status_shown; -use t27os::keys_host::kc_put; -use t27os::keys_host::kc_get; +use t27os::keys_host::kc_put_n; use t27os::keys_host::kc_get_n; -use t27os::keys_host::kc_del_all; +use t27os::keys_host::kc_del_n; use t27os::keys_host::kc_has; use t27os::keys_host::probe; use t27os::keys_host::push_keys; @@ -2547,9 +2554,10 @@ fn bytes_w(kit: [48]u64, s: []const u8, n: usize, size: f64, weight: f64, mono: return w; } -/// The Keychain is read here and nowhere else in the window: how many keys each provider holds (stored[p], -/// 0 to MAX_KEYS, every numbered account is read) and the masked text of its first key (16 bytes a -/// provider). The full key is wiped from the buffer before the next one is read. +/// The Keychain is read here and nowhere else in the window: which numbers each provider holds (stored[p] is a +/// bit mask, bit k set = key k+1 exists; keys.t27 slot_count counts it) and the masked text of its newest key, +/// the highest number (16 bytes a provider). A scan stops after two misses in a row (keys.t27 scan_goes_on). +/// The full key is wiped from the buffer before the next one is read. fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { var key : [256]u8 = undefined; var p : u32 = 0; @@ -2558,11 +2566,15 @@ fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { mlen[p] = 0; stored[p] = 0; var k : u32 = 0; - while (k < MAX_KEYS) { + var misses : u32 = 0; + while (scan_goes_on(k, misses)) { const n : usize = kc_get_n(p, k, key[0..256]); if (n > 0) { - if (stored[p] == 0) { mlen[p] = @as(u32, @intCast(mask_key(key[0..n], mask[at .. at + 16]))); } - stored[p] = stored[p] + 1; + misses = 0; + mlen[p] = @as(u32, @intCast(mask_key(key[0..n], mask[at .. at + 16]))); + stored[p] = stored[p] + (1 << k); + } else { + misses = misses + 1; } var i : usize = 0; while (i < 256) { @@ -2575,14 +2587,18 @@ fn keys_refresh(stored: []u32, mask: []u8, mlen: []u32) -> void { } } -/// Hand the server every stored key with its number (keys_host push_keys), then take back the first key of each -/// provider whose last check failed, as the check button does: a refresh must not undo that. +/// Hand the server every stored key with its number (keys_host push_keys), then take back each key whose last +/// check failed (keys.t27 key_withheld), as the check button does: a refresh must not undo that. kst holds one +/// status per key (keys.t27 key_index). fn keys_sync(fd: i32, kst: []const u32, out: []u8) -> void { push_keys(fd); - const bad : u32 = bad_mask(kst); var p : u32 = 0; while (p < PROVIDER_COUNT) { - if ((bad >> p) % 2 == 1) { client_set_key(fd, setkey_byte(p, 0), "", out); } + var k : u32 = 0; + while (k < MAX_KEYS) { + if (key_withheld(kst[key_index(p, k)])) { client_set_key(fd, setkey_byte(p, k), "", out); } + k = k + 1; + } p = p + 1; } } @@ -2647,19 +2663,19 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto while (p < PROVIDER_COUNT) { const y : f64 = ky_row_top(t, p); const has : bool = stored[p] > 0; - const st : u32 = status_shown(has, kst[p], kfail[p], kopen[p], now); + const st : u32 = status_shown(has, provider_status(kst, p, stored[p]), kfail[p], kopen[p], now); cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); n = provider_name(p, w[0..48]); const nw : f64 = draw_bytes(c, kit, w[0..48], n, x, y + 14.0, 11.0, WEIGHT_SEMIBOLD, false); - // how many keys the provider holds ("zai 7 keys"), right after its name - n = key_count_text(stored[p], w[0..48]); + // how many keys the provider holds ("zai 7 keys"), right after its name; the line below shows the newest key, masked + n = key_count_text(slot_count(stored[p]), w[0..48]); if (n > 0) { cg_fill_rgba(c, 1.0, 1.0, 1.0, MUTED_WHITE); draw_bytes(c, kit, w[0..48], n, x + nw + 8.0, y + 14.0, 9.5, 0.0, false); } cg_fill_rgba(c, 1.0, 1.0, 1.0, TEXT_WHITE); draw_dot(c, status_dot(st), x + 112.0, y + 10.0, 1.0); - n = csv_field(STATUS_NAMES, st, w[0..48]); + n = key_status_text(st, kst, p, stored[p], w[0..48]); cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); draw_bytes(c, kit, w[0..48], n, x + 124.0, y + 14.0, 9.5, 0.0, false); // the key as typed (masked, with a caret) while it is being entered; else the stored key, masked @@ -2678,9 +2694,8 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto } const hit : u32 = ky_hit(mx, my, l, QP_W, QP_PAD, t); const by : f64 = y + KY_BTN_DY; - var setw : u32 = 1; - if (has) { setw = 2; } - draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_SET), by, setw, false, true, hit == p * 4 + B_SET); + const setw : u32 = ky_set_word(stored[p]); + draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_SET), by, setw, false, setw == 1, hit == p * 4 + B_SET); var delw : u32 = 3; if (karm == p) { delw = 4; } draw_key_btn(c, kit, ky_btn_x(l, QP_W, QP_PAD, B_DELETE), by, delw, karm == p, has, hit == p * 4 + B_DELETE); @@ -2699,7 +2714,7 @@ fn draw_keys_panel(c: u64, kit: [48]u64, sw: f64, sh: f64, mx: f64, my: f64, sto pi = pi + 1; } var wy : [96]u8 = undefined; - n = profile_why(sm, bad_mask(kst[0..9]), wy[0..96]); + n = profile_why(sm, bad_mask_keys(kst, stored), wy[0..96]); cg_fill_rgba(c, 1.0, 1.0, 1.0, LABEL_WHITE); // the foot (editors.t27 foot_*): the status row, then OPEN IN, then the chips, each in a band of its own // below the provider rows, so no two text rows share a y band (the status line sat on the chips) @@ -3823,14 +3838,16 @@ pub fn main() -> void { var dacc : f64 = 0.0; var dgo : u32 = 0; var dnow : bool = true; - // the KEYS panel: open, which providers hold a key, their masked text, the last check's status, the - // failure count and when the circuit opened, the provider being typed (and the typed bytes, wiped after - // use), the DELETE that waits for its second click, the check in flight, and the editors found + // the KEYS panel: open, which numbers each provider holds (a bit mask: bit k = key k+1) and the masked text + // of its newest key, the last check's status of every key (keys.t27 key_index: provider * MAX_KEYS + key), + // the failure count and when the circuit opened (per provider), the provider being typed (and the typed + // bytes, wiped after use), the DELETE that waits for its second click, the check in flight (the provider + // and the next number it reads), and the editors found var kyp : bool = false; var kstored : [9]u32 = undefined; var kmask : [144]u8 = undefined; var kmlen : [9]u32 = undefined; - var kst : [9]u32 = undefined; + var kst : [72]u32 = undefined; var kfail : [9]u32 = undefined; var kopen : [9]u64 = undefined; var kbuf : [256]u8 = undefined; @@ -3856,16 +3873,21 @@ pub fn main() -> void { var esp : u32 = 0; var eesc : u64 = 0; var kpend : u32 = KY_NONE; + var kbk : u32 = 0; var kpre : u32 = 0; var ki : u32 = 0; while (ki < 9) { kstored[ki] = 0; kmlen[ki] = 0; - kst[ki] = ST_STORED; kfail[ki] = 0; kopen[ki] = 0; ki = ki + 1; } + var kz : u32 = 0; + while (kz < 72) { + kst[kz] = ST_STORED; + kz = kz + 1; + } var khome : [256]u8 = undefined; var kdir : [512]u8 = undefined; var ktmp : [256]u8 = undefined; @@ -4002,7 +4024,7 @@ pub fn main() -> void { ctl = C_DISMISS; } } else if (kyp and qp == false and dp == false and on_panel(mx, my, sw, WS_TOP)) { - // K. the KEYS panel: close, a row's add / replace / delete (asks twice) / check, an editor chip + // K. the KEYS panel: close, a row's add (the next free number) / delete (the newest key, asks twice) / check (every key), an editor chip const kl : f64 = qp_left(sw); if (on_panel_close(mx, my, sw, WS_TOP)) { kyp = false; @@ -4013,7 +4035,7 @@ pub fn main() -> void { const kh : u32 = ky_hit(mx, my, kl, QP_W, QP_PAD, WS_TOP); const kq : u32 = kh / 4; if (kh != KY_NONE) { - const kact : u32 = ky_click(kh % 4, kstored[kq] > 0, probe_has(kq), karm, kq); + const kact : u32 = ky_click(kh % 4, kstored[kq] > 0, next_free_key(kstored[kq]) >= MAX_KEYS, probe_has(kq), karm, kq); if (kact == KA_EDIT) { kedit = kq; klen = 0; @@ -4023,21 +4045,20 @@ pub fn main() -> void { kedit = KY_NONE; klen = 0; } else if (kact == KA_DELETE) { - // the row's DELETE removes every key of the provider, from the Keychain and the server - kc_del_all(kq); + // the row's DELETE removes the newest key only (keys.t27 newest_key), from the Keychain and the server + const kdn : u32 = newest_key(kstored[kq]); + if (kdn < MAX_KEYS) { + if (kc_del_n(kq, kdn)) { + kst[key_index(kq, kdn)] = ST_STORED; + if (tfd >= 0) { client_set_key(tfd, setkey_byte(kq, kdn), "", tout[0..4100]); } + } + } karm = KY_NONE; - kst[kq] = ST_STORED; kfail[kq] = 0; keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); - if (tfd >= 0) { - var kdk : u32 = 0; - while (kdk < MAX_KEYS) { - client_set_key(tfd, setkey_byte(kq, kdk), "", tout[0..4100]); - kdk = kdk + 1; - } - } } else if (kact == KA_CHECK) { kbusy = kq; + kbk = 0; kpend = 0; karm = KY_NONE; } else { @@ -4158,7 +4179,7 @@ pub fn main() -> void { dp = false; keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); // the refresh also hands the server every numbered key again (a server that restarted forgot them) - if (tfd >= 0) { keys_sync(tfd, kst[0..9], tout[0..4100]); } + if (tfd >= 0) { keys_sync(tfd, kst[0..72], tout[0..4100]); } const khn : usize = ns_bytes(home_dir(), khome[0..256]); kemask = installed_mask(khome[0..khn]); ktpick = term_pick(term_mask()); @@ -4273,11 +4294,15 @@ pub fn main() -> void { kbuf[@as(usize, klen)] = 0; } } else if (kk == KE_SAVE) { - if (key_fits(kbuf[0 .. @as(usize, klen)])) { - if (kc_put(kedit, kbuf[0 .. @as(usize, klen)])) { - kst[kedit] = ST_STORED; - kfail[kedit] = 0; - if (tfd >= 0) { client_set_key(tfd, kedit, kbuf[0 .. @as(usize, klen)], tout[0..4100]); } + if (sec_key_safe(kbuf[0 .. @as(usize, klen)])) { + // the key takes the lowest free number of its provider, never an existing one + const ksn : u32 = next_free_key(kstored[kedit]); + if (ksn < MAX_KEYS) { + if (kc_put_n(kedit, ksn, kbuf[0 .. @as(usize, klen)])) { + kst[key_index(kedit, ksn)] = ST_STORED; + kfail[kedit] = 0; + if (tfd >= 0) { client_set_key(tfd, setkey_byte(kedit, ksn), kbuf[0 .. @as(usize, klen)], tout[0..4100]); } + } } keys_wipe(kbuf[0..256]); keys_refresh(kstored[0..9], kmask[0..144], kmlen[0..9]); @@ -4369,30 +4394,37 @@ pub fn main() -> void { if (used == false) { msg1(app, s_send, ev); } ev = msg_event(app, s_next, 0xffffffffffffffff, 0, mode, true); } - // K. a check button: the frame after the click shows "checking", then the probe runs (it blocks up to - // PROBE_TIMEOUT_S) and its HTTP answer sets the status and feeds the circuit breaker (keys.t27) + // K. a check button: the frame after the click shows "checking", then one number of the provider is + // read and probed per frame (a probe blocks up to PROBE_TIMEOUT_S); its HTTP answer sets that key's status + // and feeds the circuit breaker (keys.t27). Every stored key of the provider is checked, none is skipped. if (kbusy != KY_NONE) { if (kpend == 0) { kpend = 1; + } else if (kbk >= MAX_KEYS) { + kbusy = KY_NONE; + kpend = KY_NONE; + kbk = 0; } else { - const kn2 : usize = kc_get(kbusy, ktmp[0..256]); - var http : u32 = 1; - if (kn2 > 0) { http = probe(kbusy, ktmp[0 .. kn2]); } - kfail[kbusy] = circuit_fails(kfail[kbusy], http); - if (kfail[kbusy] >= CIRCUIT_FAILS and kopen[kbusy] + 60 <= now) { kopen[kbusy] = now; } - kst[kbusy] = status_after_probe(kst[kbusy], http); - // M12: a key that failed its check is withheld from the server, so the next claude pane - // takes the next profile (keys.t27 pick_profile); a key that passed is handed over again - if (tfd >= 0 and kn2 > 0) { - if (bad_mask(kst[0..9]) / (1 << kbusy) % 2 == 1) { - client_set_key(tfd, kbusy, "", tout[0..4100]); - } else { - client_set_key(tfd, kbusy, ktmp[0 .. kn2], tout[0..4100]); + const kn2 : usize = kc_get_n(kbusy, kbk, ktmp[0..256]); + if (kn2 > 0) { + const http : u32 = probe(kbusy, ktmp[0 .. kn2]); + const kix : usize = key_index(kbusy, kbk); + kfail[kbusy] = circuit_fails(kfail[kbusy], http); + if (kfail[kbusy] >= CIRCUIT_FAILS and kopen[kbusy] + 60 <= now) { kopen[kbusy] = now; } + kst[kix] = status_after_probe(kst[kix], http); + // M12: a key that failed its check is withheld from the server, so rotation skips it and the + // next claude pane takes the next profile when none is left (keys.t27 pick_profile); a key + // that passed is handed over again + if (tfd >= 0) { + if (key_withheld(kst[kix])) { + client_set_key(tfd, setkey_byte(kbusy, kbk), "", tout[0..4100]); + } else { + client_set_key(tfd, setkey_byte(kbusy, kbk), ktmp[0 .. kn2], tout[0..4100]); + } } } keys_wipe(ktmp[0..256]); - kbusy = KY_NONE; - kpend = KY_NONE; + kbk = kbk + 1; } } // K. pre-flight (M12): once at start, check the stored key behind each claude profile, in the order @@ -4402,6 +4434,7 @@ pub fn main() -> void { kpre = kpre + 1; if (probe_has(kpp) and kc_has(kpp)) { kbusy = kpp; + kbk = 0; kpend = 0; } } @@ -4685,14 +4718,14 @@ pub fn main() -> void { fwait = fg_wait(fquiet, tlink == PANE_LIVE and topen != PANE_NONE, FRAME_S); msg_flag(tx_cls, s_quiet, true); if (fdraw and narrow) { - draw(cc, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..9], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); + draw(cc, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..72], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); const whole : u64 = cg_image(cc); const img : u64 = cg_crop(whole, 0.0, 0.0, sw * RETINA, sh * RETINA); msg1(layer, s_contents, img); cg_image_release(img); cg_image_release(whole); } else if (fdraw) { - draw(c, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..9], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); + draw(c, kit, frame, state, hov, ma, shown, task, space, sp, ss, cur, collapsed, mx, my, z, sw, sh, ws, full, qp, ring, evn, now, sug, god, glow, dk, dp, darmed, doff, dgo, walking, dlabel, tst[0..10], tcells[0..48000], topen, tcur, kyp, kstored[0..9], kmask[0..144], kmlen[0..9], kst[0..72], kfail[0..9], kopen[0..9], kedit, kbuf[0..256], klen, karm, kbusy, kemask, kewhy, ktpick); const whole : u64 = cg_image(c); const img : u64 = cg_crop(whole, 0.0, 0.0, sw * WIDE_RETINA, sh * WIDE_RETINA); msg1(layer, s_contents, img); diff --git a/specs/t27os/keys.t27 b/specs/t27os/keys.t27 index d3f4f6ed75..08109a1512 100644 --- a/specs/t27os/keys.t27 +++ b/specs/t27os/keys.t27 @@ -7,9 +7,11 @@ ; plus cooldown), ProviderCircuitBreaker, ProviderStatusService, KeychainSecrets. ; Storage is the macOS Keychain, service "ai.t27.os", one item per key (account = provider name for key 1, ; "#2" .. "#8" for the others, keychain_account_n). -; Never a plain file and never the repo. This spec holds no key and no syscall: the Keychain, the probe and -; the child environment are host calls (keys_host.t27, pty_server.t27); only the decisions live here, so -; they run their tests (a module with @extern imports cannot). Every test uses fake values. +; Never a plain file and never the repo. The Keychain is reached only by running /usr/bin/security (the +; section "the Keychain through /usr/bin/security" at the end), never through SecItem calls. This spec holds +; no key and no syscall: the Keychain, the probe and the child environment are host calls (keys_host.t27, +; pty_server.t27); only the decisions live here, so they run their tests (a module with @extern imports +; cannot). Every test uses fake values. ; Refs gHashTag/t27#10782. ; ---- MAP: the app.t27.ai account and its keys (read 2026-10-19, read-only; M12 follow-up) ----------------- ; R = 999-multibots-telegraf apps/vibee-editor/render; Q = BrowserOS trios/agent-server/apps/server/src/api @@ -34,6 +36,7 @@ module T27osKeys; use t27os::agents::AGENT_COUNT; +use t27os::editors::same_bytes; use t27os::session_store::csv_field; use t27os::session_store::put_bytes; use t27os::session_store::put_dec; @@ -467,7 +470,7 @@ pub const KEY_BACKSPACE : u32 = 51; pub const KEY_ESC : u32 = 53; pub const KEY_V : u32 = 9; -pub const KEYS_WORDS : str = "KEYS,add,replace,delete,confirm,check,checking,no key,paste or type a key,enter saves esc cancels,open in"; +pub const KEYS_WORDS : str = "KEYS,add,full,delete,confirm,check,checking,no key,paste or type a key,enter saves esc cancels,open in"; /// Top edge of provider row r in a panel whose top is t. pub fn ky_row_top(t: f64, r: u32) -> f64 { @@ -503,10 +506,14 @@ pub fn on_keys_link(x: f64, y: f64, side_w: f64, sh: f64, foot_h: f64, doc_w: f6 return x >= right - KY_LINK_W and x <= right and y >= sh - foot_h and y <= sh - 6.0; } -/// What a click on button b of a row means. `stored`: the row has a key; `probed`: its provider has a check; -/// `armed`: the row whose DELETE was clicked once (KY_NONE when none); `row`: the clicked row. -pub fn ky_click(b: u32, stored: bool, probed: bool, armed: u32, row: u32) -> u32 { - if (b == B_SET) { return KA_EDIT; } +/// What a click on button b of a row means. `stored`: the row has a key; `full`: it holds MAX_KEYS keys, so +/// "add" has no number left; `probed`: its provider has a check; `armed`: the row whose DELETE was clicked +/// once (KY_NONE when none); `row`: the clicked row. +pub fn ky_click(b: u32, stored: bool, full: bool, probed: bool, armed: u32, row: u32) -> u32 { + if (b == B_SET) { + if (full) { return KA_NONE; } + return KA_EDIT; + } if (stored == false) { return KA_NONE; } if (b == B_DELETE) { if (armed == row) { return KA_DELETE; } @@ -528,9 +535,11 @@ pub fn ky_key(code: u32, cmd: bool) -> u32 { return KE_CHAR; } -/// A typed character is taken when it is printable ASCII without a space and the buffer has room. +/// A typed character is taken when it is printable ASCII without a space, a quote or a backslash (the three +/// that `security -i` reads as syntax, sec_word_ok) and the buffer has room. pub fn ky_accepts(len: u32, ch: u32) -> bool { if (len >= MAX_KEY_CHARS) { return false; } + if (ch == 34 or ch == 39 or ch == 92) { return false; } return ch > 32 and ch < 127; } @@ -558,18 +567,21 @@ test a_click_finds_its_row_and_button { } test delete_asks_twice { - assert ky_click(B_DELETE, true, true, KY_NONE, 3) == KA_ARM; - assert ky_click(B_DELETE, true, true, 3, 3) == KA_DELETE; - assert ky_click(B_DELETE, true, true, 2, 3) == KA_ARM; - assert ky_click(B_DELETE, false, true, KY_NONE, 3) == KA_NONE; + assert ky_click(B_DELETE, true, false, true, KY_NONE, 3) == KA_ARM; + assert ky_click(B_DELETE, true, false, true, 3, 3) == KA_DELETE; + assert ky_click(B_DELETE, true, false, true, 2, 3) == KA_ARM; + assert ky_click(B_DELETE, false, false, true, KY_NONE, 3) == KA_NONE; + assert ky_click(B_DELETE, true, true, true, 3, 3) == KA_DELETE; } -test add_always_edits_and_check_needs_a_key_and_a_probe { - assert ky_click(B_SET, false, false, KY_NONE, 0) == KA_EDIT; - assert ky_click(B_SET, true, true, KY_NONE, 0) == KA_EDIT; - assert ky_click(B_CHECK, true, true, KY_NONE, 0) == KA_CHECK; - assert ky_click(B_CHECK, false, true, KY_NONE, 0) == KA_NONE; - assert ky_click(B_CHECK, true, false, KY_NONE, 7) == KA_NONE; +test add_edits_until_the_provider_is_full_and_check_needs_a_key_and_a_probe { + assert ky_click(B_SET, false, false, false, KY_NONE, 0) == KA_EDIT; + assert ky_click(B_SET, true, false, true, KY_NONE, 0) == KA_EDIT; + assert ky_click(B_SET, true, true, true, KY_NONE, 0) == KA_NONE; + assert ky_click(B_CHECK, true, false, true, KY_NONE, 0) == KA_CHECK; + assert ky_click(B_CHECK, true, true, true, KY_NONE, 0) == KA_CHECK; + assert ky_click(B_CHECK, false, false, true, KY_NONE, 0) == KA_NONE; + assert ky_click(B_CHECK, true, false, false, KY_NONE, 7) == KA_NONE; } test keys_while_typing { @@ -583,6 +595,10 @@ test keys_while_typing { assert ky_accepts(0, 32) == false; assert ky_accepts(0, 10) == false; assert ky_accepts(MAX_KEY_CHARS, 65) == false; + assert ky_accepts(0, 34) == false; + assert ky_accepts(0, 39) == false; + assert ky_accepts(0, 92) == false; + assert ky_accepts(0, 45) and ky_accepts(0, 95) and ky_accepts(0, 35); } test a_pasted_text_loses_its_white_space { @@ -1758,3 +1774,709 @@ test the_zai_check_is_one_message_to_the_flash_model { assert b[0] == 123 and b[n - 1] == 125; assert probe_body(P_OPENAI, b[0..160]) == 0; } + +// ---- the Keychain through /usr/bin/security (gHashTag/t27#11241) -------------------------------------------- +// WHY. T27 OS is signed ad hoc, so every rebuild is a new code identity for macOS: a SecItemCopyMatching read +// asks for the login password again, once per item, and "Always Allow" does not survive the next build +// (measured 2026-10-20: 7 items, 7 dialogs, every load). The items were made with /usr/bin/security, so that +// tool is the application they trust; it is signed by Apple and does not change when T27 OS is rebuilt. +// keys_host.t27 therefore reaches the Keychain only by running it. Everything it needs decided is here: the +// argument list, the one line that goes to `security -i` (a key never travels on argv, where ps shows it), +// which bytes may go in that line, what an exit status means, how an output becomes a key. +// Measured on macOS 26 (2026-10-20), with commands that touch no item: +// - in `-i` mode a word ends at white space, a backslash escapes the next byte and a word that starts with a +// quote is quoted; `#`, `;`, `$`, `` ` ``, `(` and the rest are plain bytes. So a key with white space, a +// control byte, a quote or a backslash is refused (sec_word_ok); `#` stays, the account "zai#2" needs it. +// - `quit` is not a command there (it prints "unknown command" and sets the exit status to 1), so the line +// is followed by end of input, and the exit status of `security -i` is the status of its LAST command. +// - an exit status is the low byte of the OSStatus: -25300 (item not found) is 44, -25299 (duplicate) 45, +// -25293 (authorisation failed) 51, -25308 (interaction not allowed) 36, -128 (user cancelled) 128. +// - a line of 1500 bytes is read whole. +// An add never carries -U: an account that exists answers 45, so "add" cannot replace a key. + +pub const SEC_TOOL : str = "/usr/bin/security"; +pub const SEC_OP_READ : u32 = 0; +pub const SEC_OP_WRITE : u32 = 1; +pub const SEC_OP_DELETE : u32 = 2; +/// The most words an argument list holds (the tool, the command, -s, service, -a, account, -w). +pub const SEC_MAX_WORDS : u32 = 8; +/// Bytes of standard output the host reads: a key (MAX_KEY_CHARS), its newline and one more. A full buffer is +/// an overflow, never a key. +pub const SEC_OUT_CAP : u32 = 256; +/// How long the host waits for the tool before it kills it (a dialog, a stuck securityd). +pub const SEC_TIMEOUT_MS : u32 = 8000; +/// The exit code sec_exit_code gives a process that a signal ended. +pub const SEC_SIGNALED : u32 = 256; + +/// The four things a run of the tool can come to. +pub const SEC_OK : u32 = 0; +pub const SEC_NOT_FOUND : u32 = 1; +pub const SEC_DENIED : u32 = 2; +pub const SEC_FAILED : u32 = 3; + +/// The exit code a waitpid status holds: the second byte of a normal exit, SEC_SIGNALED when a signal ended +/// the process (or stopped it: the low seven bits are not zero). +pub fn sec_exit_code(ws: u32) -> u32 { + if (ws % 128 != 0) { return SEC_SIGNALED; } + return (ws / 256) % 256; +} + +/// What an exit code means: 0 done, 44 no such item, 36 / 51 / 128 the Keychain wanted the user and did not +/// get a yes (the item does not trust this tool), anything else a failure. Never a key. +pub fn sec_state(code: u32) -> u32 { + if (code == 0) { return SEC_OK; } + if (code == 44) { return SEC_NOT_FOUND; } + if (code == 36 or code == 51 or code == 128) { return SEC_DENIED; } + return SEC_FAILED; +} + +/// The state of a run: a run the host cut short (timeout, overflow) failed whatever status it left. +pub fn sec_verdict(code: u32, cut: bool) -> u32 { + if (cut) { return SEC_FAILED; } + return sec_state(code); +} + +/// A delete is done when the item is gone: deleted now, or not there to begin with. +pub fn sec_gone(state: u32) -> bool { + return state == SEC_OK or state == SEC_NOT_FOUND; +} + +/// A word that can go in the line `security -i` reads: not empty, printable ASCII without a space (white +/// space ends a word), a quote or a backslash (syntax). +pub fn sec_word_ok(w: []const u8) -> bool { + if (w.len == 0) { return false; } + var i : usize = 0; + while (i < w.len) { + if (w[i] <= 32 or w[i] >= 127) { return false; } + if (w[i] == 34 or w[i] == 39 or w[i] == 92) { return false; } + i = i + 1; + } + return true; +} + +/// A key that may be stored: key_fits (non-empty, within MAX_KEY_CHARS, printable) and one clean word. +pub fn sec_key_safe(key: []const u8) -> bool { + return key_fits(key) and sec_word_ok(key); +} + +/// Append `text` and a NUL to the packed argument words at `at`, remember where it starts as word k; the next +/// free offset, 0 when it does not fit. +fn sec_push(words: []u8, offs: []u32, k: u32, at: usize, text: []const u8) -> usize { + if (at + text.len + 1 > words.len) { return 0; } + offs[k] = @as(u32, @intCast(at)); + const n : usize = put_bytes(words, at, text); + words[n] = 0; + return n + 1; +} + +/// The argument list of one run, as NUL-ended words packed into `words`; offs[i] is where word i starts and +/// the count of words comes back (0 when the op is unknown, a name is empty or the words do not fit). +/// READ /usr/bin/security find-generic-password -s -a -w +/// DELETE /usr/bin/security delete-generic-password -s -a +/// WRITE /usr/bin/security -i (the command goes to standard input, sec_add_line) +/// The host points argv[i] at words + offs[i]; offs needs SEC_MAX_WORDS entries. +pub fn sec_argv(op: u32, service: []const u8, account: []const u8, words: []u8, offs: []u32) -> u32 { + if (offs.len < SEC_MAX_WORDS) { return 0; } + var at : usize = sec_push(words, offs, 0, 0, SEC_TOOL); + if (at == 0) { return 0; } + if (op == SEC_OP_WRITE) { + at = sec_push(words, offs, 1, at, "-i"); + if (at == 0) { return 0; } + return 2; + } + if (op != SEC_OP_READ and op != SEC_OP_DELETE) { return 0; } + if (service.len == 0 or account.len == 0) { return 0; } + if (op == SEC_OP_READ) { + at = sec_push(words, offs, 1, at, "find-generic-password"); + } else { + at = sec_push(words, offs, 1, at, "delete-generic-password"); + } + if (at == 0) { return 0; } + at = sec_push(words, offs, 2, at, "-s"); + if (at == 0) { return 0; } + at = sec_push(words, offs, 3, at, service); + if (at == 0) { return 0; } + at = sec_push(words, offs, 4, at, "-a"); + if (at == 0) { return 0; } + at = sec_push(words, offs, 5, at, account); + if (at == 0) { return 0; } + if (op == SEC_OP_DELETE) { return 6; } + at = sec_push(words, offs, 6, at, "-w"); + if (at == 0) { return 0; } + return 7; +} + +/// The one line the host writes to `security -i`, newline included, that adds `key` under service and account +/// (label "/", the label keychain_label_n gives). 0 when the key is not sec_key_safe, a name +/// is not a clean word or the line does not fit. No -U: an existing account makes the add fail (45). +pub fn sec_add_line(service: []const u8, account: []const u8, key: []const u8, out: []u8) -> usize { + if (sec_word_ok(service) == false or sec_word_ok(account) == false) { return 0; } + if (sec_key_safe(key) == false) { return 0; } + if (38 + 2 * service.len + 2 * account.len + key.len > out.len) { return 0; } + var n : usize = put_bytes(out, 0, "add-generic-password -s "); + n = put_bytes(out, n, service); + n = put_bytes(out, n, " -a "); + n = put_bytes(out, n, account); + n = put_bytes(out, n, " -l "); + n = put_bytes(out, n, service); + n = put_bytes(out, n, "/"); + n = put_bytes(out, n, account); + n = put_bytes(out, n, " -w "); + n = put_bytes(out, n, key); + out[n] = 10; + return n + 1; +} + +/// The key in what `find-generic-password -w` printed: one trailing newline off, then it must pass key_fits +/// and fit `out` (a key is never cut to fit). Its length, 0 when there is none. +pub fn sec_key_from_output(raw: []const u8, out: []u8) -> usize { + var n : usize = raw.len; + if (n > 0 and raw[n - 1] == 10) { n = n - 1; } + if (n == 0 or n > out.len) { return 0; } + if (key_fits(raw[0..n]) == false) { return 0; } + var i : usize = 0; + while (i < n) { + out[i] = raw[i]; + i = i + 1; + } + return n; +} + +/// Is word `at` of the packed words `text` followed by its NUL? +fn sec_word_is(w: []const u8, at: u32, text: []const u8) -> bool { + const s : usize = @as(usize, at); + if (s + text.len >= w.len) { return false; } + if (same_bytes(w[s .. s + text.len], text) == false) { return false; } + return w[s + text.len] == 0; +} + +test a_read_runs_find_generic_password_for_one_account { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 255; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + var a : [32]u8 = undefined; + const an : usize = keychain_account_n(P_ZAI, 1, a[0..32]); + assert an == 5; + const n : u32 = sec_argv(SEC_OP_READ, KEYCHAIN_SERVICE, a[0..an], w[0..160], o[0..8]); + assert n == 7; + assert o[0] == 0; + assert sec_word_is(w[0..160], o[0], "/usr/bin/security"); + assert sec_word_is(w[0..160], o[1], "find-generic-password"); + assert sec_word_is(w[0..160], o[2], "-s"); + assert sec_word_is(w[0..160], o[3], "ai.t27.os"); + assert sec_word_is(w[0..160], o[4], "-a"); + assert sec_word_is(w[0..160], o[5], "zai#2"); + assert sec_word_is(w[0..160], o[6], "-w"); + assert o[1] == 18; +} + +test a_delete_names_the_account_and_never_asks_for_the_secret { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 0; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + const n : u32 = sec_argv(SEC_OP_DELETE, "ai.t27.os", "zai", w[0..160], o[0..8]); + assert n == 6; + assert sec_word_is(w[0..160], o[1], "delete-generic-password"); + assert sec_word_is(w[0..160], o[3], "ai.t27.os"); + assert sec_word_is(w[0..160], o[5], "zai"); +} + +test a_write_runs_the_interactive_tool_and_puts_nothing_secret_on_argv { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 0; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + const n : u32 = sec_argv(SEC_OP_WRITE, "", "", w[0..160], o[0..8]); + assert n == 2; + assert sec_word_is(w[0..160], o[0], "/usr/bin/security"); + assert sec_word_is(w[0..160], o[1], "-i"); +} + +test an_argument_list_that_cannot_be_built_comes_back_empty { + var w : [160]u8 = undefined; + var o : [8]u32 = undefined; + var z : usize = 0; + while (z < 160) { + w[z] = 0; + z = z + 1; + } + var q : usize = 0; + while (q < 8) { + o[q] = 0; + q = q + 1; + } + assert sec_argv(SEC_OP_READ, "ai.t27.os", "", w[0..160], o[0..8]) == 0; + assert sec_argv(SEC_OP_DELETE, "", "zai", w[0..160], o[0..8]) == 0; + assert sec_argv(9, "ai.t27.os", "zai", w[0..160], o[0..8]) == 0; + assert sec_argv(SEC_OP_READ, "ai.t27.os", "zai", w[0..40], o[0..8]) == 0; + assert sec_argv(SEC_OP_READ, "ai.t27.os", "zai", w[0..160], o[0..4]) == 0; + assert sec_argv(SEC_OP_READ, "ai.t27.os", "zai", w[0..160], o[0..8]) == 7; +} + +test the_add_line_carries_the_key_to_standard_input_with_the_label_of_the_item { + var line : [320]u8 = undefined; + var z : usize = 0; + while (z < 320) { + line[z] = 0; + z = z + 1; + } + const n : usize = sec_add_line("ai.t27.os", "zai#2", "sk-fake-0000.abc_1", line[0..320]); + assert n == 84; + assert same_bytes(line[0..n], "add-generic-password -s ai.t27.os -a zai#2 -l ai.t27.os/zai#2 -w sk-fake-0000.abc_1\n"); + assert line[n - 1] == 10; + var lab : [48]u8 = undefined; + const ln : usize = keychain_label_n(P_ZAI, 1, lab[0..48]); + assert ln == 15; + assert same_bytes(line[46 .. 46 + ln], lab[0..ln]); +} + +test a_key_the_line_cannot_carry_is_refused { + var line : [320]u8 = undefined; + var z : usize = 0; + while (z < 320) { + line[z] = 0; + z = z + 1; + } + var k : [8]u8 = undefined; + k[0] = 115; + k[1] = 107; + k[2] = 45; + k[3] = 97; + k[4] = 98; + k[5] = 99; + k[6] = 100; + k[7] = 0; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 69; + k[3] = 32; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 34; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 39; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 92; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 10; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 127; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 200; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) == 0; + k[3] = 35; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..320]) > 0; + assert sec_add_line("ai.t27.os", "zai", k[0..0], line[0..320]) == 0; + assert sec_add_line("ai.t27.os", "", k[0..7], line[0..320]) == 0; + assert sec_add_line("", "zai", k[0..7], line[0..320]) == 0; + assert sec_add_line("ai.t27.os", "zai", k[0..7], line[0..40]) == 0; +} + +test a_key_is_safe_when_it_fits_and_is_one_clean_word { + assert sec_key_safe("sk-ant-api03-AbC_dEf.123"); + assert sec_key_safe("sk-or-v1-0000+/="); + assert sec_key_safe("a#b"); + assert sec_key_safe("") == false; + assert sec_key_safe("sk fake") == false; + assert sec_key_safe("sk\tfake") == false; + assert sec_word_ok("zai#2"); + assert sec_word_ok("ai.t27.os"); + assert sec_word_ok("") == false; + var big : [255]u8 = undefined; + var i : usize = 0; + while (i < 255) { + big[i] = 97; + i = i + 1; + } + assert sec_key_safe(big[0..254]); + assert sec_key_safe(big[0..255]) == false; +} + +test an_exit_status_says_what_the_tool_did { + assert sec_exit_code(0) == 0; + assert sec_exit_code(44 * 256) == 44; + assert sec_exit_code(1 * 256) == 1; + assert sec_exit_code(9) == SEC_SIGNALED; + assert sec_exit_code(127 + 19 * 256) == SEC_SIGNALED; + assert sec_state(0) == SEC_OK; + assert sec_state(44) == SEC_NOT_FOUND; + assert sec_state(36) == SEC_DENIED; + assert sec_state(51) == SEC_DENIED; + assert sec_state(128) == SEC_DENIED; + assert sec_state(1) == SEC_FAILED; + assert sec_state(45) == SEC_FAILED; + assert sec_state(161) == SEC_FAILED; + assert sec_state(SEC_SIGNALED) == SEC_FAILED; + assert sec_verdict(0, false) == SEC_OK; + assert sec_verdict(0, true) == SEC_FAILED; + assert sec_verdict(44, true) == SEC_FAILED; + assert sec_verdict(44, false) == SEC_NOT_FOUND; + assert sec_gone(SEC_OK) and sec_gone(SEC_NOT_FOUND); + assert sec_gone(SEC_DENIED) == false; + assert sec_gone(SEC_FAILED) == false; +} + +test what_the_tool_printed_becomes_a_key_only_when_it_is_one { + var out : [256]u8 = undefined; + var z : usize = 0; + while (z < 256) { + out[z] = 0; + z = z + 1; + } + assert sec_key_from_output("sk-fake-1\n", out[0..256]) == 9; + assert out[0] == 115 and out[8] == 49; + assert sec_key_from_output("sk-fake-2", out[0..256]) == 9; + assert sec_key_from_output("", out[0..256]) == 0; + assert sec_key_from_output("\n", out[0..256]) == 0; + assert sec_key_from_output("sk-a\n\n", out[0..256]) == 0; + assert sec_key_from_output("sk a\n", out[0..256]) == 0; + assert sec_key_from_output("sk-fake-1\n", out[0..4]) == 0; + assert sec_key_from_output("sk-fake-1\n", out[0..9]) == 9; + var big : [256]u8 = undefined; + var i : usize = 0; + while (i < 256) { + big[i] = 98; + i = i + 1; + } + big[254] = 10; + assert sec_key_from_output(big[0..255], out[0..256]) == 254; + big[254] = 98; + big[255] = 10; + assert sec_key_from_output(big[0..256], out[0..256]) == 0; + assert SEC_OUT_CAP == MAX_KEY_CHARS + 2; +} + +// ---- several keys per provider (owner, 2026-10-20: add several tokens to a provider so they rotate) --------- +// A provider row owns up to MAX_KEYS numbered Keychain accounts. The window keeps which numbers are stored as +// a bit mask (bit k set = key k+1 exists) and every decision below works from that mask: which number "add" +// takes (the lowest free one, never an existing one), which key "delete" removes (the newest, the highest +// number), whether "add" reads "full", what a row says after a check, which keys the server is not given. +// A check keeps one status per key, in an array indexed p * MAX_KEYS + k (key_index), so a key that failed +// is withheld alone and the others keep rotating. + +/// How many numbers the mask holds. +pub fn slot_count(mask: u32) -> u32 { + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if ((mask >> k) % 2 == 1) { c = c + 1; } + k = k + 1; + } + return c; +} + +/// Is key k (0-based) in the mask? +pub fn slot_taken(mask: u32, k: u32) -> bool { + return k < MAX_KEYS and (mask >> k) % 2 == 1; +} + +/// The number "add" stores under: the lowest free one; MAX_KEYS when all are taken ("full"). +pub fn next_free_key(mask: u32) -> u32 { + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) == false) { return k; } + k = k + 1; + } + return MAX_KEYS; +} + +/// The newest key, the one "delete" removes: the highest stored number; MAX_KEYS when none is stored. +pub fn newest_key(mask: u32) -> u32 { + var k : u32 = MAX_KEYS; + while (k > 0) { + k = k - 1; + if (slot_taken(mask, k)) { return k; } + } + return MAX_KEYS; +} + +/// The word of the "add" button (KEYS_WORDS): "add" while a number is free, "full" when none is. +pub fn ky_set_word(mask: u32) -> u32 { + if (next_free_key(mask) >= MAX_KEYS) { return 2; } + return 1; +} + +/// A scan of one provider's numbers ends after this many misses in a row (a gap of one is stepped over). Each +/// read is a run of /usr/bin/security (about 23 ms measured), so reading all 8 numbers of all 9 providers +/// costs about 1.7 s; this keeps a scan to the numbers that exist and the first miss behind them. Set it to +/// MAX_KEYS to read every number again. +pub const SCAN_GAP_STOP : u32 = 2; + +/// May a scan read number k after `misses` reads in a row that found nothing? +pub fn scan_goes_on(k: u32, misses: u32) -> bool { + return k < MAX_KEYS and misses < SCAN_GAP_STOP; +} + +/// Where the status of key k of provider p sits in the per-key arrays. +pub fn key_index(p: u32, k: u32) -> usize { + return @as(usize, p) * @as(usize, MAX_KEYS) + @as(usize, k); +} + +/// A status that came from a check: valid, invalid, rate limited (or circuit open), not stored / missing. +fn status_checked(s: u32) -> bool { + return s >= ST_VALID; +} + +/// How many of the keys in `mask` of provider p passed their check. +pub fn keys_valid(st: []const u32, p: u32, mask: u32) -> u32 { + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) and st[key_index(p, k)] == ST_VALID) { c = c + 1; } + k = k + 1; + } + return c; +} + +/// How many of the keys in `mask` of provider p have been checked. +pub fn keys_checked(st: []const u32, p: u32, mask: u32) -> u32 { + var c : u32 = 0; + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) and status_checked(st[key_index(p, k)])) { c = c + 1; } + k = k + 1; + } + return c; +} + +/// The status of a provider from its keys: valid when one key is (it can serve), stored while a key is +/// unchecked, then rate limited when one is, else invalid; missing with no key. +pub fn provider_status(st: []const u32, p: u32, mask: u32) -> u32 { + if (mask == 0) { return ST_MISSING; } + if (keys_valid(st, p, mask) > 0) { return ST_VALID; } + if (keys_checked(st, p, mask) < slot_count(mask)) { return ST_STORED; } + var k : u32 = 0; + while (k < MAX_KEYS) { + if (slot_taken(mask, k) and st[key_index(p, k)] == ST_RATE_LIMITED) { return ST_RATE_LIMITED; } + k = k + 1; + } + return ST_INVALID; +} + +/// The bad mask (bad_mask) from the keys: a provider is bad when it holds keys and none can serve. `stored[p]` +/// is the mask of provider p's numbers. +pub fn bad_mask_keys(st: []const u32, stored: []const u32) -> u32 { + var m : u32 = 0; + var p : u32 = 0; + while (p < PROVIDER_COUNT and p < stored.len) { + if (stored[p] > 0 and provider_status(st, p, stored[p]) == ST_INVALID) { m = m + (1 << p); } + p = p + 1; + } + return m; +} + +/// Is a key with this status kept from the server (so rotation skips it)? A key that answered 401 or 403. +pub fn key_withheld(s: u32) -> bool { + return s == ST_INVALID; +} + +/// What a row says next to its dot: "6/7 valid" once two or more keys were checked, else the status word. +/// `shown` is the status the row shows (status_shown: missing, circuit-open and the rest win over the count). +pub fn key_status_text(shown: u32, st: []const u32, p: u32, mask: u32, out: []u8) -> usize { + const checked : u32 = keys_checked(st, p, mask); + if (checked >= 2 and shown != ST_CIRCUIT_OPEN and shown != ST_MISSING) { + var n : usize = put_dec(out, 0, @as(u64, keys_valid(st, p, mask))); + n = put_bytes(out, n, "/"); + n = put_dec(out, n, @as(u64, checked)); + return put_bytes(out, n, " valid"); + } + return csv_field(STATUS_NAMES, shown, out); +} + +test add_takes_the_lowest_free_number_and_stops_when_full { + assert next_free_key(0) == 0; + assert next_free_key(1) == 1; + assert next_free_key(3) == 2; + assert next_free_key(5) == 1; + assert next_free_key(127) == 7; + assert next_free_key(255) == MAX_KEYS; + assert ky_set_word(0) == 1; + assert ky_set_word(127) == 1; + assert ky_set_word(255) == 2; + var w : [24]u8 = undefined; + assert csv_field(KEYS_WORDS, 1, w[0..24]) == 3; + assert w[0] == 97; + assert csv_field(KEYS_WORDS, 2, w[0..24]) == 4; + assert w[0] == 102 and w[3] == 108; +} + +test delete_removes_the_newest_key_only { + assert newest_key(0) == MAX_KEYS; + assert newest_key(1) == 0; + assert newest_key(127) == 6; + assert newest_key(255) == 7; + assert newest_key(5) == 2; + assert newest_key(2) == 1; + var mask : u32 = 127; + mask = mask - (1 << newest_key(mask)); + assert mask == 63; + assert newest_key(mask) == 5; +} + +test the_numbers_in_a_mask_are_counted { + assert slot_count(0) == 0; + assert slot_count(127) == 7; + assert slot_count(255) == 8; + assert slot_count(5) == 2; + assert slot_taken(5, 0) and slot_taken(5, 2); + assert slot_taken(5, 1) == false; + assert slot_taken(5, MAX_KEYS) == false; + assert slot_taken(65535, 9) == false; +} + +test a_scan_steps_over_one_gap_and_stops_at_two { + assert scan_goes_on(0, 0); + assert scan_goes_on(3, 1); + assert scan_goes_on(3, 2) == false; + assert scan_goes_on(MAX_KEYS, 0) == false; + assert SCAN_GAP_STOP >= 1; +} + +test a_key_has_its_own_place_among_the_statuses { + assert key_index(0, 0) == 0; + assert key_index(0, 7) == 7; + assert key_index(4, 0) == 32; + assert key_index(4, 6) == 38; + assert key_index(8, 7) == 71; +} + +test a_provider_is_valid_while_one_key_is_and_bad_when_none_can_serve { + var st : [72]u32 = undefined; + var i : usize = 0; + while (i < 72) { + st[i] = ST_STORED; + i = i + 1; + } + assert provider_status(st[0..72], P_ZAI, 0) == ST_MISSING; + assert provider_status(st[0..72], P_ZAI, 127) == ST_STORED; + st[key_index(P_ZAI, 0)] = ST_INVALID; + assert provider_status(st[0..72], P_ZAI, 127) == ST_STORED; + st[key_index(P_ZAI, 1)] = ST_VALID; + assert provider_status(st[0..72], P_ZAI, 127) == ST_VALID; + assert provider_status(st[0..72], P_ZAI, 1) == ST_INVALID; + assert provider_status(st[0..72], P_ZAI, 3) == ST_VALID; + var k : u32 = 0; + while (k < 7) { + st[key_index(P_ZAI, k)] = ST_INVALID; + k = k + 1; + } + assert provider_status(st[0..72], P_ZAI, 127) == ST_INVALID; + st[key_index(P_ZAI, 4)] = ST_RATE_LIMITED; + assert provider_status(st[0..72], P_ZAI, 127) == ST_RATE_LIMITED; + var stored : [9]u32 = undefined; + var s : usize = 0; + while (s < 9) { + stored[s] = 0; + s = s + 1; + } + stored[P_ZAI] = 127; + assert bad_mask_keys(st[0..72], stored[0..9]) == 0; + st[key_index(P_ZAI, 4)] = ST_INVALID; + assert bad_mask_keys(st[0..72], stored[0..9]) == 16; + stored[P_ANTHROPIC] = 1; + assert bad_mask_keys(st[0..72], stored[0..9]) == 16; + st[key_index(P_ANTHROPIC, 0)] = ST_INVALID; + assert bad_mask_keys(st[0..72], stored[0..9]) == 17; + st[key_index(P_ZAI, 6)] = ST_VALID; + assert bad_mask_keys(st[0..72], stored[0..9]) == 1; +} + +test only_a_key_that_failed_is_withheld_from_the_server { + assert key_withheld(ST_INVALID); + assert key_withheld(ST_VALID) == false; + assert key_withheld(ST_STORED) == false; + assert key_withheld(ST_RATE_LIMITED) == false; + assert key_withheld(ST_MISSING) == false; +} + +test a_row_after_a_check_says_how_many_keys_are_valid { + var st : [72]u32 = undefined; + var i : usize = 0; + while (i < 72) { + st[i] = ST_STORED; + i = i + 1; + } + var out : [32]u8 = undefined; + var z : usize = 0; + while (z < 32) { + out[z] = 0; + z = z + 1; + } + var k : u32 = 0; + while (k < 7) { + st[key_index(P_ZAI, k)] = ST_VALID; + k = k + 1; + } + st[key_index(P_ZAI, 3)] = ST_INVALID; + const n : usize = key_status_text(ST_VALID, st[0..72], P_ZAI, 127, out[0..32]); + assert n == 9; + assert out[0] == 54 and out[1] == 47 and out[2] == 55 and out[3] == 32; + assert out[4] == 118 and out[8] == 100; + assert key_status_text(ST_CIRCUIT_OPEN, st[0..72], P_ZAI, 127, out[0..32]) == 12; + assert out[0] == 99; + assert key_status_text(ST_MISSING, st[0..72], P_ZAI, 0, out[0..32]) == 7; + assert key_status_text(ST_STORED, st[0..72], P_ANTHROPIC, 1, out[0..32]) == 6; + assert out[0] == 115; + st[key_index(P_OPENAI, 0)] = ST_VALID; + assert key_status_text(ST_VALID, st[0..72], P_OPENAI, 1, out[0..32]) == 5; + assert out[0] == 118; + st[key_index(P_OPENAI, 1)] = ST_INVALID; + assert key_status_text(ST_VALID, st[0..72], P_OPENAI, 3, out[0..32]) == 9; + assert out[0] == 49 and out[2] == 50; +} + +test the_new_provider_status_agrees_with_the_old_one_for_a_single_key { + var st : [72]u32 = undefined; + var i : usize = 0; + while (i < 72) { + st[i] = ST_STORED; + i = i + 1; + } + var one : [9]u32 = undefined; + var j : usize = 0; + while (j < 9) { + one[j] = ST_STORED; + j = j + 1; + } + var s : u32 = ST_STORED; + while (s <= ST_RATE_LIMITED) { + st[key_index(P_GEMINI, 0)] = s; + one[P_GEMINI] = s; + assert provider_status(st[0..72], P_GEMINI, 1) == s; + var sm : [9]u32 = undefined; + var q : usize = 0; + while (q < 9) { + sm[q] = 0; + q = q + 1; + } + sm[P_GEMINI] = 1; + var bad : u32 = 0; + if (s == ST_INVALID) { bad = 4; } + assert bad_mask_keys(st[0..72], sm[0..9]) == bad; + assert bad_mask(one[0..9]) == bad; + s = s + 1; + } +} diff --git a/specs/t27os/keys_host.t27 b/specs/t27os/keys_host.t27 index 0f90dec80f..96b8bb36aa 100644 --- a/specs/t27os/keys_host.t27 +++ b/specs/t27os/keys_host.t27 @@ -1,26 +1,48 @@ // SPDX-License-Identifier: Apache-2.0 // T27 OS keys_host: where the key manager (keys.t27) touches the machine. Three jobs, no decisions: -// - the macOS Keychain: kc_put_n / kc_get_n / kc_del_n, one generic-password item per key (up to MAX_KEYS a -// provider; account = provider name, then "#2" .. "#8") under the service "ai.t27.os" -// (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete); kc_put / kc_get / kc_del are key 1. -// A key is never written to a file and never reaches the repo. +// - the macOS Keychain, one generic-password item per key (up to MAX_KEYS a provider; account = provider +// name, then "#2" .. "#8") under the service "ai.t27.os": kc_get_n / kc_put_n / kc_del_n. +// The Keychain is reached ONLY by running /usr/bin/security (posix_spawn, pipes, poll, waitpid), never +// through SecItem calls. The window is signed ad hoc, so each rebuild is a new code identity and every +// SecItem read of an item asked for the login password again; the items trust /usr/bin/security, which +// Apple signs and no rebuild changes. A key is never written to a file, never put on argv (ps shows it: +// an add feeds `security -i` one line on standard input) and never reaches the repo. // - the check button: probe() sends the cheapest authenticated request of a provider (a GET; for z.ai one one-token POST) (table: keys.t27) and // hands back the HTTP status; keys.t27 status_after_probe decides what it means. The key travels in a // request header, never in the URL. // - push_keys(): hand every stored key, with its number, to the PTY server (OP_SETKEY) so a pane it spawns // is logged in and the server can rotate over the keys of a provider. // -// The kSec* constants are global CFStringRef variables, not functions, so an @extern call cannot read them: -// dlsym finds the variable's address and sys_peek copies the pointer out. Specs holding @extern cannot run -// tests under t27c; every rule these fns follow is a tested fn in keys.t27. +// Specs holding @extern cannot run tests under t27c; every rule these fns follow is a tested fn in keys.t27 +// (argument lists, the add line, which keys are safe for it, the exit-status table, the output -> key rule). +// This file only spawns, pipes, reads and waits. // -// Refs gHashTag/t27#10782. +// Signals around a run: editors_host.t27 sets SIGCHLD to ignore (its children are never waited for), and with +// that set waitpid cannot return a child's status, so a run sets the default for its own length and puts back +// what it found; SIGPIPE is ignored while the line is written (a dead tool must not end the window). +// +// Refs gHashTag/t27#10782, gHashTag/t27#11241. module T27osKeysHost; use t27os::keys::PROVIDER_COUNT; use t27os::keys::MAX_KEYS; use t27os::keys::KEYCHAIN_SERVICE; use t27os::keys::PROBE_TIMEOUT_S; +use t27os::keys::SEC_OP_READ; +use t27os::keys::SEC_OP_WRITE; +use t27os::keys::SEC_OP_DELETE; +use t27os::keys::SEC_MAX_WORDS; +use t27os::keys::SEC_TIMEOUT_MS; +use t27os::keys::SEC_SIGNALED; +use t27os::keys::SEC_OK; +use t27os::keys::SEC_FAILED; +use t27os::keys::sec_argv; +use t27os::keys::sec_add_line; +use t27os::keys::sec_exit_code; +use t27os::keys::sec_verdict; +use t27os::keys::sec_gone; +use t27os::keys::sec_key_from_output; +use t27os::keys::scan_goes_on; use t27os::keys::keychain_account_n; use t27os::keys::setkey_byte; use t27os::keys::probe_url; @@ -32,28 +54,29 @@ use t27os::keys::probe_body; use t27os::keys::probe_needs_version; use t27os::pty_proto::get_u64; use t27os::pty_proto::MAX_FRAME; +use t27os::pty_logic::put_pollfd; use t27os::pty_sys::sys_peek; +use t27os::pty_sys::sys_environ; +use t27os::pty_sys::sys_signal; +use t27os::pty_sys::sys_read; +use t27os::pty_sys::sys_write; +use t27os::pty_sys::sys_close; +use t27os::pty_sys::sys_poll; +use t27os::pty_sys::sys_kill; +use t27os::pty_sys::sys_waitpid; use t27os::pty_client::client_set_key; -use t27os::session_store::put_bytes; pub const UTF8_ENC : u32 = 134217984; -pub const SEC_DUPLICATE : i32 = -25299; -pub const RTLD_LAZY : i32 = 1; +pub const SIGNAL_PIPE : i32 = 13; +pub const SIGNAL_CHLD : i32 = 20; +pub const HANDLER_DFL : u64 = 0; +pub const HANDLER_IGN : u64 = 1; +pub const SIGKILL_NUMBER : i32 = 9; -fn dl_open(path: u64, mode: i32) -> u64 { return @extern("dlopen"); } -fn dl_sym(h: u64, name: []const u8) -> u64 { return @extern("dlsym"); } fn addr_of(dst: []u8, src: []const u8, n: u64) -> u64 { return @extern("memmove"); } fn cf_str(alloc: u64, bytes: []const u8, n: usize, enc: u32, ext: bool) -> u64 { return @extern("CFStringCreateWithBytes"); } fn cf_data(alloc: u64, bytes: []const u8, n: u64) -> u64 { return @extern("CFDataCreate"); } -fn cf_data_len(d: u64) -> u64 { return @extern("CFDataGetLength"); } -fn cf_data_ptr(d: u64) -> u64 { return @extern("CFDataGetBytePtr"); } -fn cf_dict_new(alloc: u64, cap: u64, kcb: u64, vcb: u64) -> u64 { return @extern("CFDictionaryCreateMutable"); } -fn cf_dict_set(d: u64, k: u64, v: u64) -> void { @extern("CFDictionarySetValue"); } fn cf_release(o: u64) -> void { @extern("CFRelease"); } -fn sec_add(attrs: u64, result: u64) -> i32 { return @extern("SecItemAdd"); } -fn sec_copy(query: u64, result: []u8) -> i32 { return @extern("SecItemCopyMatching"); } -fn sec_update(query: u64, attrs: u64) -> i32 { return @extern("SecItemUpdate"); } -fn sec_delete(query: u64) -> i32 { return @extern("SecItemDelete"); } fn class_named(name: u64) -> u64 { return @extern("NSClassFromString"); } fn sel_named(name: u64) -> u64 { return @extern("NSSelectorFromString"); } fn msg0(o: u64, s: u64) -> u64 { return @extern("objc_msgSend"); } @@ -62,6 +85,16 @@ fn msg2(o: u64, s: u64, a: u64, b: u64) -> u64 { return @extern("objc_msgSend"); fn msg3(o: u64, s: u64, a: u64, b: u64, c: u64) -> u64 { return @extern("objc_msgSend"); } fn msg_f(o: u64, s: u64, t: f64) -> u64 { return @extern("objc_msgSend"); } +/// pipe(2): fds[0] is the read end, fds[1] the write end. +fn sys_pipe(fds: [2]u32) -> i32 { return @extern("pipe"); } +/// posix_spawn(3): the file actions are the address of the 8-byte posix_spawn_file_actions_t. +fn sec_spawn(pid: [2]u32, path: []const u8, actions: []u8, attr: u64, argv: [9]u64, envp: u64) -> i32 { return @extern("posix_spawn"); } +fn fa_init(fa: []u8) -> i32 { return @extern("posix_spawn_file_actions_init"); } +fn fa_destroy(fa: []u8) -> i32 { return @extern("posix_spawn_file_actions_destroy"); } +fn fa_dup2(fa: []u8, fd: i32, to: i32) -> i32 { return @extern("posix_spawn_file_actions_adddup2"); } +fn fa_close(fa: []u8, fd: i32) -> i32 { return @extern("posix_spawn_file_actions_addclose"); } +fn fa_open(fa: []u8, fd: i32, path: []const u8, flags: i32, mode: u32) -> i32 { return @extern("posix_spawn_file_actions_addopen"); } + fn cf(s: []const u8) -> u64 { return cf_str(0, s, s.len, UTF8_ENC, false); } @@ -80,157 +113,262 @@ fn sel(s: []const u8) -> u64 { return c; } -/// The value of a global CFStringRef (or CFBooleanRef) variable of the loaded frameworks. -fn global_ref(name: []const u8) -> u64 { - var z : [40]u8 = undefined; - const n : usize = put_bytes(z[0..40], 0, name); - z[n] = 0; - const h : u64 = dl_open(0, RTLD_LAZY); - const at : u64 = dl_sym(h, z[0..40]); - if (at == 0) { return 0; } - var b : [8]u8 = undefined; - sys_peek(b[0..8], at, 8); - return get_u64(b[0..8], 0); +fn close_fd(fd: u32) -> void { + sys_close(@as(i32, @intCast(fd))); } -/// The address of a global struct (the dictionary callbacks): dlsym's answer itself. -fn global_addr(name: []const u8) -> u64 { - var z : [40]u8 = undefined; - const n : usize = put_bytes(z[0..40], 0, name); - z[n] = 0; - return dl_sym(dl_open(0, RTLD_LAZY), z[0..40]); +fn wipe(b: []u8) -> void { + var i : usize = 0; + while (i < b.len) { + b[i] = 0; + i = i + 1; + } } -fn new_dict() -> u64 { - return cf_dict_new(0, 0, global_addr("kCFTypeDictionaryKeyCallBacks"), global_addr("kCFTypeDictionaryValueCallBacks")); +/// The body of a run of /usr/bin/security (sec_run): the argument words (keys.t27 sec_argv), `feed` to its +/// standard input (nothing when feed is empty, then /dev/null), its standard output into `out`, its standard +/// error to /dev/null. res[0] = the bytes read, res[1] = the exit code (SEC_SIGNALED when a signal ended it). +/// A tool that stays silent for `timeout` ms, or fills `out`, is killed. Returns SEC_OK / SEC_NOT_FOUND / +/// SEC_DENIED / SEC_FAILED (keys.t27 sec_verdict); a failure to start is SEC_FAILED, never a key. +fn sec_run_inner(words: []u8, offs: []const u32, argc: u32, feed: []const u8, out: []u8, res: []u32, timeout: i32) -> u32 { + res[0] = 0; + res[1] = SEC_SIGNALED; + if (argc == 0 or argc > SEC_MAX_WORDS) { return SEC_FAILED; } + var argv : [9]u64 = undefined; + var a : usize = 0; + while (a < 9) { + argv[a] = 0; + a = a + 1; + } + const base : u64 = addr_of(words, words, 0); + var w : u32 = 0; + while (w < argc) { + argv[w] = base + @as(u64, offs[w]); + w = w + 1; + } + var ev : [8]u8 = undefined; + wipe(ev[0..8]); + sys_peek(ev[0..8], sys_environ(), 8); + const envp : u64 = get_u64(ev[0..8], 0); + var po : [2]u32 = undefined; + po[0] = 0; + po[1] = 0; + var pi : [2]u32 = undefined; + pi[0] = 0; + pi[1] = 0; + const has_in : bool = feed.len > 0; + if (sys_pipe(po) != 0) { return SEC_FAILED; } + if (has_in) { + if (sys_pipe(pi) != 0) { + close_fd(po[0]); + close_fd(po[1]); + return SEC_FAILED; + } + } + // the child's descriptors: 0 = the line (or /dev/null), 1 = the output pipe, 2 = /dev/null; it closes every pipe end + var dn : [16]u8 = undefined; + wipe(dn[0..16]); + dn[0] = 47; + dn[1] = 100; + dn[2] = 101; + dn[3] = 118; + dn[4] = 47; + dn[5] = 110; + dn[6] = 117; + dn[7] = 108; + dn[8] = 108; + var fa : [8]u8 = undefined; + wipe(fa[0..8]); + var bad : bool = fa_init(fa[0..8]) != 0; + if (bad) { + close_fd(po[0]); + close_fd(po[1]); + if (has_in) { + close_fd(pi[0]); + close_fd(pi[1]); + } + return SEC_FAILED; + } + if (has_in) { + if (fa_dup2(fa[0..8], @as(i32, @intCast(pi[0])), 0) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(pi[0]))) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(pi[1]))) != 0) { bad = true; } + } else { + if (fa_open(fa[0..8], 0, dn[0..16], 0, 0) != 0) { bad = true; } + } + if (fa_dup2(fa[0..8], @as(i32, @intCast(po[1])), 1) != 0) { bad = true; } + if (fa_open(fa[0..8], 2, dn[0..16], 1, 0) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(po[0]))) != 0) { bad = true; } + if (fa_close(fa[0..8], @as(i32, @intCast(po[1]))) != 0) { bad = true; } + var pid : [2]u32 = undefined; + pid[0] = 0; + pid[1] = 0; + var rc : i32 = 1; + if (bad == false) { + rc = sec_spawn(pid, words[@as(usize, offs[0]) .. words.len], fa[0..8], 0, argv, envp); + } + fa_destroy(fa[0..8]); + close_fd(po[1]); + if (has_in) { close_fd(pi[0]); } + if (rc != 0) { + close_fd(po[0]); + if (has_in) { close_fd(pi[1]); } + return SEC_FAILED; + } + const pidv : i32 = @as(i32, @intCast(pid[0])); + // the line, in one write (it is shorter than PIPE_BUF, so it arrives whole or not at all) + var cut : bool = false; + if (has_in) { + const prev : u64 = sys_signal(SIGNAL_PIPE, HANDLER_IGN); + var off : usize = 0; + var live : bool = true; + while (live and off < feed.len) { + const wn : i64 = sys_write(@as(i32, @intCast(pi[1])), feed[off .. feed.len], @as(u64, feed.len - off)); + if (wn <= 0) { + live = false; + } else { + off = off + @as(usize, @intCast(wn)); + } + } + sys_signal(SIGNAL_PIPE, prev); + close_fd(pi[1]); + if (off < feed.len) { cut = true; } + } + // the output, until the tool closes it; a silent tool or a full buffer is cut short + var pf : [8]u8 = undefined; + wipe(pf[0..8]); + var got : usize = 0; + var spins : u32 = 0; + var more : bool = cut == false; + while (more) { + if (got >= out.len) { + cut = true; + more = false; + } else { + put_pollfd(pf[0..8], 0, po[0]); + const r : i32 = sys_poll(pf[0..8], 1, timeout); + if (r == 0) { + cut = true; + more = false; + } else if (r < 0) { + spins = spins + 1; + if (spins > 4) { + cut = true; + more = false; + } + } else { + const rn : i64 = sys_read(@as(i32, @intCast(po[0])), out[got .. out.len], @as(u64, out.len - got)); + if (rn <= 0) { + more = false; + } else { + got = got + @as(usize, @intCast(rn)); + } + } + } + } + close_fd(po[0]); + if (cut) { sys_kill(pidv, SIGKILL_NUMBER); } + var ws : [2]u32 = undefined; + ws[0] = 0; + ws[1] = 0; + var wp : i32 = sys_waitpid(pidv, ws, 0); + var tries : u32 = 0; + while (wp != pidv and tries < 4) { + wp = sys_waitpid(pidv, ws, 0); + tries = tries + 1; + } + res[0] = @as(u32, @intCast(got)); + if (wp != pidv) { return SEC_FAILED; } + res[1] = sec_exit_code(ws[0]); + return sec_verdict(res[1], cut); } -fn set_text(d: u64, key_name: []const u8, text: []const u8) -> void { - const s : u64 = cf(text); - cf_dict_set(d, global_ref(key_name), s); - cf_release(s); +/// One run of /usr/bin/security (see sec_run_inner), with SIGCHLD at its default for the run's length. +pub fn sec_run(words: []u8, offs: []const u32, argc: u32, feed: []const u8, out: []u8, res: []u32, timeout: i32) -> u32 { + const prev : u64 = sys_signal(SIGNAL_CHLD, HANDLER_DFL); + const st : u32 = sec_run_inner(words, offs, argc, feed, out, res, timeout); + sys_signal(SIGNAL_CHLD, prev); + return st; } -/// The query that names key k of provider p (k = 0 is key 1): generic password, service "ai.t27.os", account -/// = the provider name, then "#2" .. "#8" for the others (keys.t27 keychain_account_n). -fn item_query(p: u32, k: u32) -> u64 { - const d : u64 = new_dict(); - cf_dict_set(d, global_ref("kSecClass"), global_ref("kSecClassGenericPassword")); - set_text(d, "kSecAttrService", KEYCHAIN_SERVICE); +/// One Keychain operation on key k of provider p: build the argument list (keys.t27 sec_argv), run the tool +/// with `feed` on its standard input, its output in `raw`. The state of the run (SEC_*); res as in sec_run. +fn sec_op(op: u32, p: u32, k: u32, feed: []const u8, raw: []u8, res: []u32) -> u32 { + if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return SEC_FAILED; } var nm : [32]u8 = undefined; - const n : usize = keychain_account_n(p, k, nm[0..32]); - set_text(d, "kSecAttrAccount", nm[0..n]); - return d; -} - -fn set_data(d: u64, key: []const u8) -> void { - const data : u64 = cf_data(0, key, @as(u64, key.len)); - cf_dict_set(d, global_ref("kSecValueData"), data); - cf_release(data); + wipe(nm[0..32]); + const nn : usize = keychain_account_n(p, k, nm[0..32]); + var words : [160]u8 = undefined; + var offs : [8]u32 = undefined; + wipe(words[0..160]); + var z : usize = 0; + while (z < 8) { + offs[z] = 0; + z = z + 1; + } + const argc : u32 = sec_argv(op, KEYCHAIN_SERVICE, nm[0..nn], words[0..160], offs[0..8]); + if (argc == 0) { return SEC_FAILED; } + return sec_run(words[0..160], offs[0..8], argc, feed, raw, res, @as(i32, @intCast(SEC_TIMEOUT_MS))); } -/// Store key k (0-based) of provider p, replacing an earlier one. True when the Keychain accepted it. +/// Store `key` as key k (0-based) of provider p, never replacing one. True when the Keychain accepted it; +/// false when the number is taken (the tool answers 45), the key is not fit for the `security -i` line +/// (keys.t27 sec_key_safe) or the tool did not answer. The key goes in on standard input, never on argv. pub fn kc_put_n(p: u32, k: u32, key: []const u8) -> bool { if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return false; } - if (key.len == 0) { return false; } - const a : u64 = item_query(p, k); - set_data(a, key); - var st : i32 = sec_add(a, 0); - cf_release(a); - if (st == SEC_DUPLICATE) { - const q : u64 = item_query(p, k); - const u : u64 = new_dict(); - set_data(u, key); - st = sec_update(q, u); - cf_release(q); - cf_release(u); - } - return st == 0; -} - -/// Store provider p's first key. -pub fn kc_put(p: u32, key: []const u8) -> bool { - return kc_put_n(p, 0, key); + var nm : [32]u8 = undefined; + wipe(nm[0..32]); + const nn : usize = keychain_account_n(p, k, nm[0..32]); + var line : [400]u8 = undefined; + wipe(line[0..400]); + const ln : usize = sec_add_line(KEYCHAIN_SERVICE, nm[0..nn], key, line[0..400]); + if (ln == 0) { return false; } + var raw : [256]u8 = undefined; + wipe(raw[0..256]); + var res : [2]u32 = undefined; + res[0] = 0; + res[1] = 0; + const st : u32 = sec_op(SEC_OP_WRITE, p, k, line[0..ln], raw[0..256], res[0..2]); + wipe(line[0..400]); + wipe(raw[0..256]); + return st == SEC_OK; } -/// Read key k (0-based) of provider p into out; its length, 0 when none is stored. +/// Read key k (0-based) of provider p into out; its length, 0 when none is stored, the tool did not answer +/// with exit status 0, or what it printed is not a key that fits out. pub fn kc_get_n(p: u32, k: u32, out: []u8) -> usize { - if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return 0; } - const q : u64 = item_query(p, k); - cf_dict_set(q, global_ref("kSecReturnData"), global_ref("kCFBooleanTrue")); - cf_dict_set(q, global_ref("kSecMatchLimit"), global_ref("kSecMatchLimitOne")); - var res : [8]u8 = undefined; - const st : i32 = sec_copy(q, res[0..8]); - cf_release(q); - if (st != 0) { return 0; } - const d : u64 = get_u64(res[0..8], 0); - if (d == 0) { return 0; } - var n : u64 = cf_data_len(d); - if (n > @as(u64, out.len)) { n = @as(u64, out.len); } - sys_peek(out, cf_data_ptr(d), n); - cf_release(d); - return @as(usize, n); -} - -/// Read provider p's first key into out. -pub fn kc_get(p: u32, out: []u8) -> usize { - return kc_get_n(p, 0, out); + var raw : [256]u8 = undefined; + wipe(raw[0..256]); + var res : [2]u32 = undefined; + res[0] = 0; + res[1] = 0; + const st : u32 = sec_op(SEC_OP_READ, p, k, "", raw[0..256], res[0..2]); + var n : usize = 0; + if (st == SEC_OK) { n = sec_key_from_output(raw[0 .. @as(usize, res[0])], out); } + wipe(raw[0..256]); + return n; } /// Delete key k (0-based) of provider p. True when it is gone (also when none was there). pub fn kc_del_n(p: u32, k: u32) -> bool { - if (p >= PROVIDER_COUNT or k >= MAX_KEYS) { return false; } - const q : u64 = item_query(p, k); - const st : i32 = sec_delete(q); - cf_release(q); - return st == 0 or st == -25300; -} - -/// Delete provider p's first key. -pub fn kc_del(p: u32) -> bool { - return kc_del_n(p, 0); -} - -/// Delete every key of provider p (a KEYS row's DELETE removes the provider's whole set). True when all are gone. -pub fn kc_del_all(p: u32) -> bool { - var ok : bool = true; - var k : u32 = 0; - while (k < MAX_KEYS) { - if (kc_del_n(p, k) == false) { ok = false; } - k = k + 1; - } - return ok; + var raw : [256]u8 = undefined; + wipe(raw[0..256]); + var res : [2]u32 = undefined; + res[0] = 0; + res[1] = 0; + const st : u32 = sec_op(SEC_OP_DELETE, p, k, "", raw[0..256], res[0..2]); + wipe(raw[0..256]); + return sec_gone(st); } /// True when the Keychain holds a first key for provider p (reads it, then drops the copy). pub fn kc_has(p: u32) -> bool { var b : [256]u8 = undefined; - const n : usize = kc_get(p, b[0..256]); - var i : usize = 0; - while (i < 256) { - b[i] = 0; - i = i + 1; - } + const n : usize = kc_get_n(p, 0, b[0..256]); + wipe(b[0..256]); return n > 0; } -/// How many of the provider's MAX_KEYS accounts hold a key (a gap in the numbers does not stop the count). -pub fn kc_count(p: u32) -> u32 { - var b : [256]u8 = undefined; - var c : u32 = 0; - var k : u32 = 0; - while (k < MAX_KEYS) { - if (kc_get_n(p, k, b[0..256]) > 0) { c = c + 1; } - k = k + 1; - } - var i : usize = 0; - while (i < 256) { - b[i] = 0; - i = i + 1; - } - return c; -} - fn add_header(req: u64, name: []const u8, value: []const u8) -> void { const n : u64 = cf(name); const v : u64 = cf(value); @@ -284,11 +422,11 @@ pub fn probe(p: u32, key: []const u8) -> u32 { return @as(u32, @intCast(msg0(r, sel("statusCode")))); } -/// Hand every stored key to the PTY server on `fd`, each with its number; the count pushed. Every numbered -/// account of every provider is read, up to MAX_KEYS, and the number rides in the OP_SETKEY pane byte -/// (keys.t27 setkey_byte), so the server holds all of them and rotates over them. Called on connect, when -/// the KEYS tab opens and before a spawn, because a server that restarted has forgotten them. The copy of -/// each key in this stack frame is wiped when the call ends. +/// Hand every stored key to the PTY server on `fd`, each with its number; the count pushed. The numbered +/// accounts of every provider are read (a scan stops after two misses in a row, keys.t27 scan_goes_on) and the +/// number rides in the OP_SETKEY pane byte (keys.t27 setkey_byte), so the server holds all of them and +/// rotates over them. Called on connect, when the KEYS tab opens and before a spawn, because a server that +/// restarted has forgotten them. The copy of each key in this stack frame is wiped when the call ends. pub fn push_keys(fd: i32) -> u32 { var key : [256]u8 = undefined; var reply : [MAX_FRAME]u8 = undefined; @@ -296,20 +434,20 @@ pub fn push_keys(fd: i32) -> u32 { var p : u32 = 0; while (p < PROVIDER_COUNT) { var k : u32 = 0; - while (k < MAX_KEYS) { + var misses : u32 = 0; + while (scan_goes_on(k, misses)) { const n : usize = kc_get_n(p, k, key[0..256]); if (n > 0) { + misses = 0; if (client_set_key(fd, setkey_byte(p, k), key[0..n], reply[0..MAX_FRAME]) > 0) { pushed = pushed + 1; } + } else { + misses = misses + 1; } k = k + 1; } p = p + 1; } - var i : usize = 0; - while (i < 256) { - key[i] = 0; - i = i + 1; - } + wipe(key[0..256]); return pushed; } diff --git a/specs/t27os/pty_logic.t27 b/specs/t27os/pty_logic.t27 index 8e8ca12b42..7d132bf151 100644 --- a/specs/t27os/pty_logic.t27 +++ b/specs/t27os/pty_logic.t27 @@ -90,6 +90,7 @@ use t27os::pty_proto::spawn_ok; use t27os::pty_proto::socket_path; use t27os::keys::PROVIDER_COUNT; use t27os::keys::MAX_KEYS; +use t27os::keys::key_index; use t27os::keys::KEY_NEVER; use t27os::keys::PROFILE_COUNT; use t27os::keys::PROFILE_PROVIDER; @@ -446,10 +447,6 @@ pub fn c_path(out: []u8, src: []const u8) -> usize { // When the pane's screen shows a failure the key rests and the pane is respawned by keys.t27 fo_action, on the // next key of the same provider (rotate_pick_except, the failed key skipped) or on the next profile. -fn kidx(p: u32, k: u32) -> usize { - return @as(usize, p) * @as(usize, MAX_KEYS) + @as(usize, k); -} - fn pow16(n: u32) -> u64 { var m : u64 = 1; var j : u32 = 0; @@ -462,7 +459,7 @@ fn pow16(n: u32) -> u64 { /// The offset in the arena of key slot (p, k): a length byte, the key, a NUL (OP_SETKEY fills it). pub fn key_slot(p: u32, k: u32) -> usize { - return O_KEYS + kidx(p, k) * KEY_CAP; + return O_KEYS + key_index(p, k) * KEY_CAP; } /// A pane that has chosen nothing: no keys, no profile, no failures, nothing to skip, nothing waiting. @@ -493,7 +490,7 @@ pub fn keys_init(st: []u64) -> void { } pub fn key_present(st: []const u64, p: u32, k: u32) -> bool { - return st[G_KREST + kidx(p, k)] < KEY_NEVER; + return st[G_KREST + key_index(p, k)] < KEY_NEVER; } /// How many slots of provider p hold a key. @@ -521,7 +518,7 @@ pub fn keys_stored(st: []const u64) -> u32 { /// OP_SETKEY filled (present) or emptied slot (p, k). `same`: the bytes are those already held, so the key keeps /// its last-use and rest times (a window that sends every key again must not end a cooldown). pub fn key_put(st: []u64, p: u32, k: u32, present: bool, same: bool) -> void { - const ix : usize = kidx(p, k); + const ix : usize = key_index(p, k); if (present == false) { st[G_KLAST + ix] = 0; st[G_KREST + ix] = KEY_NEVER; @@ -534,23 +531,23 @@ pub fn key_put(st: []u64, p: u32, k: u32, present: bool, same: bool) -> void { /// The key of provider p a new pane takes (MAX_KEYS when it holds none); `skip` is a key not to take. pub fn key_pick(st: []const u64, p: u32, skip: u32, now_ms: u64) -> u32 { - const at : usize = kidx(p, 0); + const at : usize = key_index(p, 0); return rotate_pick_soft(MAX_KEYS, skip, st[G_KLAST + at .. G_KLAST + at + 8], st[G_KREST + at .. G_KREST + at + 8], now_ms / 1000); } /// A key of provider p that is usable right now and is not `skip` (MAX_KEYS when none): the test for a swap. pub fn key_pick_free(st: []const u64, p: u32, skip: u32, now_ms: u64) -> u32 { - const at : usize = kidx(p, 0); + const at : usize = key_index(p, 0); return rotate_pick_except(MAX_KEYS, skip, st[G_KLAST + at .. G_KLAST + at + 8], st[G_KREST + at .. G_KREST + at + 8], now_ms / 1000); } pub fn key_used(st: []u64, p: u32, k: u32, now_ms: u64) -> void { - st[G_KLAST + kidx(p, k)] = now_ms; + st[G_KLAST + key_index(p, k)] = now_ms; } /// Key (p, k) rests until `until_s`; a longer rest it already has stays, and a slot that holds no key stays empty. pub fn key_rest(st: []u64, p: u32, k: u32, until_s: u64) -> void { - const ix : usize = G_KREST + kidx(p, k); + const ix : usize = G_KREST + key_index(p, k); if (st[ix] >= KEY_NEVER) { return; } if (until_s > st[ix]) { st[ix] = until_s; } }