P2 — implement authenticated Android ZARA/1 client enrollment, reconnect and session continuity #173

Closed
opened 2026-08-22 21:55:31 +00:00 by lost-rob0t · 4 comments
lost-rob0t commented 2026-08-22 21:55:31 +00:00 (Migrated from github.com)

Parent epic: #152
Depends on: #172, #159, #133

Goal

Implement the Android transport/client layer selected by #170 using the same authenticated ZARA/1 semantics as Linux clients.

Scope

  • client key generation/storage using researched Android keystore/file boundary;
  • server public-key pinning;
  • enrollment/rotation/revocation UX and typed failure states;
  • bounded connect + hello negotiation;
  • request/reply/event correlation;
  • session/conversation selection/resume;
  • bounded exponential reconnect;
  • stale connection/session events rejected;
  • action/capability negotiation support from #159;
  • safe diagnostics with no private keys/tokens.

Tests

Correct/wrong server pin, enrolled/unknown/revoked key, daemon unavailable/restart, network loss/reconnect, app/activity recreation, duplicate replies, stale old-session events, malformed/oversized frames, request timeout/cancel, two Android client fixtures and cross-principal isolation. Use deterministic local daemon fixtures; no external network.

Acceptance

Android can authenticate to zara-server, submit/receive ordinary typed turns/events and preserve durable conversation semantics across reconnect without owning a second server runtime.

Parent epic: #152 Depends on: #172, #159, #133 ## Goal Implement the Android transport/client layer selected by #170 using the same authenticated ZARA/1 semantics as Linux clients. ## Scope - client key generation/storage using researched Android keystore/file boundary; - server public-key pinning; - enrollment/rotation/revocation UX and typed failure states; - bounded connect + hello negotiation; - request/reply/event correlation; - session/conversation selection/resume; - bounded exponential reconnect; - stale connection/session events rejected; - action/capability negotiation support from #159; - safe diagnostics with no private keys/tokens. ## Tests Correct/wrong server pin, enrolled/unknown/revoked key, daemon unavailable/restart, network loss/reconnect, app/activity recreation, duplicate replies, stale old-session events, malformed/oversized frames, request timeout/cancel, two Android client fixtures and cross-principal isolation. Use deterministic local daemon fixtures; no external network. ## Acceptance Android can authenticate to `zara-server`, submit/receive ordinary typed turns/events and preserve durable conversation semantics across reconnect without owning a second server runtime.
Owner

Dependency-prep research note for #173 (no production implementation while #133/#159 remain open): the #170 decision's phrase “Android Keystore-backed storage” needs a precise boundary.

JeroMQ 0.6.0 CURVE config requires the client long-term secret as raw 32-byte key material (or 40-char Z85) via ZMQ.Socket.setCurveSecretKey(byte[]). Android Keystore, by design, keeps AndroidKeyStore secret/private key material non-exportable and out of the application process. Therefore the CURVE private key itself cannot naively be generated as a non-exportable AndroidKeyStore key and then handed to JeroMQ.

Recommended #173 design:

  • Generate the actual CURVE keypair using the selected JeroMQ/CURVE implementation.
  • Generate a separate AES-256-GCM wrapping key in AndroidKeyStore (PURPOSE_ENCRYPT|PURPOSE_DECRYPT, GCM, no padding). Do not require per-use user authentication: the assistant/background reconnect path must be able to reconnect without a biometric prompt.
  • Immediately encrypt/wrap the raw CURVE secret with that Keystore AES key. Persist only {version, public_key, ciphertext, iv/tag} in app-private storage; keep the wrapped credential in noBackupFilesDir and/or explicit backup/data-extraction exclusions so stale device-bound ciphertext is not restored as if it were a portable credential.
  • On connect, unwrap only long enough to configure the JeroMQ socket; keep plaintext lifetime bounded and clear temporary byte arrays where the JeroMQ ownership/copy semantics permit. Never log/Z85-print the secret.
  • Store the server public-key pin as non-secret app-private state, but compare/fail closed before accepting a session.
  • Treat missing/deleted Keystore wrapping key, undecryptable ciphertext, or restored stale credential as a typed UNENROLLED/re-enrollment state, never as permission to create a replacement identity silently.
  • Rotation should be staged and rollback-safe: create+wrap the new client key, enroll/authorize its public key, atomically switch active credential only after server acceptance, then revoke/delete the old one. A crash mid-rotation must leave one known-good credential or an explicit recovery state.

Tests I want mapped into #173 before implementation: raw/Z85 secret absent from persisted files/logs/APK diagnostics; wrong/deleted wrapping key; pin mismatch; corrupt ciphertext/tag; process recreation; rotation crash boundaries; revocation; duplicate/stale sessions; backup/restore -> explicit re-enrollment; and two-client/cross-principal isolation already required by the issue.

Authoritative references checked 2026-09-05:

This keeps the selected #170 JeroMQ/CURVE architecture intact; it just makes “Keystore-backed” technically honest.

Dependency-prep research note for #173 (no production implementation while #133/#159 remain open): the #170 decision's phrase “Android Keystore-backed storage” needs a precise boundary. JeroMQ 0.6.0 CURVE config requires the client long-term secret as raw 32-byte key material (or 40-char Z85) via `ZMQ.Socket.setCurveSecretKey(byte[])`. Android Keystore, by design, keeps AndroidKeyStore secret/private key material non-exportable and out of the application process. Therefore the CURVE private key itself cannot naively be generated as a non-exportable AndroidKeyStore key and then handed to JeroMQ. Recommended #173 design: - Generate the actual CURVE keypair using the selected JeroMQ/CURVE implementation. - Generate a separate AES-256-GCM wrapping key in `AndroidKeyStore` (`PURPOSE_ENCRYPT|PURPOSE_DECRYPT`, GCM, no padding). Do not require per-use user authentication: the assistant/background reconnect path must be able to reconnect without a biometric prompt. - Immediately encrypt/wrap the raw CURVE secret with that Keystore AES key. Persist only `{version, public_key, ciphertext, iv/tag}` in app-private storage; keep the wrapped credential in `noBackupFilesDir` and/or explicit backup/data-extraction exclusions so stale device-bound ciphertext is not restored as if it were a portable credential. - On connect, unwrap only long enough to configure the JeroMQ socket; keep plaintext lifetime bounded and clear temporary byte arrays where the JeroMQ ownership/copy semantics permit. Never log/Z85-print the secret. - Store the server public-key pin as non-secret app-private state, but compare/fail closed before accepting a session. - Treat missing/deleted Keystore wrapping key, undecryptable ciphertext, or restored stale credential as a typed `UNENROLLED`/re-enrollment state, never as permission to create a replacement identity silently. - Rotation should be staged and rollback-safe: create+wrap the new client key, enroll/authorize its public key, atomically switch active credential only after server acceptance, then revoke/delete the old one. A crash mid-rotation must leave one known-good credential or an explicit recovery state. Tests I want mapped into #173 before implementation: raw/Z85 secret absent from persisted files/logs/APK diagnostics; wrong/deleted wrapping key; pin mismatch; corrupt ciphertext/tag; process recreation; rotation crash boundaries; revocation; duplicate/stale sessions; backup/restore -> explicit re-enrollment; and two-client/cross-principal isolation already required by the issue. Authoritative references checked 2026-09-05: - JeroMQ 0.6.0 `ZMQ.Socket.setCurveSecretKey(byte[])`: https://javadoc.io/static/org.zeromq/jeromq/0.6.0/org/zeromq/ZMQ.Socket.html - Android Keystore extraction model: https://developer.android.com/privacy-and-security/keystore - Android Keystore AES/GCM example/API: https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec - Backup/data-extraction controls: https://developer.android.com/identity/data/autobackup This keeps the selected #170 JeroMQ/CURVE architecture intact; it just makes “Keystore-backed” technically honest.
Owner

Core handoff for Android #173:

  • PR #322 is merged on canonical master at ceb2e13951af518cbc29df2dafabdea022baad89, so the Android credential wrapping/server-pin substrate is now available.
  • Shared device action PR #321 is rebased onto that exact master at 3c00b9c5fabe436b297b8308389da1837a05669b. Its Android APK gate and both shared-mic lanes are green; Core full tests/Nix/package are still running, so this is not merge authority yet.
  • #321’s contract is the closed Android-neutral ZARA/1 plane: bounded open_uri/open_app availability plus session-bound device.action.request -> accepted -> result/error, explicit generated action_id, deadline rejection before enqueue, terminal cancel, and terminal-before-accepted fail-closed behavior. No payload principal/device authority and no shell/import/eval primitive.
  • Current master already has deterministic reconnect guarantees Android can rely on without inventing session.resume: stable request IDs do not duplicate side effects across reconnect, conflicting ID reuse fails closed, selected durable conversation is reopened after reconnect, and queued old-session events are discarded. A new hello gets a new application session; in-flight work is not falsely represented as resumed.
  • Production secure TCP/#329 remains in PR #331 and is under fresh fuzz/chaos + full-gate verification; do not assume that draft is merged yet.

Android can continue implementation against the merged #322 substrate and the existing reconnect/conversation contract, while treating #321/#331 as pending shared dependencies until provider-acknowledged exact-head merges.

Core handoff for Android #173: - PR #322 is merged on canonical `master` at `ceb2e13951af518cbc29df2dafabdea022baad89`, so the Android credential wrapping/server-pin substrate is now available. - Shared device action PR #321 is rebased onto that exact master at `3c00b9c5fabe436b297b8308389da1837a05669b`. Its Android APK gate and both shared-mic lanes are green; Core full tests/Nix/package are still running, so this is not merge authority yet. - #321’s contract is the closed Android-neutral ZARA/1 plane: bounded `open_uri`/`open_app` availability plus session-bound `device.action.request -> accepted -> result/error`, explicit generated `action_id`, deadline rejection before enqueue, terminal cancel, and terminal-before-accepted fail-closed behavior. No payload principal/device authority and no shell/import/eval primitive. - Current master already has deterministic reconnect guarantees Android can rely on without inventing `session.resume`: stable request IDs do not duplicate side effects across reconnect, conflicting ID reuse fails closed, selected durable conversation is reopened after reconnect, and queued old-session events are discarded. A new hello gets a new application session; in-flight work is not falsely represented as resumed. - Production secure TCP/#329 remains in PR #331 and is under fresh fuzz/chaos + full-gate verification; do not assume that draft is merged yet. Android can continue implementation against the merged #322 substrate and the existing reconnect/conversation contract, while treating #321/#331 as pending shared dependencies until provider-acknowledged exact-head merges.
Owner

Core handoff update for Android #173:

  • Shared typed device capability/action lifecycle is now merged on canonical master via PR #336 at merge SHA eb6e9bac0a9fc13ca90b3779fe1d7271107d431e.
  • Exact pre-merge head 87ecf536c2347e6407155846a6c47bf8b7890e6e was fully green: full pytest, nix flake check, packaged build, Android APK/secret gate, Ubuntu shared-mic and Arch shared-mic.
  • Android may now rely on the generic closed ZARA/1 contract: capability.snapshot; session-bound device.action.request -> accepted -> result/error; server-generated action_id; deadline fail-before-enqueue; terminal cancel; stale-session rejection; no shell/import/eval authority.
  • Core security PR #331 remains draft/pending. Threat testing exposed and Core has patched a reconnect semantic bug there: a fresh authenticated hello is now rate-limited + connection-limited but no longer consumes the concurrent runtime slot held by an old in-flight turn. This directly prevents an abandoned route/turn from deadlocking Android reconnect. Treat this behavior as pending until #331 reaches exact-head green and merges.

Canonical Core master after #336: eb6e9bac0a9fc13ca90b3779fe1d7271107d431e.

Core handoff update for Android #173: - Shared typed device capability/action lifecycle is now **merged on canonical `master`** via PR #336 at merge SHA `eb6e9bac0a9fc13ca90b3779fe1d7271107d431e`. - Exact pre-merge head `87ecf536c2347e6407155846a6c47bf8b7890e6e` was fully green: full pytest, `nix flake check`, packaged build, Android APK/secret gate, Ubuntu shared-mic and Arch shared-mic. - Android may now rely on the generic closed ZARA/1 contract: `capability.snapshot`; session-bound `device.action.request -> accepted -> result/error`; server-generated `action_id`; deadline fail-before-enqueue; terminal cancel; stale-session rejection; no shell/import/eval authority. - Core security PR #331 remains draft/pending. Threat testing exposed and Core has patched a reconnect semantic bug there: a fresh authenticated `hello` is now rate-limited + connection-limited but no longer consumes the concurrent runtime slot held by an old in-flight turn. This directly prevents an abandoned route/turn from deadlocking Android reconnect. Treat this behavior as pending until #331 reaches exact-head green and merges. Canonical Core master after #336: `eb6e9bac0a9fc13ca90b3779fe1d7271107d431e`.
Owner

Core handoff: PR #331 is now merged to canonical master as 7cb047a390644c7da96cfb4620bfb8d569450fff. This removes the stock-daemon P0 secure-listener blocker: production TCP now uses the canonical authenticated CURVE/ZAP gateway with durable server identity, enrolled-client registry, live owner-local enroll/revoke/list authority, immediate revocation, and fail-closed state divergence handling. Exact head fabea77133914976bd0da2373ad9392d42a46a1e passed CI run #1138: full tests, nix flake check, packaged Zara build, Android skeleton/native-Prolog/debug-APK/secret gate, and Arch+Ubuntu shared-mic jobs. Android can now run its real stock-daemon hello/text-turn interoperability gate against merge 7cb047a….

Core handoff: PR #331 is now merged to canonical `master` as `7cb047a390644c7da96cfb4620bfb8d569450fff`. This removes the stock-daemon P0 secure-listener blocker: production TCP now uses the canonical authenticated CURVE/ZAP gateway with durable server identity, enrolled-client registry, live owner-local enroll/revoke/list authority, immediate revocation, and fail-closed state divergence handling. Exact head `fabea77133914976bd0da2373ad9392d42a46a1e` passed CI run #1138: full tests, `nix flake check`, packaged Zara build, Android skeleton/native-Prolog/debug-APK/secret gate, and Arch+Ubuntu shared-mic jobs. Android can now run its real stock-daemon hello/text-turn interoperability gate against merge `7cb047a…`.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
nsaspy/zara#173
No description provided.