Harden SSH Admission And Credential Input
This commit is contained in:
@@ -14,7 +14,7 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
|
||||
|
||||
## Development status
|
||||
|
||||
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A–8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance evidence](docs/web_administration_acceptance.md). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. **Phase 9 security hardening is in progress**, starting with 9A crash/debug build policy; host/build validation passed and target-hardware validation is pending. Production readiness is not yet established. See [security hardening](docs/security_hardening.md) for scope, operational profiles, and validation gates.
|
||||
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A–8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance evidence](docs/web_administration_acceptance.md). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. **Phase 9 security hardening is in progress**: 9A crash/debug policy and 9B SSH admission/credential handling have passed host/build checks. Hardware validation is deferred to Phase 9 as a whole. Production readiness is not yet established. See [security hardening](docs/security_hardening.md) for scope, operational profiles, and validation gates.
|
||||
|
||||
### Browser administration
|
||||
|
||||
@@ -91,6 +91,8 @@ Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with
|
||||
|
||||
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Physical flash/RAM extraction and firmware replacement remain outside the threat model even after Phase 9. Secure boot and encrypted NVS are explicitly excluded; no flash/PSRAM encryption or physical JTAG eFuse restriction is promised.
|
||||
|
||||
SSH uses separate, boot-lifetime global admission budgets for handshakes and password/signed-key checks (burst six, one refill per ten seconds), and unsigned key probes (burst twelve, one per five seconds). Reconnect, SSH restart and counter clearing do not replenish them. Rate denial closes the authenticating connection without sleeping the owner task; the three-attempt per-connection failure limit remains. These global limits can temporarily deny legitimate new SSH logins under attack and do not promise fair access or zero CPU impact. Hidden console prompts reject overlong/unsupported input instead of silently accepting a prefix; consumed SSH admin staging bytes are wiped. See [security hardening](docs/security_hardening.md#9b-ssh-admission-and-credential-handling) for exact semantics and remaining review work.
|
||||
|
||||
The Phase 9A supported build baseline requires disabled core dumps and silent panic reboot, rejecting panic print/halt/GDBstub and software debugger-aware options at compile time. Development, test, and production are operational profiles of the same build baseline, not separate PlatformIO environments. Silent panic reboot sacrifices panic backtraces/register dumps; reset-reason/boot information and ordinary status/logging can remain. This is not a general log-redaction guarantee. Treat raw flash, RAM and dumps as secret-bearing; do not export them as routine diagnostics. No retroactive dump clearing or secure-erase claim is made. See [security hardening](docs/security_hardening.md) for the pending checks and reviewed synthetic-secret debugging procedure.
|
||||
|
||||
## License
|
||||
|
||||
@@ -126,9 +126,9 @@ Cookie login/logout replaces Basic/cache. Digest-only records carry copied princ
|
||||
|
||||
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
|
||||
|
||||
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
|
||||
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers. Phase 9B adds owner-only boot-lifetime token buckets for handshake admission, password/signed-key checks and unsigned probes; reconnect/service restart/counter clear do not reset them. Rate rejection closes the authenticating connection without sleeping the owner. Global starvation remains a tradeoff; see [admission policy](../security_hardening.md#9b-ssh-admission-and-credential-handling).
|
||||
|
||||
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
|
||||
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`. A pending-result marker gates exactly-once signed-key completion/currentness; the reviewed wolfSSH version/feature profile is guarded and keyboard-interactive has an explicit rejecting callback (advertisement is not a dispatch filter). Consumed admin staging bytes and retired slots are wiped; this is not a full library-memory wipe guarantee.
|
||||
|
||||
Routing follows the authenticated role:
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtur
|
||||
|
||||
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
|
||||
|
||||
- Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
|
||||
- Files: `src/ssh_transport.{h,c}`, `src/ssh_auth_policy.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
|
||||
- Phase 9B admission: three owner-only boot-lifetime token buckets (handshakes, password/signed-key requests, unsigned probes); no restart/counter-clear reset. Explicit keyboard rejection, pending-result marker and version/feature guard preserve reviewed callback order. Consumed admin RX/accepted TX and retired slots are wiped. Tests: `tests/ssh_auth_policy/run.py`, `tests/ssh_auth_transport/run.py`, `tests/wolfssh_auth_contract/run.py` (requires installed vendor source and production compile database). [Policy/counters/limits](../security_hardening.md#9b-ssh-admission-and-credential-handling).
|
||||
- Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters; `ssh_transport_get_management_snapshot()` / `ssh_transport_manage_current()` fence lifecycle and exact session admission. `ssh_transport_replace_identity()` reserves service before task-bound security identity across stop/commit/restart, retains context until all slots retire and rejects orphan starts. Tests: `tests/ssh_management/run.py`, `tests/ssh_management/security.py`, `tests/ssh_management/runtime.py`.
|
||||
- Called by: startup, network clients, user revocation, console/local UI
|
||||
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; boot start gate requires Wi-Fi and SSH security/runtime readiness, independently of HTTPS identity readiness (verified in `main.c` after accepted legacy cleanup).
|
||||
@@ -142,6 +143,7 @@ Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtur
|
||||
- Flow: `UART0/admin SSH/browser admin -> bounded request queue -> one dispatcher -> esp_console_run()`
|
||||
- Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls.
|
||||
- Lifecycle: remote session tokens include slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
|
||||
- Hidden input: UART0 and shared remote prompts reject overflow/unsupported bytes on submit with wiped output, sticky across edits; visible editing is unchanged. Tests: `tests/hidden_input/run.py` plus console boundary regressions.
|
||||
- Constraint: one slow command or prompt serializes all administration. Admin SSH is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
|
||||
|
||||
## Wi-Fi
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
|
||||
|
||||
## Phase 9B — SSH admission / credential handling — 2026-09-15
|
||||
|
||||
- User requested continued Phase 9 work and will validate **the phase as a whole**. Do not pause between slices for target approval; all target gates remain unrun and collected in `docs/security_hardening.md`. Secure boot/encrypted NVS remain excluded; Phase 8 sign-off stays closed. Initial Git status for this slice was clean.
|
||||
- `ssh_auth_policy.{c,h}`: 72-byte owner-only boot-lifetime state, independent handshake and password/signed-key buckets (capacity6, refill1/10s), unsigned-probe bucket(capacity12, refill1/5s). No waits, allocations, per-peer maps or NVS writes. Reconnect, stop/start/rotation/counter clear do not replenish; no refunds, idle saturation/no excess credit, clock regression fails closed. Global starvation is a deliberate documented tradeoff; natural refill only after hostile traffic subsides, not fairness/zeroCPU protection.
|
||||
- `ssh_transport` gates handshake before wolfSSH allocation and credentials before database/ordinary signature work; keeps existing per-slot three-counted-attempt closure and 15s deadline. Explicit pending-result marker fences duplicate/unexpected completion. wolfSSH1.4.20 and certificates/none-disabled guard; keyboard prompt rejection callback/context prevents unregistered callback dispatch while keeping password/publickey advertisement. New aggregate admission/probe/throttle/limit/backend/method counters via `ssh counters`; `add_counter` saturates (do not generalize to unrelated direct lifecycle increments).
|
||||
- Admin RX consumed spans / TX positively accepted spans are securely wiped; pending retry and serial hot-path bytes unchanged. Whole retired slot securely wiped before generation/fd restoration. `console_input` and shared remote hidden prompts reject overflow/unsupported bytes on submit, sticky across editing; visible CLI behavior preserved; existing callers prevent prefix persistence. Input errors wipe output and return zero length.
|
||||
- Source audit verified actual pinned wolfSSH auth callback order and `SendChannelData` positive copied/consumed behavior. Tests pin `internal.c` SHA256 and execute extracted vendor parser/send functions with crypto/IO doubles plus actual compiler-feature preprocessing. Followup resolved reviewer concern about keyboard error-path one-byte write: inline buffer initialized, framed packets retain padding reserve, exact-sized protocol-identification pending state cannot reach auth, rejection purges without advancing length. This is a narrow invariant audit, not library security certification.
|
||||
- Validation: parent `pio run` PASS **94,340 B linked RAM / 1,829,925 B flash** (+144RAM/+1,360flash vs9A; not runtime reserve). Parent and independent review PASS all four new suites `ssh_auth_policy`, `ssh_auth_transport`, `wolfssh_auth_contract` (35 vendor cases+resolved feature profile), `hidden_input`; token policy UB-sanitizer trap mode passed, standard UBSan runtime absent. Related 11 command suite PASS: SSH management/security/runtime, console boundary/accounts/lifecycle, admin SSH policy, web cookie SSH/accounts, web admin transport+tickets, security build policy18. `git diff --check` PASS. No blocking review findings; no upload/erase/device operations/deps/generated assets/commit.
|
||||
- **Next:** complete remaining secret-lifetime and protocol review; inspect pinned library private-key/password-packet/buffer-growth/destruction before claiming comprehensive zeroization. Focused application audit found and fixed admin staging retention and hidden-input truncation; other checked key/candidate handoffs already wipe. TLS/SSH algorithm/certificate/header/password/KDF policy and web receive-before-throttle/challenge fairness remain review items. Dependency advisory/license review and lifecycle runbooks still planned, not performed. No need to await 9B hardware sign-off to continue.
|
||||
- Final target checklist includes real SSH clients offering several keys, bad signatures/passwords, keyboard decline, each pool/refill/restart-clear persistence, established mixed transport/USB/UART0 responsiveness and reserve measurements, hidden-input errors/CRLF timing and crash recovery. UART0 paired/delayed LF relies on next-prompt flush; host UART fake does not model timing. No real-crypto/live packet-network or target claims from extracted-function tests.
|
||||
|
||||
## Phase 9A — crash/debug baseline — 2026-09-15
|
||||
|
||||
- User requested Phase 9 and explicitly excluded secure boot and encrypted NVS. Roadmap now marks Phase 9 in progress; Phase 8 acceptance remains closed. Physical extraction/firmware replacement stay outside the threat model even after hardening; no encryption/eFuse/partition changes.
|
||||
@@ -9,7 +20,7 @@ Working memory, not an implementation timeline. Source is authoritative; begin w
|
||||
- `docs/security_hardening.md` defines shared operational profiles, secret-bearing artifact handling, evidence limits and target gates. Reserved partitions unchanged; old dump/credential copies are not erased. No generated assets/dependency changes.
|
||||
- Validation: `pio run` PASS **94,196 B linked RAM / 1,828,565 B flash**. Host policy matrix PASS17; actual generated SDK header PASS as eighteenth case. Initial host test hit read-only ccache storage; `CCACHE_DISABLE=1` rerun passed. Independent review found no actionable issues and repeated both host modes and diff check. No upload, erase, hardware validation or commit.
|
||||
- **9A target gate remains open:** synthetic-secret controlled panic, no register/UART/flash dump, reboot rather than halt, UART0/USB/network recovery and broker behavior. No test-only panic command was added to production.
|
||||
- **Next implementation: SSH cross-connection authentication throttling.** Read-only audit verified three counted attempts per slot reset on reconnect, with unsigned key probes uncounted; web already gates five verifications per fixed global 60-second window. Choose bounded monotonic admission and explicit counters without sleeping the SSH owner, preserve currentness/slot ownership and test real wolfSSH callback ordering. No auth behavior changed in 9A. Remaining zeroization/crypto/dependency/license/lifecycle audits are planned, not completed.
|
||||
- 9A changed no authentication behavior; its planned SSH follow-up is now implemented in 9B above. Whole-phase target validation remains deferred.
|
||||
|
||||
## Web popup cosmetics — 2026-09-14
|
||||
|
||||
|
||||
@@ -104,6 +104,18 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
**Consequence:** Shared remote-console slots require transport-qualified tokens and immutable owner adapters. Validate owner currentness outside console locks, then recheck identity. Owner-side HTTPD/SSH IO and generation-safe cleanup remain mandatory; session liveness checks do not cancel executing handlers. Browser-shell permissions are parsed and narrower than typed Settings. [Authentication](../web_administration.md#authentication-and-admission), [console policy](../web_administration.md#browser-shell-policy).
|
||||
|
||||
## SSH admission budgets survive service and session lifetimes
|
||||
|
||||
**Decision:** Three fixed-size owner-only token buckets independently admit handshakes, password/signed-key requests and unsigned probes. Budgets last for the boot, not the slot/service/counter epoch. Rejection closes without sleeping; no per-account/IP storage or persistent lockout is added. Signed-key completion has an explicit pending-result marker and retains authoritative principal checks. Pinned library version/feature/source-contract tests protect callback order; keyboard-interactive is explicitly rejected.
|
||||
|
||||
**Consequence:** This bounds admitted work but permits global-budget starvation; restarting SSH is not an immediate recovery override. Established streams bypass admission, while actual load/latency still needs whole-phase device evidence. Counters are observations, never enforcement state. [Policy and tests](../security_hardening.md#9b-ssh-admission-and-credential-handling).
|
||||
|
||||
## Hidden input must not accept a truncated credential
|
||||
|
||||
**Decision:** Hidden console prompts reject overflow/unsupported bytes at submit, wiping output; rejection stays sticky after edits. Submission, Backspace/Delete and Ctrl-C retain their roles. Visible CLI editing is unchanged. Consumed SSH admin RX and accepted TX spans are wiped without touching pending retry bytes; slot retirement securely wipes before restoring generation/sentinels.
|
||||
|
||||
**Consequence:** Overlong/unsupported pastes must be retried; caller errors prevent prefix persistence. Application wipes do not establish library/stack/PSRAM zeroization. The pinned wolfSSH positive-send contract is copied/consumed bytes, not peer receipt. UART0 paired-CRLF timing remains a target check. Tests: `tests/hidden_input/run.py`, `tests/ssh_auth_transport/run.py`, `tests/wolfssh_auth_contract/run.py`.
|
||||
|
||||
## Typed serial mutations share the administration dispatcher
|
||||
|
||||
**Decision:** Typed domains queue IDs to the existing serialized dispatcher, never CLI strings or secrets. One original-login slot per domain and a nonreused ID fence stale work; session/deadline checks precede canonical admission. Results are replaceable observations, not durable history/idempotency.
|
||||
|
||||
@@ -32,7 +32,7 @@ Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/
|
||||
| `user key clear <username> --force` | Delete all public keys for an account. |
|
||||
| `user recover --force` | When normal user-database initialization failed, explicitly replace only its blob with an empty database; UART0-only, refuses a healthy database. |
|
||||
|
||||
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 12–64 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may be assigned to multiple accounts but cannot be duplicated within one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
|
||||
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 12–64 printable ASCII characters. Hidden console prompts reject overflow or unsupported bytes rather than accepting a truncated/normalized prefix, even if later editing reduces the length; submit or cancel and start again. CR/LF submits, Backspace/Delete edits, and Ctrl-C cancels. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may be assigned to multiple accounts but cannot be duplicated within one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
|
||||
|
||||
Missing `user_db/database` storage is committed empty. On UART0 run `user add <username> admin`, optionally with `--generate`, to create the first administrator. There is no bootstrap command, imported shared credential, or synchronization with HTTPS material. Existing valid v1 user databases load unchanged, including previously migrated role-`user` accounts; no account is silently promoted.
|
||||
|
||||
@@ -157,6 +157,10 @@ HTTPS listens on port 443 only. Authenticate with any current user-database user
|
||||
|
||||
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. A `user` receives the broker-backed UART1 serial stream. An `admin` receives the administration shell instead, does not become a broker client, and cannot acquire a UART1 writer lease.
|
||||
|
||||
SSH admission uses global boot-lifetime token buckets: handshakes and password/signed-key requests each allow a burst of six and refill one token per ten seconds; unsigned-key probes allow twelve and refill one per five seconds. The existing three-counted-attempt failure closure remains per connection. Reconnect, `ssh stop`/`ssh start`, host-key rotation and `ssh clear-counters` do not replenish these budgets. Rate denial closes the authenticating connection, not an established stream. Restrict hostile traffic and allow natural refill; repeatedly reconnecting consumes shared capacity and can prevent other users from logging in.
|
||||
|
||||
`ssh counters` separates handshake/verification/probe admissions and rate rejections, attempt-limit closures, backend errors and rejected methods. Admitted work is not necessarily successful or completed; probes/rate denials are not completed `auth-attempts`. These counters expose no submitted credentials and clearing them does not change enforcement. Keyboard-interactive is explicitly rejected, not merely omitted from the advertised list. See [policy and validation](security_hardening.md#9b-ssh-admission-and-credential-handling).
|
||||
|
||||
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH.
|
||||
|
||||
UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix expands inline; a Tab that cannot extend an ambiguous prefix prints the matching candidates and redraws the unchanged input line instead of cycling candidates. Admin SSH additionally supports four-entry per-session command history with Up/Down, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. Its history is RAM-only, private to the session, and wiped on disconnect. Ping callbacks enqueue bounded typed results so all formatting remains on the dispatcher task.
|
||||
|
||||
+5
-5
@@ -39,7 +39,7 @@ These constraints apply across all phases:
|
||||
| 6 | Authenticated SSH serial transport | **Complete** |
|
||||
| 7 | Local display and button interface | **Complete** |
|
||||
| 8 | Role-based users and administrative access | **Complete (8D.22 accepted 2026-09-13)** |
|
||||
| 9 | Security and production hardening | **In progress (9A hardware validation pending)** |
|
||||
| 9 | Security and production hardening | **In progress (9A/9B implemented; combined phase validation deferred)** |
|
||||
| 10 | Authenticated, rollback-capable OTA | **Planned** |
|
||||
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
|
||||
| 12 | Advanced network integration | **Under evaluation** |
|
||||
@@ -209,16 +209,16 @@ Phase 8 is complete for its accepted scope. Phase 9 has started at the user's re
|
||||
|
||||
### Phase 9 — Security and production hardening
|
||||
|
||||
**In progress.** Harden network authentication, secret lifetimes, crash/debug exposure and operational maintenance. Secure boot and encrypted NVS are explicitly excluded by user preference. No eFuse, partition, at-rest encryption or dependency-upgrade changes are part of this first slice; no future flash/PSRAM encryption commitment is implied. Physical extraction and firmware replacement remain outside the threat model after Phase 9, and software debug restrictions do not imply physical JTAG fuse restrictions.
|
||||
**In progress.** Harden network authentication, secret lifetimes, crash/debug exposure and operational maintenance. Secure boot and encrypted NVS are explicitly excluded by user preference. No eFuse, partition, at-rest encryption or dependency-upgrade changes are part of 9A/9B; no future flash/PSRAM encryption commitment is implied. Physical extraction and firmware replacement remain outside the threat model after Phase 9, and software debug restrictions do not imply physical JTAG fuse restrictions.
|
||||
|
||||
Staged work:
|
||||
|
||||
1. **9A — Crash/debug build policy and operational profiles — In progress; hardware pending.** `src/security_build_policy.c` requires `CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y` and `CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y`; rejects core-dump enable/flash/UART, panic print/halt/GDBstub, runtime GDBstub and ESP/FreeRTOS debugger-aware options. `sdkconfig.defaults` makes the baseline explicit. Development/test/production use the same build baseline, not separate PlatformIO environments. Host matrix (`python3 tests/security_build_policy/run.py`) compiles the actual guard: 17 cases plus the generated-header check passed on 2026-09-15. `pio run` passed (94,196 B linked RAM / 1,828,565 B flash); target panic/recovery tests have not run. Production readiness remains pending.
|
||||
2. **Next — SSH cross-connection authentication throttle.** Current SSH has three counted authentication attempts per session, reset on reconnect; HTTPS already admits five credential verifications per 60-second fixed global window. Define bounded reconnect-resistant admission/backoff, recovery and secret-free counters; test isolation from established sessions, UART0 and USB.
|
||||
3. **Later — Secret-lifetime and protocol review.** Audit zeroization across application/mbedTLS/wolfSSL/wolfSSH allocations; review TLS/SSH algorithms, certificates, browser headers and password policy.
|
||||
2. **9B — SSH admission and credential handling — Implemented; combined target validation deferred.** Boot-lifetime, owner-only token buckets independently bound handshakes, password/signed-key requests and unsigned probes; reconnect/restart/counter clearing do not replenish them. Existing per-slot attempt limits/currentness remain. Explicit keyboard-interactive rejection, pending-signature result fencing, secret-free admission counters, consumed admin-buffer wipes and fail-closed hidden-prompt overflow/unsupported-byte handling are implemented. Four focused suites (including 35 pinned-vendor control-flow cases), 11 related regressions and `pio run` passed on 2026-09-15: 94,340 B linked RAM / 1,829,925 B flash. Global-budget starvation remains a documented tradeoff, not a solved availability problem.
|
||||
3. **Next — Complete secret-lifetime and protocol review.** Audit remaining application/library allocations, private-key and packet-buffer destruction; review TLS/SSH algorithms, certificates, browser headers, password/KDF policy and remaining web-admission exposure. 9B's focused fixes are not full zeroization or protocol certification.
|
||||
4. **Later — Maintenance and lifecycle.** Review dependency advisories and licenses without assuming pinned versions are permanently safe; document provisioning, rotation, factory reset, backup, recovery and decommissioning. OTA signing trust needs an independent policy without secure boot (Phase 10).
|
||||
|
||||
[Security hardening](security_hardening.md) defines profiles and concrete host/build/hardware gates. Silent panic reboot removes useful crash diagnostics, not ordinary reset/boot/status information or every possible log disclosure. Raw flash/RAM/dumps remain secret-bearing, not routine diagnostic exports. Existing coredump bytes are not retroactively cleared; no secure erase is claimed. Isolated synthetic-secret debug builds require explicit reviewed source-policy changes, not a provided bypass flag.
|
||||
At the user's request, hardware validation is deferred to **Phase 9 as a whole**, not required between implementation slices. [Security hardening](security_hardening.md) collects profiles, host evidence and the combined target checklist. Silent panic reboot removes useful crash diagnostics, not ordinary reset/boot/status information or every possible log disclosure. Raw flash/RAM/dumps remain secret-bearing, not routine diagnostic exports. Existing coredump bytes are not retroactively cleared; no secure erase is claimed. Isolated synthetic-secret debug builds require explicit reviewed source-policy changes, not a provided bypass flag.
|
||||
|
||||
### Phase 10 — Authenticated OTA and rollback
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Security hardening — Phase 9
|
||||
|
||||
**Status: in progress.** Phase 8 is complete at the accepted 8D.22 scope. Phase 9 starts with **9A crash/debug build policy**; host matrix and firmware build validation passed on 2026-09-15, and target-hardware validation has not run. This document records policy and procedures, not unrun passes or production certification.
|
||||
**Status: in progress.** Phase 8 is complete at the accepted 8D.22 scope. **9A crash/debug policy and 9B SSH admission/credential handling** are implemented with host/build validation. At the user's request, hardware validation is deferred to **Phase 9 as a whole**, not an approval gate between implementation slices. This document records policy and procedures, not unrun passes or production certification.
|
||||
|
||||
## Scope and threat model
|
||||
|
||||
@@ -23,6 +23,48 @@ The host matrix in `tests/security_build_policy/run.py` compiles the actual guar
|
||||
|
||||
Silent panic reboot deliberately sacrifices panic text, register dumps and backtraces for reduced crash disclosure. Reset-reason/boot information and ordinary status/logging can remain; neither silence across the full boot sequence nor general log redaction is guaranteed. A monitor exception decoder cannot reconstruct a backtrace that was never emitted.
|
||||
|
||||
## 9B SSH admission and credential handling
|
||||
|
||||
### Boot-lifetime admission budgets
|
||||
|
||||
`src/ssh_auth_policy.{c,h}` owns three independent, fixed-size token buckets. Only the SSH owner task accesses the shared 72-byte policy; no allocation, per-peer/account map, timer task or sleep is added.
|
||||
|
||||
| Admission class | Initial/maximum burst | Refill |
|
||||
|---|---:|---|
|
||||
| New SSH handshake | 6 | One token per 10 seconds |
|
||||
| Password or signed-key authentication request | 6 | One token per 10 seconds |
|
||||
| Unsigned public-key probe | 12 | One token per 5 seconds |
|
||||
|
||||
These are **burst-plus-refill limits**, not six/twelve requests in every rolling minute. All peers/accounts and both slots share each class. Idle refill stops at capacity; denials do not extend the refill deadline. Reconnects, service stop/start, identity rotation and `ssh clear-counters` do not replenish the pools. Reboot starts a new policy lifetime. Timestamp regression fails closed. No persistent account lockout or NVS write is introduced.
|
||||
|
||||
- A handshake token is taken after finding capacity but before `wolfSSH_new()`/handshake work. Full-capacity rejection takes no token; later allocation/IO failure does not refund it.
|
||||
- Password/signed-key admission precedes database verification/authorization and ordinary key signature work. Success, invalid credentials, backend errors and rejected password-change requests do not refund admission. Unsigned probes use their own pool and cannot authenticate.
|
||||
- Exhaustion shuts down/rejects the new or authenticating connection without waiting inside the owner task. Already-authenticated streams do not pass through this admission gate. The existing three-counted-attempt failure closure, two-slot bound and 15-second handshake deadline remain.
|
||||
- **Availability tradeoff:** a client can consume the handshake burst by opening/abandoning connections and race legitimate clients for each refill. Global verification/probe pools can also starve other users. This bounds admitted work, not fair access or immunity to denial of service. TCP accept/rejection work and library parsing still occur; target latency under abuse is not yet measured. Restrict network access, stop the offending traffic and allow natural refill rather than repeatedly reconnecting/restarting. UART0/USB remain independent of these pools; HTTPS keeps its separate policy.
|
||||
|
||||
### Callback and library contract
|
||||
|
||||
`src/ssh_transport.c` requires wolfSSH 1.4.20, certificates disabled and `none` authentication disabled at compile time. The reviewed parser calls ordinary-key authorization before signature verification; rejected authorizations and unsigned probes have no result callback. Password results are completed within the password callback. An explicit pending-result marker fences signed-key completion; duplicate/unexpected/closing-session results cannot promote a principal or count another completed attempt. Principal currentness is still checked at successful signature completion and route admission.
|
||||
|
||||
Advertising only password/publickey is not a dispatch filter in this wolfSSH version. An explicit rejecting keyboard-interactive prompt callback and per-slot context prevent its unregistered-callback path; it creates/sends no prompts and closes the connection. The advertised methods remain password/publickey. This does not certify every malformed-packet path in the library.
|
||||
|
||||
`tests/wolfssh_auth_contract/run.py` checks the reviewed `internal.c` SHA-256 and version, preprocesses the actual build's feature profile, and executes extracted vendor parser/send functions with narrow crypto/IO doubles. A same-version source change requires re-audit, not blindly replacing the hash. It does not replace real-client/cryptographic integration testing. The positive `SendChannelData()` return contract means the caller's accepted prefix has been copied, including its consumed-data WANT_WRITE case; it is not peer acknowledgement.
|
||||
|
||||
### Counters and secret lifetime
|
||||
|
||||
`ssh counters` adds aggregate-only diagnostics:
|
||||
|
||||
- `handshakes` / `handshake-throttled`: admitted handshake work / rate-denied connections, separate from capacity failures.
|
||||
- `verifications` / `verification-throttled`: admitted password/signed-key requests / rate-denied requests. Admission does not imply the verifier ran or completed.
|
||||
- `probes` / `probe-throttled`: admitted/denied unsigned-key lookups, not completed credential attempts.
|
||||
- `attempt-limit-closes`, `backend-errors`, `method-rejects`: three-attempt closures, database auth/authorization/currentness errors, and rejected callback-level methods (including keyboard). These are not counts of every malformed SSH packet.
|
||||
|
||||
Existing `auth-attempts`/`auth-failures` remain completed counted outcomes; rejected password changes count, unsigned probes and rate-denied requests do not. Signed-key results finalize once after authorized work. These admission/auth counters saturate at `UINT64_MAX`, contain no submitted credentials/identities, and may be cleared independently of enforcement state.
|
||||
|
||||
The transport now wipes consumed admin RX bytes, positively accepted admin TX bytes, and the full retired slot while retaining its generation. Partial/retry paths preserve pending bytes. Serial-route hot-path behavior is unchanged. This shortens application plaintext lifetime; it is not a claim that wolfSSH/wolfSSL/mbedTLS, stack or PSRAM copies are all erased.
|
||||
|
||||
Hidden UART0 and shared remote-console prompts now reject overflow or unsupported bytes on submission with a wiped output buffer and `ESP_ERR_INVALID_SIZE`, rather than accepting a truncated/normalized prefix. The failure remains sticky after Backspace/Delete. Printable ASCII, CR/LF submission, Backspace/Delete and Ctrl-C retain their defined roles; visible command-line editing is unchanged. Existing callers prevent a rejected password or confirmation from reaching persistence. For pasted passwords, exceeding 64 characters or including unsupported bytes requires a fresh attempt; the password policy itself is unchanged.
|
||||
|
||||
## Operational profiles
|
||||
|
||||
These are handling and validation profiles of the **same supported build baseline**, not separate PlatformIO environments or selectable security overrides.
|
||||
@@ -39,32 +81,49 @@ Raw flash, RAM and dumps can contain Wi-Fi passwords, private keys, password ver
|
||||
|
||||
## Validation gates
|
||||
|
||||
### Host and build — passed 2026-09-15
|
||||
### Host and build — passed 2026-09-15 (9A and 9B)
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/security_build_policy/run.py
|
||||
python3 tests/ssh_auth_policy/run.py
|
||||
python3 tests/ssh_auth_transport/run.py
|
||||
python3 tests/hidden_input/run.py
|
||||
pio run
|
||||
python3 tests/security_build_policy/run.py --sdkconfig-header .pio/build/esp32-s3-devkitc-1-n16r8/config/sdkconfig.h
|
||||
python3 tests/wolfssh_auth_contract/run.py
|
||||
```
|
||||
|
||||
Validation: 17 synthetic/default compile cases passed; the generated SDK-header check passed as the eighteenth case. `pio run` passed with 94,196 B linked RAM and 1,828,565 B flash. The initial host invocation was blocked by the compiler wrapper's read-only cache location; rerunning with `CCACHE_DISABLE=1` passed. Independent policy review and `git diff --check` passed. No upload, erase, eFuse operation or target test was performed.
|
||||
Latest 9B `pio run` passed with **94,340 B linked RAM / 1,829,925 B flash**, +144 B RAM / +1,360 B flash against 9A. This is linked size, not measured runtime headroom. All four new focused host suites passed, including 35 pinned-vendor parser/control-flow cases and actual compiler-feature preprocessing. The crash-policy generated-header matrix passed all 18 cases. Related SSH management/security/runtime, console boundary/accounts/lifecycle/policy, cookie-auth SSH/accounts and browser-admin transport/tickets regressions passed (11 commands). Independent review and `git diff --check` passed. Use `CCACHE_DISABLE=1` on host commands if the compiler wrapper's cache is read-only in a sandbox. No upload, erase, eFuse operation or target test was performed.
|
||||
|
||||
Record the revision, compiler/build outcome and effective configuration. Confirm that the matrix accepts the supported configuration, rejects each prohibited option independently, and rejects absent/disabled required settings. Confirm the normal firmware build compiles the guard. A rejected unsafe configuration is an expected negative-test result, not a firmware build pass. Neither these commands nor a successful build proves target panic behavior.
|
||||
|
||||
### Target hardware — not run; required for 9A acceptance
|
||||
### Combined Phase 9 target validation — deferred, not run
|
||||
|
||||
Retain these checks for the final phase test session; do not stop implementation for a separate 9A/9B sign-off.
|
||||
|
||||
#### Crash and recovery
|
||||
|
||||
1. On an isolated synthetic-secret target, record the tested image/configuration and capture UART0 at 115200 baud. Verify normal boot, UART0 administration, native USB UART1 access, HTTPS and SSH before fault testing.
|
||||
2. Through separately reviewed test-only fault injection, trigger a controlled panic with the supported build policy intact. Verify reboot rather than halt/debugger wait, no panic register/backtrace output and no UART core dump. Record any remaining boot/reset information; do not promise complete UART silence.
|
||||
3. Verify no new flash core dump is written using a reviewed target-side pass/fail check that does not export partition contents. Distinguish old partition contents from a new write; do not erase the partition merely to claim this test passed.
|
||||
4. After reboot, verify UART0 recovery and USB serial access, then authenticated HTTPS/SSH and broker writer/observer behavior. With network services unavailable, verify UART0 and native USB still work. Review routine status/log output using synthetic secrets; this is bounded evidence, not universal redaction proof.
|
||||
5. Record outcomes and limitations before marking 9A complete. Device flashing/fault injection requires a separately authorized hardware session; no eFuse changes, partition migration or erase is required by this policy.
|
||||
5. Record outcomes and limitations in the combined Phase 9 acceptance. Device flashing/fault injection requires a separately authorized hardware session; no eFuse changes, partition migration or erase is required by this policy.
|
||||
|
||||
#### Authentication, input and loaded isolation
|
||||
|
||||
1. On a restricted test network using synthetic credentials, exercise password and Ed25519/P-256 key login for both roles, including a client offering multiple keys. Verify unsigned probes, wrong passwords/signatures, stale-principal rejection and normal shell admission. Explicit keyboard-interactive requests must close/reject without a crash or prompt.
|
||||
2. Exhaust each admission class separately, respecting the independent budgets. For verification testing reuse admitted connections (up to the existing three-failure limit) so handshake exhaustion does not mask the verification gate. Verify counter deltas, reconnect resistance, natural refill and that successful logins also consume capacity. Unsigned probes must not increase completed `auth-attempts`.
|
||||
3. From UART0, clear counters and stop/start SSH while exhausted; observe that neither grants fresh tokens. Account for time elapsed during these operations. Do not assume that a reconnect failure indicates bad credentials. A quiet 60-second period replenishes all pools; ongoing hostile traffic can keep them depleted.
|
||||
4. Keep an established SSH serial stream and USB/browser clients active while generating bounded invalid-login/reconnect traffic. Record serial/broker drops, UART0 command latency, SSH stream responsiveness, internal/DMA minima and recovery. Do not use this admission policy to claim zero CPU impact; TCP/kernel work, KDF/signature work within budget and two-slot occupancy still matter.
|
||||
5. Test hidden credentials at maximum length and one byte over, different suffixes past the limit, unsupported input bytes, overflow followed by editing, Ctrl-C, disconnect and confirmation failure on UART0 and remote administration. No rejected prefix may be persisted or echoed. Check both CR/LF behavior, including delayed UART0 LF delivery: the current UART0 reader relies on next-prompt input flushing, unlike the remote reader's explicit paired-LF handling; host fakes do not prove device timing.
|
||||
6. Exercise generated-password delivery with slow/partial remote output and short subsequent commands, then disconnect/reconnect. Application-buffer wipe assertions are host evidence; do not export live RAM to establish a device pass.
|
||||
|
||||
## Staged next work
|
||||
|
||||
- **Next: bounded SSH cross-connection throttling.** `src/ssh_transport.c` currently closes after three counted failed authentication attempts in a session; slot reset/reconnect resets the budget. Not every protocol message is counted (for example, an unsigned public-key probe is not a failed signed authentication). `src/web_cookie_auth.c` already limits credential verifications to five per 60-second fixed global window, shared across clients; this is not a sliding window or persistent account lockout, and auth lifecycle restart resets its state. Define reconnect-resistant SSH admission/backoff, bounded state and secret-free counters without blocking established sessions, UART0 or USB.
|
||||
- **Later: zeroization and protocol policy.** Audit secret lifetimes and failure cleanup across application, mbedTLS, wolfSSL and wolfSSH; review crypto algorithms, certificate trust, browser security headers and password policy. Existing wipes are not proof that every library/stack/PSRAM copy is cleared.
|
||||
- **Next: complete the secret-lifetime and protocol review.** 9B fixes confirmed admin-buffer retention and hidden-input truncation, but does not complete the library-allocation audit. Inspect pinned private-key import/destruction, password-packet storage and buffer growth/free paths in mbedTLS/wolfSSL/wolfSSH before claiming full zeroization. Review negotiated TLS/SSH algorithms, certificate trust/validity, browser security headers and password/KDF policy without casually changing shared crypto or persisted identities.
|
||||
- **Web admission review remains separate.** `src/web_cookie_auth.c` still limits credential verifications to five per 60-second fixed global window, shared across clients. This is not a sliding window or persistent account lockout; auth lifecycle restart resets it. Challenge starvation/global-budget starvation and the receive-before-throttle path remain review items, not changes delivered by 9B.
|
||||
- **Later: maintenance and lifecycle.** Review ESP-IDF/wolfSSL/wolfSSH advisories and dependency licenses, then plan any upgrades separately. Complete provisioning, key rotation, backup, factory reset, recovery and decommissioning runbooks without claiming physical-extraction resistance or secure erasure.
|
||||
- **Phase 10: OTA trust.** Define independent image-signature verification, trust-anchor provisioning, rotation/revocation, rollback/downgrade and recovery policy without secure boot. Authenticated transport alone is not image-signing policy, and OTA signature checks cannot prevent physical firmware replacement.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ idf_component_register(
|
||||
"admin_command_gate.c"
|
||||
"admin_ssh_console.c"
|
||||
"ssh_transport.c"
|
||||
"ssh_auth_policy.c"
|
||||
"ssh_console.c"
|
||||
"usb_cdc_transport.c"
|
||||
"usb_console.c"
|
||||
|
||||
+15
-3
@@ -69,6 +69,7 @@ typedef struct {
|
||||
bool discard_next_lf;
|
||||
admin_prompt_state_t prompt_state;
|
||||
bool prompt_hidden;
|
||||
bool prompt_rejected;
|
||||
size_t prompt_capacity;
|
||||
size_t prompt_length;
|
||||
uint8_t prompt_input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
@@ -385,6 +386,7 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
session->prompt_length = 0U;
|
||||
session->prompt_capacity = capacity;
|
||||
session->prompt_hidden = hidden;
|
||||
session->prompt_rejected = false;
|
||||
session->prompt_state = ADMIN_PROMPT_WAITING;
|
||||
bool published = append_output_locked(session, (const uint8_t *)prompt, strlen(prompt));
|
||||
if (!published) {
|
||||
@@ -413,14 +415,19 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
if (!current || !session->active || session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
|
||||
result = ESP_ERR_NOT_FOUND;
|
||||
} else if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
|
||||
memcpy(output, session->prompt_input, session->prompt_length);
|
||||
*output_length = session->prompt_length;
|
||||
result = ESP_OK;
|
||||
if (session->prompt_rejected) {
|
||||
result = ESP_ERR_INVALID_SIZE;
|
||||
} else {
|
||||
memcpy(output, session->prompt_input, session->prompt_length);
|
||||
*output_length = session->prompt_length;
|
||||
result = ESP_OK;
|
||||
}
|
||||
}
|
||||
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
|
||||
session->prompt_length = 0U;
|
||||
session->prompt_capacity = 0U;
|
||||
session->prompt_hidden = false;
|
||||
session->prompt_rejected = false;
|
||||
session->prompt_state = ADMIN_PROMPT_NONE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return result;
|
||||
@@ -1209,9 +1216,14 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
if (!session->prompt_hidden) {
|
||||
(void)append_output_locked(session, &value, 1U);
|
||||
}
|
||||
} else if (session->prompt_hidden) {
|
||||
session->prompt_rejected = true;
|
||||
} else {
|
||||
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
|
||||
}
|
||||
} else if (session->prompt_hidden) {
|
||||
/* Do not silently normalize unrepresentable credential bytes. */
|
||||
session->prompt_rejected = true;
|
||||
}
|
||||
}
|
||||
++*consumed;
|
||||
|
||||
@@ -43,6 +43,7 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
|
||||
return error;
|
||||
}
|
||||
|
||||
bool rejected = false;
|
||||
for (;;) {
|
||||
uint8_t byte = 0U;
|
||||
if (uart_read_bytes(CONSOLE_INPUT_UART, &byte, 1U, portMAX_DELAY) != 1) {
|
||||
@@ -71,6 +72,11 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
|
||||
continue;
|
||||
}
|
||||
if (byte < 0x20U || byte > 0x7eU || *output_length >= capacity - 1U) {
|
||||
/* Hidden credentials must never accept a truncated/normalized prefix. */
|
||||
if (hidden) {
|
||||
rejected = true;
|
||||
continue;
|
||||
}
|
||||
putchar('\a');
|
||||
fflush(stdout);
|
||||
continue;
|
||||
@@ -82,6 +88,11 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
|
||||
}
|
||||
}
|
||||
putchar('\n');
|
||||
if (rejected) {
|
||||
secure_wipe(output, capacity);
|
||||
*output_length = 0U;
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/* Hidden input accepts printable ASCII with CR/LF submit, BS/DEL editing and
|
||||
* Ctrl-C cancellation. Overflow or any other byte rejects the entire prompt on
|
||||
* submit (ESP_ERR_INVALID_SIZE), even after editing; rejected input is wiped.
|
||||
* capacity includes the trailing NUL. Visible line editing is unchanged. */
|
||||
esp_err_t console_input_read_hidden(const char *prompt,
|
||||
uint8_t *output, size_t capacity,
|
||||
size_t minimum_length, size_t maximum_length,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "ssh_auth_policy.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
bool ssh_auth_policy_admit(ssh_auth_policy_t *policy,
|
||||
ssh_auth_policy_kind_t kind, int64_t now_us)
|
||||
{
|
||||
if (policy == NULL || (unsigned)kind >= SSH_AUTH_POLICY_KIND_COUNT || now_us < 0) {
|
||||
return false;
|
||||
}
|
||||
for (unsigned i = 0; i < SSH_AUTH_POLICY_KIND_COUNT; ++i) {
|
||||
if (policy->buckets[i].initialized && now_us < policy->buckets[i].last_seen_us) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const unsigned capacity = kind == SSH_AUTH_POLICY_PROBE
|
||||
? SSH_AUTH_POLICY_PROBE_CAPACITY
|
||||
: kind == SSH_AUTH_POLICY_HANDSHAKE
|
||||
? SSH_AUTH_POLICY_HANDSHAKE_CAPACITY : SSH_AUTH_POLICY_VERIFICATION_CAPACITY;
|
||||
const int64_t interval = kind == SSH_AUTH_POLICY_PROBE
|
||||
? SSH_AUTH_POLICY_PROBE_REFILL_US
|
||||
: kind == SSH_AUTH_POLICY_HANDSHAKE
|
||||
? SSH_AUTH_POLICY_HANDSHAKE_REFILL_US : SSH_AUTH_POLICY_VERIFICATION_REFILL_US;
|
||||
ssh_auth_policy_bucket_t *bucket = &policy->buckets[kind];
|
||||
if (!bucket->initialized) {
|
||||
bucket->tokens = (uint8_t)capacity;
|
||||
bucket->refill_us = now_us;
|
||||
bucket->initialized = true;
|
||||
} else {
|
||||
/* Both timestamps are nonnegative and ordered. Divide before adding
|
||||
* to avoid overflow even for a jump from zero to INT64_MAX. */
|
||||
const int64_t elapsed = now_us - bucket->refill_us;
|
||||
const int64_t earned = elapsed / interval;
|
||||
if (earned >= (int64_t)(capacity - bucket->tokens)) {
|
||||
bucket->tokens = (uint8_t)capacity;
|
||||
bucket->refill_us = now_us;
|
||||
} else {
|
||||
bucket->tokens += (uint8_t)earned;
|
||||
bucket->refill_us = now_us - elapsed % interval;
|
||||
}
|
||||
}
|
||||
bucket->last_seen_us = now_us;
|
||||
if (bucket->tokens == 0) {
|
||||
return false;
|
||||
}
|
||||
--bucket->tokens;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define SSH_AUTH_POLICY_HANDSHAKE_CAPACITY 6U
|
||||
#define SSH_AUTH_POLICY_HANDSHAKE_REFILL_US INT64_C(10000000)
|
||||
#define SSH_AUTH_POLICY_VERIFICATION_CAPACITY 6U
|
||||
#define SSH_AUTH_POLICY_VERIFICATION_REFILL_US INT64_C(10000000)
|
||||
#define SSH_AUTH_POLICY_PROBE_CAPACITY 12U
|
||||
#define SSH_AUTH_POLICY_PROBE_REFILL_US INT64_C(5000000)
|
||||
|
||||
typedef enum {
|
||||
SSH_AUTH_POLICY_HANDSHAKE,
|
||||
SSH_AUTH_POLICY_VERIFICATION,
|
||||
SSH_AUTH_POLICY_PROBE,
|
||||
SSH_AUTH_POLICY_KIND_COUNT
|
||||
} ssh_auth_policy_kind_t;
|
||||
|
||||
typedef struct {
|
||||
int64_t refill_us;
|
||||
int64_t last_seen_us;
|
||||
uint8_t tokens;
|
||||
bool initialized;
|
||||
} ssh_auth_policy_bucket_t;
|
||||
|
||||
typedef struct {
|
||||
ssh_auth_policy_bucket_t buckets[SSH_AUTH_POLICY_KIND_COUNT];
|
||||
} ssh_auth_policy_t;
|
||||
|
||||
/* Single-owner only: no allocation, locks, clock reads, timers or sleeps.
|
||||
* Start zero-initialized; each class lazily starts at capacity. The owner must
|
||||
* retain one shared instance across sessions, stop/start and counter clears;
|
||||
* only reboot resets it. Do not modify fields directly or refund admissions.
|
||||
* Each true result consumes one token, regardless of subsequent auth outcome.
|
||||
* Refill adds one token per class-specific interval, preserving partial credit
|
||||
* below capacity and discarding all surplus (including fractions) at capacity.
|
||||
* now_us must be nonnegative and nondecreasing across ALL classes (equal is OK).
|
||||
* NULL, invalid kind, negative time and regression reject without mutation.
|
||||
* An empty-bucket denial records time but never postpones the refill deadline.
|
||||
*/
|
||||
bool ssh_auth_policy_admit(ssh_auth_policy_t *policy,
|
||||
ssh_auth_policy_kind_t kind, int64_t now_us);
|
||||
@@ -138,6 +138,16 @@ static int show_counters(void)
|
||||
counter->handshake_successes, counter->handshake_failures,
|
||||
counter->handshake_timeouts, counter->authentication_attempts,
|
||||
counter->authentication_failures, counter->request_rejections);
|
||||
printf("Auth admission: handshakes=%" PRIu64 " handshake-throttled=%" PRIu64
|
||||
" verifications=%" PRIu64 " verification-throttled=%" PRIu64 "\n",
|
||||
counter->handshake_admissions, counter->handshake_throttle_rejections,
|
||||
counter->authentication_admissions, counter->authentication_throttle_rejections);
|
||||
printf("Auth policy: probes=%" PRIu64 " probe-throttled=%" PRIu64
|
||||
" attempt-limit-closes=%" PRIu64 " backend-errors=%" PRIu64
|
||||
" method-rejects=%" PRIu64 "\n",
|
||||
counter->authentication_probe_admissions, counter->authentication_probe_rejections,
|
||||
counter->authentication_limit_disconnects, counter->authentication_backend_errors,
|
||||
counter->authentication_method_rejections);
|
||||
printf("Broker: connect=%" PRIu64 " failures=%" PRIu64
|
||||
" disconnect=%" PRIu64 " writer-requests=%" PRIu64
|
||||
" grants=%" PRIu64 " denials=%" PRIu64
|
||||
|
||||
+115
-17
@@ -24,10 +24,21 @@
|
||||
#include "secure_random.h"
|
||||
#include "serial_service.h"
|
||||
#include "ssh_security.h"
|
||||
#include "ssh_auth_policy.h"
|
||||
#include "user_database.h"
|
||||
#include <wolfssl/wolfcrypt/memory.h>
|
||||
#include <wolfssl/wolfcrypt/random.h>
|
||||
#include <wolfssh/ssh.h>
|
||||
#include <wolfssh/version.h>
|
||||
|
||||
/* Admission precedes signature work; result callbacks only follow authorized
|
||||
* signed keys. Re-audit the parser/callback contract when upgrading wolfSSH. */
|
||||
#if LIBWOLFSSH_VERSION_HEX != 0x01004020
|
||||
#error "Re-audit SSH authentication callback ordering for this wolfSSH version"
|
||||
#endif
|
||||
#if defined(WOLFSSH_CERTS) || defined(WOLFSSH_ALLOW_USERAUTH_NONE)
|
||||
#error "SSH admission policy requires certificate and none authentication disabled"
|
||||
#endif
|
||||
|
||||
#if defined(CONFIG_MBEDTLS_HARDWARE_AES) && CONFIG_MBEDTLS_HARDWARE_AES
|
||||
#error "Concurrent mbedTLS/wolfSSH operation requires mbedTLS software AES"
|
||||
@@ -64,6 +75,7 @@ typedef struct {
|
||||
user_principal_t pending_principal;
|
||||
bool principal_valid;
|
||||
bool pending_principal_valid;
|
||||
bool awaiting_auth_result;
|
||||
bool authenticated;
|
||||
bool shell_requested;
|
||||
uint8_t console_slot_index;
|
||||
@@ -110,11 +122,14 @@ static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
|
||||
/* Owned exclusively by the transport task. */
|
||||
static WOLFSSH_CTX *s_context;
|
||||
static int s_listen_fd = -1;
|
||||
/* Boot-lifetime admission state: neither service restart nor counter clear
|
||||
* replenishes it. Only the SSH owner accesses this fixed-size policy. */
|
||||
static ssh_auth_policy_t s_auth_policy;
|
||||
|
||||
static void add_counter(uint64_t *counter, uint64_t value)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*counter += value;
|
||||
*counter = value > UINT64_MAX - *counter ? UINT64_MAX : *counter + value;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
@@ -384,9 +399,52 @@ static void clear_pending_principal(ssh_slot_t *slot)
|
||||
if (slot != NULL) {
|
||||
secure_wipe(&slot->pending_principal, sizeof(slot->pending_principal));
|
||||
slot->pending_principal_valid = false;
|
||||
slot->awaiting_auth_result = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void close_authentication(ssh_slot_t *slot)
|
||||
{
|
||||
if (slot != NULL && !slot->close_requested) {
|
||||
slot->close_requested = true;
|
||||
if (slot->socket_fd >= 0) {
|
||||
(void)shutdown(slot->socket_fd, SHUT_RDWR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool admit_authentication(ssh_slot_t *slot, bool probe)
|
||||
{
|
||||
ssh_auth_policy_kind_t kind = probe ? SSH_AUTH_POLICY_PROBE
|
||||
: SSH_AUTH_POLICY_VERIFICATION;
|
||||
if (!ssh_auth_policy_admit(&s_auth_policy, kind, esp_timer_get_time())) {
|
||||
add_counter(probe ? &s_counters.authentication_probe_rejections
|
||||
: &s_counters.authentication_throttle_rejections, 1U);
|
||||
clear_pending_principal(slot);
|
||||
close_authentication(slot);
|
||||
return false;
|
||||
}
|
||||
add_counter(probe ? &s_counters.authentication_probe_admissions
|
||||
: &s_counters.authentication_admissions, 1U);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Advertisement is not a dispatch filter in wolfSSH 1.4.20. Supply a rejecting
|
||||
* callback so a direct keyboard-interactive request cannot call through NULL. */
|
||||
static int reject_keyboard_auth(WS_UserAuthData_Keyboard *keyboard, void *context)
|
||||
{
|
||||
if (keyboard != NULL) {
|
||||
secure_wipe(keyboard, sizeof(*keyboard));
|
||||
}
|
||||
ssh_slot_t *slot = (ssh_slot_t *)context;
|
||||
if (slot != NULL && !slot->close_requested) {
|
||||
add_counter(&s_counters.authentication_method_rejections, 1U);
|
||||
}
|
||||
clear_pending_principal(slot);
|
||||
close_authentication(slot);
|
||||
return WS_ERROR;
|
||||
}
|
||||
|
||||
static bool complete_authentication_attempt(ssh_slot_t *slot, bool failed)
|
||||
{
|
||||
add_counter(&s_counters.authentication_attempts, 1U);
|
||||
@@ -401,10 +459,8 @@ static bool complete_authentication_attempt(ssh_slot_t *slot, bool failed)
|
||||
return true;
|
||||
}
|
||||
|
||||
slot->close_requested = true;
|
||||
if (slot->socket_fd >= 0) {
|
||||
(void)shutdown(slot->socket_fd, SHUT_RDWR);
|
||||
}
|
||||
add_counter(&s_counters.authentication_limit_disconnects, 1U);
|
||||
close_authentication(slot);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -430,10 +486,14 @@ static int authenticate_password(ssh_slot_t *slot,
|
||||
slot->principal = principal;
|
||||
slot->principal_valid = true;
|
||||
slot->authenticated = true;
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
return WOLFSSH_USERAUTH_SUCCESS;
|
||||
}
|
||||
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (error != ESP_OK) {
|
||||
add_counter(&s_counters.authentication_backend_errors, 1U);
|
||||
}
|
||||
bool retry = complete_authentication_attempt(slot, true);
|
||||
if (!retry) {
|
||||
return WOLFSSH_USERAUTH_REJECTED;
|
||||
@@ -463,6 +523,9 @@ static int authenticate_public_key(ssh_slot_t *slot,
|
||||
|
||||
if (error != ESP_OK || !authorized) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (error != ESP_OK) {
|
||||
add_counter(&s_counters.authentication_backend_errors, 1U);
|
||||
}
|
||||
if (public_key->hasSignature == 0U) {
|
||||
return error == ESP_OK ? WOLFSSH_USERAUTH_INVALID_PUBLICKEY
|
||||
: WOLFSSH_USERAUTH_FAILURE;
|
||||
@@ -478,6 +541,7 @@ static int authenticate_public_key(ssh_slot_t *slot,
|
||||
if (public_key->hasSignature != 0U) {
|
||||
slot->pending_principal = principal;
|
||||
slot->pending_principal_valid = true;
|
||||
slot->awaiting_auth_result = true;
|
||||
}
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
return WOLFSSH_USERAUTH_SUCCESS;
|
||||
@@ -488,20 +552,29 @@ static int authenticate_user(byte authentication_type,
|
||||
void *context)
|
||||
{
|
||||
ssh_slot_t *slot = (ssh_slot_t *)context;
|
||||
if (slot == NULL || authentication == NULL ||
|
||||
authentication_type != authentication->type) {
|
||||
if (slot == NULL || slot->state != SSH_TRANSPORT_SESSION_HANDSHAKE ||
|
||||
slot->close_requested || slot->authenticated || slot->awaiting_auth_result) {
|
||||
clear_pending_principal(slot);
|
||||
return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
|
||||
close_authentication(slot);
|
||||
return WOLFSSH_USERAUTH_REJECTED;
|
||||
}
|
||||
if (authentication_type == WOLFSSH_USERAUTH_PASSWORD) {
|
||||
return authenticate_password(slot, authentication);
|
||||
}
|
||||
if (authentication_type == WOLFSSH_USERAUTH_PUBLICKEY) {
|
||||
return authenticate_public_key(slot, authentication);
|
||||
if (authentication == NULL || authentication_type != authentication->type ||
|
||||
(authentication_type != WOLFSSH_USERAUTH_PASSWORD &&
|
||||
authentication_type != WOLFSSH_USERAUTH_PUBLICKEY)) {
|
||||
add_counter(&s_counters.authentication_method_rejections, 1U);
|
||||
clear_pending_principal(slot);
|
||||
close_authentication(slot);
|
||||
return WOLFSSH_USERAUTH_REJECTED;
|
||||
}
|
||||
|
||||
clear_pending_principal(slot);
|
||||
return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
|
||||
bool probe = authentication_type == WOLFSSH_USERAUTH_PUBLICKEY &&
|
||||
authentication->sf.publicKey.hasSignature == 0U;
|
||||
if (!admit_authentication(slot, probe)) {
|
||||
return WOLFSSH_USERAUTH_REJECTED;
|
||||
}
|
||||
return authentication_type == WOLFSSH_USERAUTH_PASSWORD
|
||||
? authenticate_password(slot, authentication)
|
||||
: authenticate_public_key(slot, authentication);
|
||||
}
|
||||
|
||||
static int authentication_result(byte result, WS_UserAuthData *authentication,
|
||||
@@ -510,10 +583,17 @@ static int authentication_result(byte result, WS_UserAuthData *authentication,
|
||||
ssh_slot_t *slot = (ssh_slot_t *)context;
|
||||
if (slot == NULL || authentication == NULL ||
|
||||
authentication->type != WOLFSSH_USERAUTH_PUBLICKEY ||
|
||||
authentication->sf.publicKey.hasSignature == 0U) {
|
||||
authentication->sf.publicKey.hasSignature == 0U ||
|
||||
slot->state != SSH_TRANSPORT_SESSION_HANDSHAKE ||
|
||||
slot->close_requested || slot->authenticated ||
|
||||
!slot->awaiting_auth_result || !slot->pending_principal_valid) {
|
||||
clear_pending_principal(slot);
|
||||
close_authentication(slot);
|
||||
return WS_ERROR;
|
||||
}
|
||||
/* The authorization callback already consumed the verification token.
|
||||
* Take the completion marker before any result/currentness processing. */
|
||||
slot->awaiting_auth_result = false;
|
||||
|
||||
if (result != WOLFSSH_USERAUTH_SUCCESS) {
|
||||
(void)complete_authentication_attempt(slot, true);
|
||||
@@ -527,6 +607,9 @@ static int authentication_result(byte result, WS_UserAuthData *authentication,
|
||||
&slot->pending_principal, ¤t)
|
||||
: ESP_ERR_INVALID_STATE;
|
||||
if (error != ESP_OK || !current) {
|
||||
if (error != ESP_OK) {
|
||||
add_counter(&s_counters.authentication_backend_errors, 1U);
|
||||
}
|
||||
(void)complete_authentication_attempt(slot, true);
|
||||
clear_pending_principal(slot);
|
||||
return WS_ERROR;
|
||||
@@ -606,7 +689,7 @@ static bool cleanup_slot(ssh_slot_t *slot)
|
||||
}
|
||||
|
||||
uint32_t generation = slot->generation;
|
||||
memset(slot, 0, sizeof(*slot));
|
||||
secure_wipe(slot, sizeof(*slot));
|
||||
slot->state = SSH_TRANSPORT_SESSION_FREE;
|
||||
slot->generation = generation;
|
||||
slot->socket_fd = -1;
|
||||
@@ -662,6 +745,7 @@ static esp_err_t create_context(void)
|
||||
wolfSSH_SetUserAuth(context, authenticate_user);
|
||||
wolfSSH_SetUserAuthTypes(context, allowed_auth_types);
|
||||
wolfSSH_SetUserAuthResult(context, authentication_result);
|
||||
wolfSSH_SetKeyboardAuthPrompts(context, reject_keyboard_auth);
|
||||
(void)wolfSSH_CTX_SetChannelReqShellCb(context, accept_shell);
|
||||
(void)wolfSSH_CTX_SetChannelReqExecCb(context, reject_channel_request);
|
||||
(void)wolfSSH_CTX_SetChannelReqSubsysCb(context, reject_channel_request);
|
||||
@@ -897,6 +981,13 @@ static void accept_connections(void)
|
||||
close(socket_fd);
|
||||
continue;
|
||||
}
|
||||
if (!ssh_auth_policy_admit(&s_auth_policy, SSH_AUTH_POLICY_HANDSHAKE,
|
||||
esp_timer_get_time())) {
|
||||
add_counter(&s_counters.handshake_throttle_rejections, 1U);
|
||||
close(socket_fd);
|
||||
continue;
|
||||
}
|
||||
add_counter(&s_counters.handshake_admissions, 1U);
|
||||
if (set_nonblocking(socket_fd) != ESP_OK) {
|
||||
add_counter(&s_counters.io_failures, 1U);
|
||||
close(socket_fd);
|
||||
@@ -927,6 +1018,7 @@ static void accept_connections(void)
|
||||
wolfSSH_SetIOReadCtx(slot->ssh, slot);
|
||||
wolfSSH_SetUserAuthCtx(slot->ssh, slot);
|
||||
wolfSSH_SetUserAuthResultCtx(slot->ssh, slot);
|
||||
wolfSSH_SetKeyboardAuthCtx(slot->ssh, slot);
|
||||
wolfSSH_SetChannelReqCtx(slot->ssh, slot);
|
||||
publish_slot(slot, slot_index);
|
||||
}
|
||||
@@ -1224,6 +1316,11 @@ static bool flush_client_output(ssh_slot_t *slot)
|
||||
slot->ssh, slot->tx_buffer + slot->tx_offset,
|
||||
(word32)(slot->tx_length - slot->tx_offset));
|
||||
if (result > 0) {
|
||||
/* Positive stream_send means copied/consumed by wolfSSH, not peer
|
||||
* receipt. Preserve pending bytes on retry and the binary serial path. */
|
||||
if (slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
|
||||
secure_wipe(slot->tx_buffer + slot->tx_offset, (size_t)result);
|
||||
}
|
||||
slot->tx_offset += (size_t)result;
|
||||
add_counter(&s_counters.tx_bytes, (uint64_t)result);
|
||||
if (slot->tx_offset >= slot->tx_length) {
|
||||
@@ -1285,6 +1382,7 @@ static bool flush_admin_input(ssh_slot_t *slot, size_t slot_index)
|
||||
&token, slot->rx_buffer + slot->rx_offset,
|
||||
slot->rx_length - slot->rx_offset, &consumed);
|
||||
if (consumed > 0U) {
|
||||
secure_wipe(slot->rx_buffer + slot->rx_offset, consumed);
|
||||
slot->rx_offset += consumed;
|
||||
add_counter(&s_counters.rx_accepted_bytes, consumed);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,17 @@ typedef struct {
|
||||
uint64_t handshake_timeouts;
|
||||
uint64_t authentication_attempts;
|
||||
uint64_t authentication_failures;
|
||||
/* Admission is before work; completion above excludes denied requests and
|
||||
* unsigned probes. All values are counts, never submitted identity data. */
|
||||
uint64_t handshake_admissions;
|
||||
uint64_t handshake_throttle_rejections;
|
||||
uint64_t authentication_admissions;
|
||||
uint64_t authentication_throttle_rejections;
|
||||
uint64_t authentication_probe_admissions;
|
||||
uint64_t authentication_probe_rejections;
|
||||
uint64_t authentication_limit_disconnects;
|
||||
uint64_t authentication_backend_errors;
|
||||
uint64_t authentication_method_rejections;
|
||||
uint64_t request_rejections;
|
||||
uint64_t broker_connections;
|
||||
uint64_t broker_failures;
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
#include <setjmp.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
||||
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
|
||||
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND,
|
||||
ESP_ERR_INVALID_SIZE };
|
||||
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
|
||||
#define USER_DATABASE_USERNAME_CAPACITY 16U
|
||||
typedef struct {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Hidden credential input regression
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
CCACHE_DISABLE=1 python3 tests/hidden_input/run.py
|
||||
```
|
||||
|
||||
The host harness exercises production UART0/shared remote prompt handling and the extracted password-confirmation helper with synthetic input. It covers maximum capacity, sticky overflow (including differing suffixes and later backspace), unsupported bytes, cancellation, read failure, remote disconnect/revocation, confirmation rejection, secret-free output, remote CRLF handling and unchanged visible editing.
|
||||
|
||||
A rejected hidden prompt must return an error with zero length and wiped output, never a truncated credential prefix. These tests do not change password policy or persistence contracts.
|
||||
|
||||
The UART fake does not reproduce driver flushing or delayed paired-CRLF timing. No device, real transport or hardware validation is implied. Those checks are deferred to the [combined Phase 9 target session](../../docs/security_hardening.md#combined-phase-9-target-validation--deferred-not-run).
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual UART/shared remote prompt readers with deterministic host IO/RTOS fakes."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
|
||||
def stripped(name):
|
||||
return "\n".join(line for line in (ROOT / name).read_text().splitlines()
|
||||
if not line.startswith(("#include", "#pragma once"))) + "\n"
|
||||
|
||||
user = (ROOT / "src/user_console.c").read_text()
|
||||
password = user[user.index("static esp_err_t read_password("):user.index("static void show_generated_password(")]
|
||||
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
|
||||
+ stripped("src/admin_ssh_console.h") + stripped("src/admin_ssh_console.c")
|
||||
+ (ROOT / "tests/hidden_input/uart_fakes.h").read_text()
|
||||
+ stripped("src/console_input.c")
|
||||
+ "\n#undef printf\n#undef putchar\n#undef fflush\n"
|
||||
+ "#define USER_DATABASE_PASSWORD_CAPACITY 64U\n"
|
||||
+ "#define USER_DATABASE_PASSWORD_MIN_LENGTH 12U\n"
|
||||
+ "#define ESP_ERR_INVALID_RESPONSE 100\n" + password
|
||||
+ (ROOT / "tests/hidden_input/test.c").read_text())
|
||||
with tempfile.TemporaryDirectory(prefix="hidden-input-") as directory:
|
||||
path = Path(directory)
|
||||
(path / "test.c").write_text(unit)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
|
||||
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
|
||||
"-o", str(path / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||
@@ -0,0 +1,126 @@
|
||||
|
||||
static admin_ssh_console_token_t token={.session_id=7,.slot_generation=1};
|
||||
static user_principal_t admin={.role=USER_ROLE_ADMIN,.username="admin",.username_length=5};
|
||||
static bool live=true, disconnect_input, revoke_input, password_mode, visible;
|
||||
static esp_err_t expected;
|
||||
static uint8_t answer[65];
|
||||
static size_t answer_length;
|
||||
static unsigned calls;
|
||||
static bool owner_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
|
||||
{ (void)t; (void)p; assert(!lock_depth); return live; }
|
||||
static bool drained(const admin_ssh_console_token_t *t)
|
||||
{ (void)t; assert(!lock_depth); return owner_drained; }
|
||||
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t arg)
|
||||
{ (void)t; (void)action; (void)arg; ++actions; return ESP_ERR_NOT_SUPPORTED; }
|
||||
static const admin_console_owner_t owner={.is_current=owner_current,.drained=drained,.perform=perform};
|
||||
static void zeroed(const void *data, size_t n)
|
||||
{ const uint8_t *p=data; for(size_t i=0;i<n;++i) assert(p[i]==0); }
|
||||
static void reply(void)
|
||||
{
|
||||
++publications;
|
||||
/* Feed bytewise to exercise persistent state across transport packets. */
|
||||
while(input_offset<input_size) {
|
||||
uint8_t byte=input[input_offset++]; size_t consumed=0;
|
||||
assert(admin_ssh_console_feed_input(&token,&byte,1,&consumed) && consumed==1);
|
||||
if(s_sessions[0].prompt_state!=ADMIN_PROMPT_WAITING) break;
|
||||
}
|
||||
if(disconnect_input) admin_ssh_console_close(&token);
|
||||
if(revoke_input) live=false;
|
||||
}
|
||||
static void command(void)
|
||||
{
|
||||
++calls;
|
||||
memset(answer,0xa5,sizeof(answer)); answer_length=999;
|
||||
esp_err_t error=password_mode ? read_password(answer,&answer_length) :
|
||||
visible ? console_input_read_line("Input: ",answer,sizeof(answer),&answer_length) :
|
||||
console_input_read_hidden("Password: ",answer,sizeof(answer),12,64,&answer_length);
|
||||
assert(error==expected);
|
||||
if(error==ESP_OK) {
|
||||
assert(answer_length==64 && answer[64]==0);
|
||||
for(size_t i=0;i<64;++i) assert(answer[i]=='Q');
|
||||
} else {
|
||||
assert(!answer_length); zeroed(answer,sizeof(answer));
|
||||
}
|
||||
if(s_dispatch_remote) {
|
||||
zeroed(s_sessions[0].prompt_input,sizeof(s_sessions[0].prompt_input));
|
||||
assert(!s_sessions[0].prompt_rejected && !s_sessions[0].prompt_length);
|
||||
}
|
||||
}
|
||||
static void run_case(int route, const uint8_t *bytes, size_t n, esp_err_t result,
|
||||
bool passwords, bool disconnect, bool revoke, bool show)
|
||||
{
|
||||
input=bytes; input_size=n; input_offset=0; expected=result;
|
||||
password_mode=passwords; disconnect_input=disconnect; revoke_input=revoke; visible=show;
|
||||
publications=0; calls=0; live=true; uart_output_length=0; uart_output[0]=0;
|
||||
s_dispatch_remote=false;
|
||||
if(route==0) command();
|
||||
else {
|
||||
++token.slot_generation; token.transport=route==1 ? ADMIN_CONSOLE_TRANSPORT_SSH : ADMIN_CONSOLE_TRANSPORT_WEB;
|
||||
assert(admin_ssh_console_open_owned(&token,&admin,&owner)==ESP_OK);
|
||||
uint8_t out[4096]; size_t count;
|
||||
assert(admin_ssh_console_read_output(&token,out,sizeof(out),&count)==ESP_OK);
|
||||
size_t consumed;
|
||||
assert(admin_ssh_console_feed_input(&token,(const uint8_t *)"user password target\r",21,&consumed));
|
||||
assert(consumed==21);
|
||||
prompt_hook=reply; command_hook=command;
|
||||
if(!setjmp(loop_done)) worker_task(NULL);
|
||||
prompt_hook=NULL; command_hook=NULL;
|
||||
if(s_sessions[0].active) {
|
||||
assert(admin_ssh_console_read_output(&token,out,sizeof(out)-1,&count)==ESP_OK);
|
||||
out[count]=0;
|
||||
if(!show) assert(!strstr((char *)out,"QQQ"));
|
||||
admin_ssh_console_close(&token);
|
||||
}
|
||||
zeroed(&s_sessions[0],sizeof(s_sessions[0]));
|
||||
}
|
||||
assert(calls==1 && !lock_depth);
|
||||
assert(input_offset==n);
|
||||
if(!show) assert(!strstr(uart_output,"QQQ"));
|
||||
assert(publications==(passwords && n>=130 ? 2U : 1U));
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
assert(owner_drained && actions==0);
|
||||
assert(admin_ssh_console_init()==ESP_OK);
|
||||
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||
uint8_t bytes[140];
|
||||
for(int route=0;route<3;++route) {
|
||||
memset(bytes,'Q',64); bytes[64]='\r';
|
||||
run_case(route,bytes,65,ESP_OK,false,false,false,false);
|
||||
bytes[64]='\n'; run_case(route,bytes,65,ESP_OK,false,false,false,false);
|
||||
/* Confirmation accepts the exact maximum, not a truncated prefix. */
|
||||
memcpy(bytes+65,bytes,65);
|
||||
run_case(route,bytes,130,ESP_OK,true,false,false,false);
|
||||
if(route) {
|
||||
bytes[64]='\r'; bytes[65]='\n'; memset(bytes+66,'Q',64); bytes[130]='\r';
|
||||
run_case(route,bytes,131,ESP_OK,true,false,false,false);
|
||||
}
|
||||
for(int suffix='X';suffix<='Y';++suffix) {
|
||||
memset(bytes,'Q',64); bytes[64]=(uint8_t)suffix; bytes[65]='\r';
|
||||
run_case(route,bytes,66,ESP_ERR_INVALID_SIZE,true,false,false,false);
|
||||
bytes[65]=8; bytes[66]=127; bytes[67]='Q'; bytes[68]='\r';
|
||||
run_case(route,bytes,69,ESP_ERR_INVALID_SIZE,false,false,false,false);
|
||||
}
|
||||
/* Unsupported controls/high bytes cannot silently disappear, even if erased. */
|
||||
for(unsigned byte=0;byte<256;++byte) {
|
||||
if((byte>=32 && byte<=126) || byte==3 || byte==8 || byte==127 || byte==10 || byte==13) continue;
|
||||
memset(bytes,'Q',63); bytes[63]=(uint8_t)byte; bytes[64]=8;
|
||||
bytes[65]='Q'; bytes[66]='Q'; bytes[67]='\r';
|
||||
run_case(route,bytes,68,ESP_ERR_INVALID_SIZE,false,false,false,false);
|
||||
}
|
||||
memset(bytes,'Q',64); bytes[64]=8; bytes[65]='Q'; bytes[66]=127; bytes[67]='Q'; bytes[68]='\r';
|
||||
run_case(route,bytes,69,ESP_OK,false,false,false,false);
|
||||
memset(bytes,'Q',65); bytes[65]=3;
|
||||
run_case(route,bytes,66,ESP_ERR_INVALID_STATE,false,false,false,false);
|
||||
run_case(route,bytes,65,route ? ESP_ERR_NOT_FOUND : ESP_FAIL,false,route!=0,false,false);
|
||||
if(route) run_case(route,bytes,65,ESP_ERR_NOT_FOUND,false,false,true,false);
|
||||
/* Visible prompts retain their existing truncation/edit behavior. */
|
||||
memset(bytes,'Q',65); bytes[65]=8; bytes[66]='Q'; bytes[67]='\r';
|
||||
run_case(route,bytes,68,ESP_OK,false,false,false,true);
|
||||
/* A failed confirmation also wipes the first full password. */
|
||||
memset(bytes,'Q',64); bytes[64]='\r'; memset(bytes+65,'Q',65); bytes[130]='\r';
|
||||
run_case(route,bytes,131,ESP_ERR_INVALID_SIZE,true,false,false,false);
|
||||
}
|
||||
puts("PASS: UART0/SSH/web exact capacity, sticky overflow/suffix/backspace, all unsupported bytes, cancellation/IO failure/disconnect/revocation, no echo, confirmation and visible editing");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
#include <stdarg.h>
|
||||
#define UART_NUM_0 0
|
||||
static const uint8_t *input;
|
||||
static size_t input_size, input_offset;
|
||||
static char uart_output[4096];
|
||||
static size_t uart_output_length;
|
||||
static unsigned publications;
|
||||
static esp_err_t uart_flush_input(int uart) { assert(uart==0); ++publications; return ESP_OK; }
|
||||
static int uart_read_bytes(int uart, void *out, size_t n, unsigned wait)
|
||||
{
|
||||
assert(uart==0 && n==1 && wait==portMAX_DELAY && !lock_depth);
|
||||
if (input_offset==input_size) return -1;
|
||||
*(uint8_t *)out=input[input_offset++];
|
||||
return 1;
|
||||
}
|
||||
static int capture_printf(const char *format, ...)
|
||||
{
|
||||
va_list ap; va_start(ap,format);
|
||||
int n=vsnprintf(uart_output+uart_output_length,
|
||||
sizeof(uart_output)-uart_output_length,format,ap);
|
||||
va_end(ap); assert(n>=0 && (size_t)n<sizeof(uart_output)-uart_output_length);
|
||||
uart_output_length+=(size_t)n; return n;
|
||||
}
|
||||
static int capture_putchar(int c) { return capture_printf("%c",c); }
|
||||
static int capture_fflush(FILE *f) { (void)f; return 0; }
|
||||
#define printf capture_printf
|
||||
#define putchar capture_putchar
|
||||
#define fflush capture_fflush
|
||||
@@ -0,0 +1,54 @@
|
||||
# SSH authentication admission policy host tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
CCACHE_DISABLE=1 python3 tests/ssh_auth_policy/run.py
|
||||
CCACHE_DISABLE=1 CFLAGS='-O1 -g -fsanitize=undefined -fno-sanitize-recover=all' python3 tests/ssh_auth_policy/run.py
|
||||
```
|
||||
|
||||
Requires Python 3 and a host C11 compiler (`cc`, or `CC`). `CFLAGS` may override
|
||||
optimization/add sanitizers. The runner disables ccache, compiles the actual
|
||||
`src/ssh_auth_policy.c` with strict warnings, and runs in a temporary directory.
|
||||
No ESP-IDF, mocks, third-party dependencies, sleeps or real clock are involved.
|
||||
|
||||
## Contract
|
||||
|
||||
`ssh_auth_policy_t` is zero-initialized, allocation-free, single-owner state
|
||||
(72 bytes on the tested host, compile-time limit of 72 bytes). Three independent
|
||||
buckets lazily start at capacity:
|
||||
|
||||
| Kind | Capacity | Refill |
|
||||
| --- | --- | --- |
|
||||
| `SSH_AUTH_POLICY_HANDSHAKE` | 6 | 1 token / 10 seconds |
|
||||
| `SSH_AUTH_POLICY_VERIFICATION` | 6 | 1 token / 10 seconds |
|
||||
| `SSH_AUTH_POLICY_PROBE` | 12 | 1 token / 5 seconds |
|
||||
|
||||
The header exposes each capacity and refill interval in microseconds.
|
||||
`ssh_auth_policy_admit(policy, kind, now_us)` consumes one token on success,
|
||||
with no refund for subsequent failure. Below capacity, fractional elapsed credit
|
||||
is retained. Reaching capacity discards all surplus, including fractional credit.
|
||||
Empty-bucket denials do not shift the refill deadline or incur debt.
|
||||
|
||||
Time must be nonnegative and nondecreasing across the shared policy, including
|
||||
across classes and ordinary rate-limit denials. Equal timestamps are valid.
|
||||
Negative/regressing time, invalid enum values and NULL fail closed without
|
||||
mutation. Refill arithmetic remains bounded through `INT64_MAX`.
|
||||
|
||||
The integrating SSH owner must retain ONE static policy across callers/sessions,
|
||||
SSH stop/start, and counter clears; only reboot zeroes it. There are no locks,
|
||||
timers, clock reads, sleeps, reset or refund APIs. These tests do not integrate
|
||||
`ssh_transport` or CMake, and do not validate lifecycle wiring.
|
||||
|
||||
## Coverage
|
||||
|
||||
All three classes: initial burst at zero and `INT64_MAX`; refill boundaries at
|
||||
minus/exact/plus one microsecond; partial and multi-token credit; 998 consecutive
|
||||
exact-rate refill cycles with intervening denials; idle saturation with no
|
||||
fractional surplus; huge forward jumps including zero to `INT64_MAX`; negative
|
||||
and regressing time with byte-for-byte unchanged state; regression after an
|
||||
initial zero timestamp and after denial. Also checks invalid enum/NULL handling,
|
||||
independent class budgets, cross-class monotonic validation, and two logical
|
||||
callers sharing one exhausted budget.
|
||||
|
||||
Hardware validation is deferred to the combined Phase 9 validation.
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile and run the actual allocation-free firmware policy on the host."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
env = dict(os.environ, CCACHE_DISABLE="1")
|
||||
with tempfile.TemporaryDirectory(prefix="ssh-auth-policy-") as temporary:
|
||||
binary = Path(temporary) / "test"
|
||||
command = shlex.split(env.get("CC", "cc")) + [
|
||||
"-std=c11", "-Wall", "-Wextra", "-Werror", "-pedantic",
|
||||
*shlex.split(env.get("CFLAGS", """-O2""")),
|
||||
"-I", str(ROOT / "src"),
|
||||
str(ROOT / "src/ssh_auth_policy.c"),
|
||||
str(ROOT / "tests/ssh_auth_policy/test.c"),
|
||||
"-o", str(binary),
|
||||
]
|
||||
subprocess.run(command, env=env, check=True)
|
||||
subprocess.run([str(binary)], env=env, check=True)
|
||||
@@ -0,0 +1,147 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "ssh_auth_policy.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define CHECK(condition) do { \
|
||||
if (!(condition)) { \
|
||||
fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #condition); \
|
||||
exit(EXIT_FAILURE); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
_Static_assert(sizeof(ssh_auth_policy_t) <= 72, "policy must remain small");
|
||||
_Static_assert(SSH_AUTH_POLICY_HANDSHAKE_CAPACITY == 6, "handshake burst");
|
||||
_Static_assert(SSH_AUTH_POLICY_VERIFICATION_CAPACITY == 6, "verification burst");
|
||||
_Static_assert(SSH_AUTH_POLICY_PROBE_CAPACITY == 12, "probe burst");
|
||||
_Static_assert(SSH_AUTH_POLICY_HANDSHAKE_REFILL_US == 10000000, "handshake rate");
|
||||
_Static_assert(SSH_AUTH_POLICY_VERIFICATION_REFILL_US == 10000000, "verification rate");
|
||||
_Static_assert(SSH_AUTH_POLICY_PROBE_REFILL_US == 5000000, "probe rate");
|
||||
|
||||
static void drain(ssh_auth_policy_t *p, ssh_auth_policy_kind_t kind,
|
||||
int64_t now, unsigned count)
|
||||
{
|
||||
for (unsigned i = 0; i < count; ++i) {
|
||||
CHECK(ssh_auth_policy_admit(p, kind, now));
|
||||
}
|
||||
CHECK(!ssh_auth_policy_admit(p, kind, now));
|
||||
}
|
||||
|
||||
static void unchanged(ssh_auth_policy_t *p, ssh_auth_policy_kind_t kind, int64_t now)
|
||||
{
|
||||
unsigned char before[sizeof(*p)];
|
||||
memcpy(before, p, sizeof(*p));
|
||||
CHECK(!ssh_auth_policy_admit(p, kind, now));
|
||||
CHECK(memcmp(before, p, sizeof(*p)) == 0);
|
||||
}
|
||||
|
||||
static void test_class(ssh_auth_policy_kind_t kind, unsigned capacity, int64_t interval)
|
||||
{
|
||||
ssh_auth_policy_t p = {0};
|
||||
drain(&p, kind, 0, capacity);
|
||||
unchanged(&p, kind, -1); /* Initial zero is a real timestamp. */
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, 1));
|
||||
unchanged(&p, kind, 0); /* Regression after an empty-bucket denial. */
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, interval - 1));
|
||||
unchanged(&p, kind, interval - 2);
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, interval));
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, interval + 1));
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, 2 * interval));
|
||||
|
||||
/* Many denials cannot extend cooldown; exactly one token each interval. */
|
||||
for (int64_t n = 3; n <= 1000; ++n) {
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, n * interval - 1));
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, n * interval));
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, n * interval));
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, n * interval + 1));
|
||||
}
|
||||
|
||||
p = (ssh_auth_policy_t){0};
|
||||
drain(&p, kind, 0, capacity);
|
||||
/* Refill multiple tokens without losing fractional elapsed credit. */
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, 2 * interval + interval / 2));
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, 3 * interval - 1));
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, 3 * interval - 1));
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, 3 * interval));
|
||||
|
||||
p = (ssh_auth_policy_t){0};
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, 0));
|
||||
const int64_t idle = 100 * interval + interval / 2;
|
||||
drain(&p, kind, idle, capacity); /* Full idle discards fractional surplus. */
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, idle + interval - 1));
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, idle + interval));
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, idle + interval + 1));
|
||||
|
||||
p = (ssh_auth_policy_t){0};
|
||||
drain(&p, kind, 0, capacity);
|
||||
const int64_t late = INT64_MAX - interval;
|
||||
drain(&p, kind, late, capacity); /* Huge forward jump saturates, not wraps. */
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, INT64_MAX - 1));
|
||||
CHECK(ssh_auth_policy_admit(&p, kind, INT64_MAX));
|
||||
CHECK(!ssh_auth_policy_admit(&p, kind, INT64_MAX));
|
||||
unchanged(&p, kind, INT64_MAX - 1);
|
||||
|
||||
p = (ssh_auth_policy_t){0};
|
||||
drain(&p, kind, INT64_MAX, capacity); /* Lazy init at maximum timestamp. */
|
||||
p = (ssh_auth_policy_t){0};
|
||||
drain(&p, kind, 0, capacity);
|
||||
drain(&p, kind, INT64_MAX, capacity); /* Direct maximum-sized subtraction. */
|
||||
}
|
||||
|
||||
static bool caller_a(ssh_auth_policy_t *p)
|
||||
{
|
||||
return ssh_auth_policy_admit(p, SSH_AUTH_POLICY_HANDSHAKE, 0);
|
||||
}
|
||||
|
||||
static bool caller_b(ssh_auth_policy_t *p)
|
||||
{
|
||||
return ssh_auth_policy_admit(p, SSH_AUTH_POLICY_HANDSHAKE, 0);
|
||||
}
|
||||
|
||||
static void test_validation_and_sharing(void)
|
||||
{
|
||||
ssh_auth_policy_t p = {0};
|
||||
CHECK(!ssh_auth_policy_admit(NULL, SSH_AUTH_POLICY_HANDSHAKE, 0));
|
||||
unchanged(&p, SSH_AUTH_POLICY_HANDSHAKE, INT64_MIN);
|
||||
unchanged(&p, (ssh_auth_policy_kind_t)-1, 0);
|
||||
unchanged(&p, SSH_AUTH_POLICY_KIND_COUNT, 0);
|
||||
unchanged(&p, (ssh_auth_policy_kind_t)INT_MAX, INT64_MAX);
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
CHECK(caller_a(&p));
|
||||
CHECK(caller_b(&p));
|
||||
}
|
||||
CHECK(!caller_a(&p));
|
||||
CHECK(!caller_b(&p));
|
||||
drain(&p, SSH_AUTH_POLICY_VERIFICATION, 0, 6);
|
||||
drain(&p, SSH_AUTH_POLICY_PROBE, 0, 12);
|
||||
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_PROBE, 5000000));
|
||||
CHECK(!ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_HANDSHAKE, 5000000));
|
||||
CHECK(!ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_VERIFICATION, 5000000));
|
||||
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_HANDSHAKE, 10000000));
|
||||
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_VERIFICATION, 10000000));
|
||||
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_PROBE, 10000000));
|
||||
unchanged(&p, (ssh_auth_policy_kind_t)-1, INT64_MAX);
|
||||
unchanged(&p, SSH_AUTH_POLICY_PROBE, -1);
|
||||
unchanged(&p, SSH_AUTH_POLICY_VERIFICATION, 9999999);
|
||||
|
||||
p = (ssh_auth_policy_t){0};
|
||||
CHECK(ssh_auth_policy_admit(&p, SSH_AUTH_POLICY_HANDSHAKE, 100));
|
||||
unchanged(&p, SSH_AUTH_POLICY_PROBE, 99); /* Even an uninitialized class. */
|
||||
drain(&p, SSH_AUTH_POLICY_PROBE, 100, 12);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_class(SSH_AUTH_POLICY_HANDSHAKE, SSH_AUTH_POLICY_HANDSHAKE_CAPACITY,
|
||||
SSH_AUTH_POLICY_HANDSHAKE_REFILL_US);
|
||||
test_class(SSH_AUTH_POLICY_VERIFICATION, SSH_AUTH_POLICY_VERIFICATION_CAPACITY,
|
||||
SSH_AUTH_POLICY_VERIFICATION_REFILL_US);
|
||||
test_class(SSH_AUTH_POLICY_PROBE, SSH_AUTH_POLICY_PROBE_CAPACITY,
|
||||
SSH_AUTH_POLICY_PROBE_REFILL_US);
|
||||
test_validation_and_sharing();
|
||||
printf("ssh_auth_policy: all tests passed (policy size: %zu bytes)\n", sizeof(ssh_auth_policy_t));
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# SSH authentication transport host regression
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
CCACHE_DISABLE=1 python3 tests/ssh_auth_transport/run.py
|
||||
```
|
||||
|
||||
Requires Python 3 and a C11 host compiler (`cc`, or `CC` override). The runner also sets `CCACHE_DISABLE=1` for its subprocesses. Compilation uses `-Wall -Wextra -Werror`; compilation and execution have timeouts. Generated C and executable live in a temporary directory, not the source tree. Assertion or compiler failures propagate as a nonzero exit.
|
||||
|
||||
## Production code under test
|
||||
|
||||
Following `tests/ssh_management/runtime.py`, `run.py` extracts named function definitions **verbatim** from `src/ssh_transport.c`. It extracts the actual slot definition, transport constants, session/route enums and counters declaration too, and links the actual `src/ssh_auth_policy.c` with its header. Missing/ambiguous function definitions fail extraction rather than silently falling back to fixture implementations.
|
||||
|
||||
The extracted set covers authentication callbacks and helpers, counter saturation/clear, socket acceptance and free-slot selection, start/stop, retirement, admin RX, serial RX and shared TX flushing. The fixture supplies only synchronous boundary doubles for clock, database, socket/library allocation and I/O, broker, console, publication and context/listener construction. No credential verifier, admission algorithm or callback logic is reimplemented. `boot()` resets test state between independent scenarios; the restart tests use actual production start/stop without resetting the policy.
|
||||
|
||||
## Coverage
|
||||
|
||||
- Password success, invalid password, backend failure and rejected password change; admissions versus completed attempts, principal promotion and pending-principal clearing.
|
||||
- Signed key authorization denial/backend error, bad signature result, stale principal, currentness backend failure and success. Admission precedes authorization, authorized signed keys await completion, and duplicate/unexpected callbacks cannot create a promotion or count again. Verification-throttled signed requests do not reach authorization or acquire a pending signature-result marker.
|
||||
- Close/state/pending-result fences, unsupported method handling, keyboard callback rejection with the entire prompt structure cleared and no repeated method count after close.
|
||||
- Unsigned authorized/rejected probes share a 12-token pool across both slots/reconnects, refill one token at five seconds and never become completed attempts. Password/signed verification shares six tokens, refilling one per ten seconds. Three completed failed password or signed-key attempts close the slot.
|
||||
- Actual acceptance loop: two-slot capacity, six handshake admissions, allocation failures consume admission, throttle denial before allocation, and one-token/ten-second refill.
|
||||
- Actual stop/start and counter clear preserve all three exhausted pools. Counter clear initialization guard, 64-bit saturating addition and per-slot 8-bit saturation.
|
||||
- Partial admin RX consumption (including `false` with consumed bytes), zero-consumption rejection and full drain wipe only consumed spans. WANT_READ, WANT_WRITE, rekey, window/channel retry, zero and error paths preserve pending TX; positive partial sends wipe only accepted admin spans. Binary serial RX/TX remains unchanged, including embedded NUL/0xff and partial broker acceptance.
|
||||
- Whole-slot retirement wipe including padding and buffers, exact retained generation, `socket_fd == -1` sentinel, broker-disconnect failure retaining a closing slot, and repeated cleanup without double free/close. The old live fd is closed, not retained.
|
||||
- Source checks for context registration of authentication, type advertisement, result and rejecting keyboard callbacks, plus per-session auth/result/keyboard contexts.
|
||||
|
||||
## Limits and deferred validation
|
||||
|
||||
The wolfSSH types in `fixture.h` are narrow host shapes, not ABI validation. Library return codes model distinct outcomes; this does not compile wolfSSH or perform cryptographic signature work. Denied authorization is verified to return a non-success result and leave no pending completion; proving the parser actually skips signature processing belongs to the separately delegated real wolfSSH parser-order regression. Do not treat these tests as its replacement.
|
||||
|
||||
Context construction is stubbed; callback registration is checked in production source rather than executed. Console/broker/database behavior and real socket scheduling are outside this suite. A fixture principal uses synthetic bytes to inspect lifecycle clearing; there are no real credentials.
|
||||
|
||||
No firmware build, device execution, upload or erase is performed. All device validation remains deferred to whole Phase 9, including real clients, timing, concurrency, recovery and physical UART/broker behavior.
|
||||
@@ -0,0 +1,142 @@
|
||||
/* Host-only boundary doubles. No authentication/policy/transport logic here. */
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include "ssh_auth_policy.h"
|
||||
typedef unsigned char byte;
|
||||
typedef uint32_t word32;
|
||||
typedef int esp_err_t;
|
||||
typedef int WOLFSSH;
|
||||
typedef int WOLFSSH_CTX;
|
||||
typedef unsigned session_broker_client_id_t;
|
||||
typedef struct { uint32_t id; unsigned char secret[28]; } user_principal_t;
|
||||
typedef struct { unsigned slot; } admin_ssh_console_token_t;
|
||||
typedef struct {
|
||||
byte isCert, hasSignature;
|
||||
const byte *publicKeyType, *publicKey;
|
||||
word32 publicKeyTypeSz, publicKeySz;
|
||||
} WS_UserAuthData_PublicKey;
|
||||
typedef struct {
|
||||
byte type;
|
||||
const byte *username;
|
||||
word32 usernameSz;
|
||||
union {
|
||||
struct { byte hasNewPassword; const byte *password; word32 passwordSz; } password;
|
||||
WS_UserAuthData_PublicKey publicKey;
|
||||
} sf;
|
||||
} WS_UserAuthData;
|
||||
typedef struct { unsigned promptCount; void *prompts; byte storage[32]; } WS_UserAuthData_Keyboard;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_TIMEOUT, ESP_ERR_NOT_FOUND };
|
||||
enum { WOLFSSH_USERAUTH_PASSWORD=1, WOLFSSH_USERAUTH_PUBLICKEY=2,
|
||||
WOLFSSH_USERAUTH_SUCCESS=0, WOLFSSH_USERAUTH_REJECTED=3,
|
||||
WOLFSSH_USERAUTH_INVALID_AUTHTYPE, WOLFSSH_USERAUTH_INVALID_PASSWORD,
|
||||
WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_INVALID_PUBLICKEY };
|
||||
enum { WS_SUCCESS=0, WS_ERROR=-1, WS_WANT_READ=-2, WS_WANT_WRITE=-3,
|
||||
WS_REKEYING=-4, WS_WINDOW_FULL=-5, WS_CHAN_RXD=-6 };
|
||||
#define SESSION_BROKER_NO_CLIENT 0U
|
||||
/* PRODUCTION TYPES */
|
||||
static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_counters_t s_counters;
|
||||
static ssh_auth_policy_t s_auth_policy;
|
||||
static WOLFSSH_CTX *s_context;
|
||||
static int s_listen_fd=-1, s_lock;
|
||||
static bool s_initialized;
|
||||
static unsigned lock_depth, shutdown_calls, close_calls, password_calls, key_calls;
|
||||
static unsigned current_calls, allocations, frees, context_frees;
|
||||
static int64_t now_us;
|
||||
static bool db_accept, db_current, feed_ok, accepts_input=true, allocation_fail;
|
||||
static esp_err_t db_error, current_error, broker_error;
|
||||
static size_t feed_consumed, broker_accepted;
|
||||
static int send_result, read_result, ssh_error, accepts_remaining;
|
||||
static const byte payload[] = {0x00, 0xff, 'p', 'a', 's', 's', '\r', '\n'};
|
||||
static const void *wipe_address;
|
||||
static size_t wipe_size;
|
||||
#define taskENTER_CRITICAL(p) do { (void)(p); assert(lock_depth++ == 0); } while (0)
|
||||
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--lock_depth == 0); } while (0)
|
||||
#define pdMS_TO_TICKS(n) (n)
|
||||
static void secure_wipe(void *p, size_t n) {
|
||||
wipe_address=p; wipe_size=n;
|
||||
volatile byte *b=p; while(n--) *b++=0;
|
||||
}
|
||||
static int64_t esp_timer_get_time(void) { return now_us; }
|
||||
static int fake_shutdown(int fd, int how) { assert(fd>=0 && how==SHUT_RDWR); ++shutdown_calls; return 0; }
|
||||
static int fake_close(int fd) { assert(fd>=0); ++close_calls; return 0; }
|
||||
#define shutdown fake_shutdown
|
||||
#define close fake_close
|
||||
static esp_err_t user_database_authenticate_password(const byte *u, word32 un,
|
||||
const byte *p, word32 pn, user_principal_t *out, bool *ok) {
|
||||
assert(u && un==1 && p && pn==sizeof(payload)); ++password_calls;
|
||||
memset(out, 0x5a, sizeof(*out)); *ok=db_accept; return db_error;
|
||||
}
|
||||
static esp_err_t user_database_authorize_ssh_public_key(const byte *u, word32 un,
|
||||
const byte *t, word32 tn, const byte *k, word32 kn, user_principal_t *out, bool *ok) {
|
||||
assert(u && un==1 && t && tn==1 && k && kn==sizeof(payload)); ++key_calls;
|
||||
memset(out, 0x5a, sizeof(*out)); *ok=db_accept; return db_error;
|
||||
}
|
||||
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *ok) {
|
||||
assert(p->id==0x5a5a5a5a); ++current_calls; *ok=db_current; return current_error;
|
||||
}
|
||||
static admin_ssh_console_token_t admin_console_token(ssh_slot_t *s, size_t i) {
|
||||
assert(s==&s_slots[i]); return (admin_ssh_console_token_t){(unsigned)i};
|
||||
}
|
||||
static void admin_ssh_console_close(const admin_ssh_console_token_t *t) { assert(t->slot<2); }
|
||||
static int wolfSSH_shutdown(WOLFSSH *s) { assert(s); return 0; }
|
||||
static void wolfSSH_free(WOLFSSH *s) { assert(s); ++frees; }
|
||||
static esp_err_t session_broker_disconnect(session_broker_client_id_t id) { assert(id); return broker_error; }
|
||||
static void publish_slot(ssh_slot_t *s, size_t i) { assert(s==&s_slots[i]); }
|
||||
static void vTaskDelay(unsigned n) { (void)n; }
|
||||
static esp_err_t create_context(void) { static WOLFSSH_CTX ctx; s_context=&ctx; return ESP_OK; }
|
||||
static esp_err_t create_listener(void) { s_listen_fd=10; return ESP_OK; }
|
||||
static void wolfSSH_CTX_free(WOLFSSH_CTX *c) { assert(c); ++context_frees; }
|
||||
static int fake_accept(int fd, struct sockaddr *p, socklen_t *n) {
|
||||
assert(fd==10); memset(p,0,*n);
|
||||
if(accepts_remaining>0) { --accepts_remaining; return 20+accepts_remaining; }
|
||||
errno=EAGAIN; return -1;
|
||||
}
|
||||
#define accept fake_accept
|
||||
static int fake_setsockopt(int f,int l,int o,const void *v,socklen_t n) {
|
||||
(void)f;(void)l;(void)o;(void)v;(void)n; return 0;
|
||||
}
|
||||
#define setsockopt fake_setsockopt
|
||||
static esp_err_t set_nonblocking(int fd) { assert(fd>=0); return ESP_OK; }
|
||||
static void format_peer(const struct sockaddr_storage *p,char *out,size_t n) {
|
||||
(void)p; assert(n>4); strcpy(out,"host");
|
||||
}
|
||||
static WOLFSSH *wolfSSH_new(WOLFSSH_CTX *c) { static WOLFSSH ssh; assert(c); ++allocations; return allocation_fail ? NULL : &ssh; }
|
||||
static int wolfSSH_set_fd(WOLFSSH *s,int fd) { assert(s && fd>=0); return WS_SUCCESS; }
|
||||
#define CONTEXT_SETTER(name) static void name(WOLFSSH *s,void *p) { assert(s && p); }
|
||||
CONTEXT_SETTER(wolfSSH_SetIOReadCtx)
|
||||
CONTEXT_SETTER(wolfSSH_SetUserAuthCtx)
|
||||
CONTEXT_SETTER(wolfSSH_SetUserAuthResultCtx)
|
||||
CONTEXT_SETTER(wolfSSH_SetKeyboardAuthCtx)
|
||||
CONTEXT_SETTER(wolfSSH_SetChannelReqCtx)
|
||||
static int wolfSSH_get_error(WOLFSSH *s) { assert(s); return ssh_error; }
|
||||
static int wolfSSH_stream_send(WOLFSSH *s,const byte *p,word32 n) {
|
||||
assert(s && n && (send_result<=0 || (unsigned)send_result<=n));
|
||||
assert(memcmp(p,payload+sizeof(payload)-n,n)==0); return send_result;
|
||||
}
|
||||
static int wolfSSH_stream_read(WOLFSSH *s,byte *p,word32 n) {
|
||||
assert(s && n>=sizeof(payload));
|
||||
if(read_result>0) { assert(read_result==(int)sizeof(payload)); memcpy(p,payload,sizeof(payload)); }
|
||||
return read_result;
|
||||
}
|
||||
static bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *t,
|
||||
const byte *p,size_t n,size_t *consumed) {
|
||||
assert(t->slot<2 && feed_consumed<=n);
|
||||
assert(memcmp(p,payload+sizeof(payload)-n,n)==0);
|
||||
*consumed=feed_consumed; return feed_ok;
|
||||
}
|
||||
static bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *t) { assert(t->slot<2); return accepts_input; }
|
||||
static esp_err_t session_broker_write(session_broker_client_id_t id,const byte *p,
|
||||
size_t n,size_t *accepted) {
|
||||
(void)id; assert(broker_accepted<=n);
|
||||
assert(memcmp(p,payload+sizeof(payload)-n,n)==0);
|
||||
*accepted=broker_accepted; return broker_error;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile verbatim transport functions with narrow host boundary doubles."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
SOURCE = (ROOT / 'src/ssh_transport.c').read_text()
|
||||
HEADER = (ROOT / 'src/ssh_transport.h').read_text()
|
||||
|
||||
|
||||
def function(name):
|
||||
matches = list(re.finditer(r'^(?:static )?[^\n;{}]+\b' + re.escape(name)
|
||||
+ r'\([^;]*?\n\{.*?^\}', SOURCE, re.M | re.S))
|
||||
assert len(matches) == 1, f'expected exactly one production definition: {name}'
|
||||
return matches[0].group() + '\n'
|
||||
|
||||
|
||||
def declaration(text, name):
|
||||
match = re.search(r'^typedef (?:struct|enum) \{[^}]*\} ' + name + ';', text, re.M)
|
||||
assert match, name
|
||||
return match.group() + '\n'
|
||||
|
||||
|
||||
for setter, callback in (
|
||||
('wolfSSH_SetUserAuth', 'authenticate_user'),
|
||||
('wolfSSH_SetUserAuthTypes', 'allowed_auth_types'),
|
||||
('wolfSSH_SetUserAuthResult', 'authentication_result'),
|
||||
('wolfSSH_SetKeyboardAuthPrompts', 'reject_keyboard_auth'),
|
||||
):
|
||||
assert re.search(r'\b' + setter + r'\(context,\s*' + callback + r'\)',
|
||||
function('create_context')), setter
|
||||
for setter in ('wolfSSH_SetUserAuthCtx', 'wolfSSH_SetUserAuthResultCtx',
|
||||
'wolfSSH_SetKeyboardAuthCtx'):
|
||||
assert re.search(r'\b' + setter + r'\(slot->ssh,\s*slot\)',
|
||||
function('accept_connections')), setter
|
||||
assert re.search(r'^static ssh_auth_policy_t s_auth_policy;', SOURCE, re.M)
|
||||
|
||||
names = '''add_counter make_session_id allowed_auth_types clear_pending_principal
|
||||
close_authentication admit_authentication reject_keyboard_auth
|
||||
complete_authentication_attempt authenticate_password authenticate_public_key
|
||||
authenticate_user authentication_result wolfssh_would_block close_socket
|
||||
cleanup_slot request_slot_close start_runtime stop_runtime find_free_slot
|
||||
accept_connections flush_client_input receive_client_input flush_client_output
|
||||
flush_admin_input receive_admin_input ssh_transport_clear_counters'''.split()
|
||||
constants = '\n'.join(line for text in (HEADER, SOURCE) for line in text.splitlines()
|
||||
if line.startswith('#define SSH_TRANSPORT_')) + '\n'
|
||||
types = ''.join(declaration(HEADER, n) for n in (
|
||||
'ssh_transport_session_state_t', 'ssh_transport_session_route_t',
|
||||
'ssh_transport_counters_t')) + declaration(SOURCE, 'ssh_slot_t')
|
||||
fixture = (HERE / 'fixture.h').read_text()
|
||||
unit = fixture.replace('/* PRODUCTION TYPES */', constants + types)
|
||||
unit += '\n'.join(function(n) for n in names)
|
||||
unit += (HERE / 'test.c').read_text()
|
||||
env = dict(os.environ, CCACHE_DISABLE='1')
|
||||
with tempfile.TemporaryDirectory(prefix='ssh-auth-transport-') as directory:
|
||||
out = Path(directory)
|
||||
(out / 'test.c').write_text(unit)
|
||||
subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + [
|
||||
'-std=c11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-I', str(ROOT / 'src'), str(out / 'test.c'),
|
||||
str(ROOT / 'src/ssh_auth_policy.c'), '-o', str(out / 'test')],
|
||||
env=env, check=True, timeout=30)
|
||||
subprocess.run([str(out / 'test')], env=env, check=True, timeout=10)
|
||||
print('PASS production callback registration and per-slot context source checks')
|
||||
@@ -0,0 +1,249 @@
|
||||
/* Included after verbatim production definitions by run.py. */
|
||||
static void zero_bytes(const void *p,size_t n) {
|
||||
const byte *b=p; for(size_t i=0;i<n;++i) assert(b[i]==0);
|
||||
}
|
||||
static ssh_slot_t *fresh(unsigned i) {
|
||||
assert(i<2); memset(&s_slots[i],0,sizeof(s_slots[i]));
|
||||
s_slots[i].state=SSH_TRANSPORT_SESSION_HANDSHAKE;
|
||||
s_slots[i].socket_fd=30+(int)i; s_slots[i].ssh=(WOLFSSH *)&s_lock;
|
||||
return &s_slots[i];
|
||||
}
|
||||
static void boot(void) {
|
||||
memset(s_slots,0,sizeof(s_slots)); memset(&s_counters,0,sizeof(s_counters));
|
||||
memset(&s_auth_policy,0,sizeof(s_auth_policy));
|
||||
s_context=NULL; s_listen_fd=-1; s_initialized=true; now_us=0;
|
||||
password_calls=key_calls=current_calls=shutdown_calls=close_calls=0;
|
||||
allocations=frees=context_frees=0; db_error=current_error=broker_error=ESP_OK;
|
||||
db_accept=db_current=true; allocation_fail=false;
|
||||
}
|
||||
static WS_UserAuthData password(void) {
|
||||
WS_UserAuthData a={.type=WOLFSSH_USERAUTH_PASSWORD,.username=(const byte *)"u",.usernameSz=1};
|
||||
a.sf.password.password=payload; a.sf.password.passwordSz=sizeof(payload); return a;
|
||||
}
|
||||
static WS_UserAuthData key(bool signed_key) {
|
||||
WS_UserAuthData a={.type=WOLFSSH_USERAUTH_PUBLICKEY,.username=(const byte *)"u",.usernameSz=1};
|
||||
a.sf.publicKey=(WS_UserAuthData_PublicKey){.hasSignature=signed_key,
|
||||
.publicKeyType=(const byte *)"k",.publicKeyTypeSz=1,
|
||||
.publicKey=payload,.publicKeySz=sizeof(payload)}; return a;
|
||||
}
|
||||
static int auth(ssh_slot_t *s,WS_UserAuthData *a) { return authenticate_user(a->type,a,s); }
|
||||
static void passwords(void) {
|
||||
for(unsigned mode=0;mode<4;++mode) {
|
||||
boot(); ssh_slot_t *s=fresh(0); WS_UserAuthData a=password();
|
||||
db_accept=mode==0; db_error=mode==2 ? ESP_FAIL : ESP_OK;
|
||||
a.sf.password.hasNewPassword=mode==3;
|
||||
const int expected[]={WOLFSSH_USERAUTH_SUCCESS,WOLFSSH_USERAUTH_INVALID_PASSWORD,
|
||||
WOLFSSH_USERAUTH_FAILURE,WOLFSSH_USERAUTH_INVALID_AUTHTYPE};
|
||||
assert(auth(s,&a)==expected[mode]);
|
||||
assert(password_calls==(mode==3 ? 0U : 1U));
|
||||
assert(s->authenticated==(mode==0) && s->principal_valid==(mode==0));
|
||||
assert(s->authentication_attempts==1 && s_counters.authentication_attempts==1);
|
||||
assert(s_counters.authentication_failures==(mode!=0));
|
||||
assert(s_counters.authentication_backend_errors==(mode==2));
|
||||
assert(s_counters.authentication_admissions==1);
|
||||
zero_bytes(&s->pending_principal,sizeof(s->pending_principal));
|
||||
}
|
||||
boot(); db_accept=false; WS_UserAuthData a=password();
|
||||
for(unsigned i=0;i<2;++i) {
|
||||
ssh_slot_t *s=fresh(i);
|
||||
for(unsigned j=0;j<3;++j) {
|
||||
assert(auth(s,&a)==(j==2 ? WOLFSSH_USERAUTH_REJECTED : WOLFSSH_USERAUTH_INVALID_PASSWORD));
|
||||
assert(s->close_requested==(j==2));
|
||||
}
|
||||
}
|
||||
assert(s_counters.authentication_limit_disconnects==2 && shutdown_calls==2);
|
||||
ssh_slot_t *s=fresh(0);
|
||||
assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED && password_calls==6);
|
||||
assert(s->authentication_attempts==0 && s_counters.authentication_throttle_rejections==1);
|
||||
WS_UserAuthData signed_key=key(true); s=fresh(1);
|
||||
assert(auth(s,&signed_key)==WOLFSSH_USERAUTH_REJECTED && key_calls==0);
|
||||
assert(!s->awaiting_auth_result && !s->pending_principal_valid);
|
||||
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,&signed_key,s)==WS_ERROR);
|
||||
assert(s_counters.authentication_attempts==6 && current_calls==0 && !s->authenticated);
|
||||
now_us=9999999; s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
|
||||
now_us=10000000; s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_INVALID_PASSWORD);
|
||||
s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED && password_calls==7);
|
||||
puts("PASS password outcomes, shared verification budget/refill, reconnect and three-failure closure");
|
||||
}
|
||||
static void signed_keys(void) {
|
||||
for(unsigned mode=0;mode<6;++mode) {
|
||||
boot(); ssh_slot_t *s=fresh(0); WS_UserAuthData a=key(true);
|
||||
db_accept=mode!=0; db_error=mode==1 ? ESP_FAIL : ESP_OK;
|
||||
int result=auth(s,&a); assert(key_calls==1);
|
||||
if(mode<2) {
|
||||
assert(result==(mode==0 ? WOLFSSH_USERAUTH_INVALID_PUBLICKEY : WOLFSSH_USERAUTH_FAILURE));
|
||||
assert(!s->awaiting_auth_result && !s->pending_principal_valid);
|
||||
assert(s_counters.authentication_attempts==1);
|
||||
/* Denied authorization never grants permission for signature work. */
|
||||
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,&a,s)==WS_ERROR);
|
||||
} else {
|
||||
assert(result==WOLFSSH_USERAUTH_SUCCESS && s->awaiting_auth_result);
|
||||
assert(!s->authenticated && s_counters.authentication_attempts==0);
|
||||
db_current=mode!=3; current_error=mode==4 ? ESP_FAIL : ESP_OK;
|
||||
assert(authentication_result(mode==2 ? WOLFSSH_USERAUTH_FAILURE : WOLFSSH_USERAUTH_SUCCESS,
|
||||
&a,s)==((mode==3 || mode==4) ? WS_ERROR : WS_SUCCESS));
|
||||
assert(s->authenticated==(mode==5));
|
||||
assert(current_calls==(mode==2 ? 0U : 1U));
|
||||
assert(s_counters.authentication_attempts==1);
|
||||
assert(s_counters.authentication_admissions==1);
|
||||
assert(s_counters.authentication_failures==(mode!=5));
|
||||
assert(s_counters.authentication_backend_errors==(mode==4));
|
||||
bool before=s->authenticated;
|
||||
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,&a,s)==WS_ERROR);
|
||||
assert(s->authenticated==before);
|
||||
}
|
||||
assert(s_counters.authentication_attempts==1 && s->close_requested);
|
||||
assert(!s->awaiting_auth_result && !s->pending_principal_valid);
|
||||
zero_bytes(&s->pending_principal,sizeof(s->pending_principal));
|
||||
}
|
||||
boot(); WS_UserAuthData a=key(true); ssh_slot_t *s=fresh(0);
|
||||
for(unsigned i=0;i<3;++i) {
|
||||
assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
|
||||
assert(authentication_result(WOLFSSH_USERAUTH_FAILURE,&a,s)==WS_SUCCESS);
|
||||
assert(s->close_requested==(i==2));
|
||||
}
|
||||
assert(s_counters.authentication_limit_disconnects==1);
|
||||
puts("PASS signed authorization, result/currentness failures, success, exactly-once completion and limits");
|
||||
}
|
||||
static void fences_and_probes(void) {
|
||||
boot(); WS_UserAuthData a=key(false);
|
||||
for(unsigned i=0;i<12;++i) {
|
||||
ssh_slot_t *s=fresh(i%2); db_accept=i%2==0;
|
||||
assert(auth(s,&a)==(db_accept ? WOLFSSH_USERAUTH_SUCCESS : WOLFSSH_USERAUTH_INVALID_PUBLICKEY));
|
||||
assert(!s->awaiting_auth_result && !s->authenticated && !s->authentication_attempts);
|
||||
}
|
||||
ssh_slot_t *s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
|
||||
assert(key_calls==12 && s_counters.authentication_attempts==0);
|
||||
assert(s_counters.authentication_probe_admissions==12 && s_counters.authentication_probe_rejections==1);
|
||||
now_us=4999999; s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
|
||||
now_us=5000000; db_accept=true; s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
|
||||
s=fresh(1); assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED && key_calls==13);
|
||||
a=key(true); s=fresh(0); assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
|
||||
assert(s_counters.authentication_admissions==1); /* Probe pool independent. */
|
||||
for(unsigned mode=0;mode<7;++mode) {
|
||||
boot(); s=fresh(0); a=key(true);
|
||||
if(mode<3) assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
|
||||
if(mode==0) s->close_requested=true;
|
||||
if(mode==1) s->state=SSH_TRANSPORT_SESSION_CLOSING;
|
||||
if(mode==2) { assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED); }
|
||||
if(mode==4) a.sf.publicKey.hasSignature=0;
|
||||
if(mode==5) a=password();
|
||||
assert(authentication_result(WOLFSSH_USERAUTH_SUCCESS,mode==6 ? NULL : &a,s)==WS_ERROR);
|
||||
assert(!s->authenticated && !s->principal_valid && !s->awaiting_auth_result);
|
||||
assert(s_counters.authentication_attempts==0 && current_calls==0);
|
||||
assert(auth(s,&a)==WOLFSSH_USERAUTH_REJECTED);
|
||||
}
|
||||
boot(); s=fresh(0); a=password();
|
||||
assert(authenticate_user(99,&a,s)==WOLFSSH_USERAUTH_REJECTED);
|
||||
assert(s_counters.authentication_method_rejections==1 && !password_calls);
|
||||
assert(allowed_auth_types(NULL,NULL)==(WOLFSSH_USERAUTH_PASSWORD|WOLFSSH_USERAUTH_PUBLICKEY));
|
||||
boot(); s=fresh(0); a=key(true); assert(auth(s,&a)==WOLFSSH_USERAUTH_SUCCESS);
|
||||
WS_UserAuthData_Keyboard keyboard; memset(&keyboard,0xa5,sizeof(keyboard));
|
||||
assert(reject_keyboard_auth(&keyboard,s)==WS_ERROR); zero_bytes(&keyboard,sizeof(keyboard));
|
||||
assert(s->close_requested && !s->pending_principal_valid && !s->awaiting_auth_result);
|
||||
assert(s_counters.authentication_method_rejections==1 && !s_counters.authentication_attempts);
|
||||
assert(reject_keyboard_auth(NULL,s)==WS_ERROR && s_counters.authentication_method_rejections==1);
|
||||
assert(reject_keyboard_auth(NULL,NULL)==WS_ERROR);
|
||||
puts("PASS bounded unsigned probes, independent pools, result/close fences and keyboard decline without prompts");
|
||||
}
|
||||
static void admissions_lifecycle(void) {
|
||||
boot(); assert(start_runtime()==ESP_OK);
|
||||
accepts_remaining=3; accept_connections();
|
||||
assert(allocations==2 && s_counters.capacity_rejections==1);
|
||||
assert(s_counters.handshake_admissions==2);
|
||||
assert(s_slots[0].generation==1 && s_slots[1].generation==1);
|
||||
assert(cleanup_slot(&s_slots[0]) && cleanup_slot(&s_slots[1]));
|
||||
allocation_fail=true; accepts_remaining=4; accept_connections();
|
||||
assert(allocations==6 && s_counters.handshake_admissions==6 && s_counters.handshake_failures==4);
|
||||
accepts_remaining=1; accept_connections();
|
||||
assert(allocations==6 && s_counters.handshake_throttle_rejections==1);
|
||||
WS_UserAuthData p=password(), k=key(false); db_accept=false;
|
||||
for(unsigned i=0;i<6;++i) assert(auth(fresh(i%2),&p)==WOLFSSH_USERAUTH_INVALID_PASSWORD);
|
||||
for(unsigned i=0;i<12;++i) assert(auth(fresh(i%2),&k)==WOLFSSH_USERAUTH_INVALID_PUBLICKEY);
|
||||
ssh_auth_policy_t saved=s_auth_policy;
|
||||
assert(stop_runtime()==ESP_OK && start_runtime()==ESP_OK);
|
||||
assert(memcmp(&saved,&s_auth_policy,sizeof(saved))==0);
|
||||
s_initialized=false; assert(ssh_transport_clear_counters()==ESP_ERR_INVALID_STATE);
|
||||
s_initialized=true; assert(ssh_transport_clear_counters()==ESP_OK);
|
||||
zero_bytes(&s_counters,sizeof(s_counters));
|
||||
assert(memcmp(&saved,&s_auth_policy,sizeof(saved))==0);
|
||||
accepts_remaining=1; accept_connections(); assert(allocations==6);
|
||||
assert(auth(fresh(0),&p)==WOLFSSH_USERAUTH_REJECTED);
|
||||
assert(auth(fresh(1),&k)==WOLFSSH_USERAUTH_REJECTED);
|
||||
assert(s_counters.handshake_throttle_rejections==1 && s_counters.authentication_throttle_rejections==1 && s_counters.authentication_probe_rejections==1);
|
||||
assert(cleanup_slot(&s_slots[0]) && cleanup_slot(&s_slots[1]));
|
||||
now_us=9999999; accepts_remaining=1; accept_connections(); assert(allocations==6);
|
||||
now_us=10000000; allocation_fail=false; accepts_remaining=2; accept_connections();
|
||||
assert(allocations==7 && s_counters.handshake_admissions==1);
|
||||
uint64_t count=UINT64_MAX-1; add_counter(&count,1); assert(count==UINT64_MAX);
|
||||
add_counter(&count,1); assert(count==UINT64_MAX); count=1;
|
||||
add_counter(&count,UINT64_MAX); assert(count==UINT64_MAX);
|
||||
s_counters.authentication_attempts=UINT64_MAX;
|
||||
s_counters.authentication_failures=UINT64_MAX;
|
||||
ssh_slot_t *s=fresh(1); s->authentication_attempts=UINT8_MAX;
|
||||
assert(!complete_authentication_attempt(s,true));
|
||||
assert(s->authentication_attempts==UINT8_MAX && s_counters.authentication_attempts==UINT64_MAX && s_counters.authentication_failures==UINT64_MAX);
|
||||
puts("PASS actual accept loop capacity/budget, failed allocations consume admission, restart/clear retain all pools, saturation");
|
||||
}
|
||||
static void buffers(void) {
|
||||
boot(); ssh_slot_t *s=fresh(0); s->route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
|
||||
memcpy(s->rx_buffer,payload,sizeof(payload)); s->rx_length=sizeof(payload);
|
||||
feed_ok=true; feed_consumed=2; assert(flush_admin_input(s,0));
|
||||
zero_bytes(s->rx_buffer,2); assert(s->rx_offset==2 && !memcmp(s->rx_buffer+2,payload+2,6));
|
||||
feed_ok=false; feed_consumed=2; assert(flush_admin_input(s,0));
|
||||
zero_bytes(s->rx_buffer,4); assert(s->rx_offset==4 && !memcmp(s->rx_buffer+4,payload+4,4));
|
||||
assert(s_counters.rx_accepted_bytes==4 && !s_counters.admin_console_input_rejections);
|
||||
feed_consumed=0; assert(flush_admin_input(s,0)); assert(s->rx_offset==4 && s_counters.admin_console_input_rejections==1);
|
||||
feed_ok=true; feed_consumed=4; assert(flush_admin_input(s,0));
|
||||
zero_bytes(s->rx_buffer,sizeof(payload)); assert(!s->rx_length && !s->rx_offset);
|
||||
const int retry[]={0,WS_WANT_READ,WS_WANT_WRITE,WS_REKEYING,WS_WINDOW_FULL,WS_CHAN_RXD};
|
||||
for(unsigned route=SSH_TRANSPORT_ROUTE_BROKER;route<=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;++route) {
|
||||
s->route=route; memcpy(s->tx_buffer,payload,sizeof(payload)); s->tx_length=sizeof(payload); s->tx_offset=0;
|
||||
for(size_t i=0;i<sizeof(retry)/sizeof(retry[0]);++i) {
|
||||
send_result=retry[i]; ssh_error=0; assert(flush_client_output(s));
|
||||
assert(!s->tx_offset && s->tx_length==sizeof(payload) && !memcmp(s->tx_buffer,payload,sizeof(payload)));
|
||||
}
|
||||
send_result=3; assert(flush_client_output(s)); assert(s->tx_offset==3);
|
||||
if(route==SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) zero_bytes(s->tx_buffer,3);
|
||||
else assert(!memcmp(s->tx_buffer,payload,3));
|
||||
assert(!memcmp(s->tx_buffer+3,payload+3,5));
|
||||
send_result=WS_ERROR; ssh_error=WS_WANT_WRITE; assert(flush_client_output(s)); assert(s->tx_offset==3);
|
||||
ssh_error=WS_ERROR; assert(!flush_client_output(s)); assert(!memcmp(s->tx_buffer+3,payload+3,5));
|
||||
send_result=5; assert(flush_client_output(s)); assert(!s->tx_offset && !s->tx_length);
|
||||
if(route==SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) zero_bytes(s->tx_buffer,sizeof(payload));
|
||||
else assert(!memcmp(s->tx_buffer,payload,sizeof(payload)));
|
||||
}
|
||||
for(size_t i=0;i<sizeof(retry)/sizeof(retry[0]);++i) {
|
||||
read_result=retry[i]; ssh_error=0; assert(receive_admin_input(s,0)); assert(!s->rx_length);
|
||||
assert(receive_client_input(s));
|
||||
}
|
||||
read_result=WS_ERROR; ssh_error=WS_ERROR; assert(!receive_admin_input(s,0)); assert(!receive_client_input(s));
|
||||
read_result=sizeof(payload); feed_consumed=3; feed_ok=false; assert(receive_admin_input(s,0));
|
||||
zero_bytes(s->rx_buffer,3); assert(s->rx_offset==3 && !memcmp(s->rx_buffer+3,payload+3,5));
|
||||
feed_consumed=5; assert(receive_admin_input(s,0)); zero_bytes(s->rx_buffer,sizeof(payload));
|
||||
s->route=SSH_TRANSPORT_ROUTE_BROKER; s->writer=true; broker_accepted=3;
|
||||
assert(receive_client_input(s)); assert(s->rx_offset==3 && !memcmp(s->rx_buffer,payload,sizeof(payload)));
|
||||
broker_error=ESP_ERR_TIMEOUT; broker_accepted=0; assert(flush_client_input(s)); assert(s->rx_offset==3);
|
||||
broker_error=ESP_OK; broker_accepted=5; assert(flush_client_input(s)); assert(!s->rx_length && !memcmp(s->rx_buffer,payload,sizeof(payload)));
|
||||
puts("PASS consumed admin RX including false+consumed, retry/partial TX wiping, binary serial buffers unchanged");
|
||||
}
|
||||
static void retirement(void) {
|
||||
boot(); ssh_slot_t *s=&s_slots[0]; memset(s,0xa5,sizeof(*s));
|
||||
s->generation=0x12345678; s->socket_fd=42; s->ssh=(WOLFSSH *)&s_lock;
|
||||
s->route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE; s->broker_client_id=7;
|
||||
broker_error=ESP_FAIL; assert(!cleanup_slot(s));
|
||||
assert(s->state==SSH_TRANSPORT_SESSION_CLOSING && s->generation==0x12345678);
|
||||
assert(s->socket_fd==-1 && s->ssh==NULL && frees==1);
|
||||
broker_error=ESP_OK; assert(cleanup_slot(s));
|
||||
assert(wipe_address==s && wipe_size==sizeof(*s));
|
||||
ssh_slot_t expected; memset(&expected,0,sizeof(expected));
|
||||
expected.state=SSH_TRANSPORT_SESSION_FREE; expected.generation=0x12345678; expected.socket_fd=-1;
|
||||
assert(!memcmp(s,&expected,sizeof(*s)) && frees==1 && close_calls==1);
|
||||
assert(cleanup_slot(s)); assert(!memcmp(s,&expected,sizeof(*s)));
|
||||
puts("PASS retirement whole-slot wipe, exact generation and fd=-1 sentinel, deferred broker cleanup");
|
||||
}
|
||||
int main(void) {
|
||||
passwords(); signed_keys(); fences_and_probes(); admissions_lifecycle(); buffers(); retirement();
|
||||
puts("PASS SSH transport extracted-production host suite");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Pinned wolfSSH authentication control-flow contract
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
CCACHE_DISABLE=1 python3 tests/wolfssh_auth_contract/run.py
|
||||
```
|
||||
|
||||
Requires Python 3, a host C compiler (`CC`, default `cc`), installed managed
|
||||
wolfSSH, and an existing firmware compilation database/toolchain. No packages
|
||||
are downloaded and no firmware build or device commands run. Generated C and
|
||||
the executable live in a temporary directory and are removed on exit. Compile,
|
||||
preprocess and execution subprocesses have 30/30/10-second limits.
|
||||
|
||||
The runner prefers the sole `.pio/build/*/compile_commands.json`, otherwise the
|
||||
root database. Select another existing database with `--compile-commands PATH`.
|
||||
It preprocesses the actual wolfSSH `internal.c` compile command (`-E -dM`) and
|
||||
checks this reviewed profile:
|
||||
|
||||
- `LIBWOLFSSH_VERSION_HEX == 0x01004020` (1.4.20).
|
||||
- RSA disabled; ECDSA and Ed25519 not disabled.
|
||||
- Certificates, `none` authentication and `NO_FAILURE_ON_REJECTED` not defined.
|
||||
|
||||
For hosts without the ESP compiler/database, explicitly use `--host-only`.
|
||||
This prints a **SKIP** for production feature verification; it still checks the
|
||||
source/version/pin and executes the host contract with the reviewed feature
|
||||
profile. A stale compilation database is not proof of the next firmware build's
|
||||
configuration.
|
||||
|
||||
## What executes
|
||||
|
||||
`run.py` checks the exact application wolfSSH pin, installed version header and
|
||||
reviewed SHA-256 of `managed_components/wolfssl__wolfssh/src/internal.c`:
|
||||
|
||||
```text
|
||||
81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9
|
||||
```
|
||||
|
||||
Any same-version source change fails before compilation. **Re-audit before
|
||||
updating this hash**; do not automatically bless a dependency update.
|
||||
|
||||
The runner extracts actual function definitions by balancing braces after
|
||||
masking comments/string literals. It does not rewrite their bodies:
|
||||
|
||||
- `GetBoolean`, `GetUint32`, `GetSize`, `GetStringRef`
|
||||
- `DoUserAuthRequestPassword`, `DoUserAuthRequestPublicKey`, `DoUserAuthRequest`
|
||||
- `SendUserAuthKeyboardRequest`, `GetAllowedAuth`
|
||||
- `SendChannelData`
|
||||
|
||||
Callback data structures, auth result constants and method masks are extracted
|
||||
from the installed public header. `contract.c` supplies small session/context
|
||||
models, name/algorithm lookup, crypto and packet-output doubles. Binary request
|
||||
fixtures execute the extracted parsers; ordered event traces assert callback,
|
||||
hashing/signature and response order, rather than inspecting source substrings.
|
||||
|
||||
The 35 cases cover:
|
||||
|
||||
- Ed25519 and ECDSA: signed authorization rejection (`INVALID_PUBLICKEY`,
|
||||
`FAILURE`, `REJECTED`, `INVALID_USER`, `INVALID_AUTHTYPE`) never hashes,
|
||||
verifies or calls the result callback.
|
||||
- Both unsigned probe outcomes: no signature work/result callback; an accepted
|
||||
probe sends PK_OK but does not complete authentication.
|
||||
- Bad signatures, good signatures, success-result veto, ignored failure-result
|
||||
callback return, and auth `WOULD_BLOCK`.
|
||||
- Password success/failure, rejected password change, and the installed parser's
|
||||
callback on a truncated new-password-length field. No password result callback.
|
||||
- Disabled `none`, unknown methods/key algorithms and truncated signed framing.
|
||||
- Direct keyboard-interactive dispatch invokes a **registered non-NULL rejecting
|
||||
prompt callback**, returns error and purges without preparing/building/sending
|
||||
a prompt. The actual library still writes the message-ID byte into its existing
|
||||
output buffer on this path; the test models that buffer and checks this detail.
|
||||
- The actual advertised-method builder excludes keyboard despite the registered
|
||||
keyboard callback, because the allowed-types callback overrides the defaults.
|
||||
- Actual `SendChannelData` copies the bounded consumed prefix before returning a
|
||||
positive count, both on send success and `WS_WANT_WRITE`. Wiping that caller
|
||||
prefix leaves the library copy intact. A blocked flush of earlier data returns
|
||||
a negative code without consuming new data.
|
||||
|
||||
## Limits / ownership
|
||||
|
||||
This is a library parser/control-flow regression, **not application callback
|
||||
integration coverage**. Its rejecting keyboard callback models the parent's
|
||||
registration and return policy; it does not prove production registration,
|
||||
admission counters, awaiting-result state, principal promotion, or admin wiping.
|
||||
Those belong to the separate application unit suite.
|
||||
|
||||
Crypto helpers are instrumented doubles; algorithm name lookup is limited to
|
||||
fixture names. Packet construction/network send, hashing and session internals
|
||||
are modeled. This does not validate cryptographic correctness, encrypted packet
|
||||
decoding, real sockets, asynchronous re-entry, complete malformed-input safety,
|
||||
allocation failure, memory erasure throughout wolfSSH, or device behavior. The
|
||||
send test proves only the extracted copy/consumed control flow with successful
|
||||
packet preparation/bundling and the specified send outcomes—not the entire
|
||||
admin transmit loop or TLS/SSH buffer lifecycle.
|
||||
|
||||
All Phase9 hardware validation remains deferred to the combined phase.
|
||||
@@ -0,0 +1,246 @@
|
||||
/* Control-flow doubles only: no cryptographic implementation or real credentials. */
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef uint8_t byte;
|
||||
typedef uint32_t word32;
|
||||
#include "auth_types.h"
|
||||
|
||||
/* Reviewed production feature profile. Never enable certificates/none silently. */
|
||||
#define WOLFSSH_NO_RSA
|
||||
#if defined(WOLFSSH_CERTS) || defined(WOLFSSH_ALLOW_USERAUTH_NONE) || \
|
||||
defined(WOLFSSH_NO_ECDSA) || defined(WOLFSSH_NO_ED25519) || \
|
||||
defined(NO_FAILURE_ON_REJECTED)
|
||||
#error "Unexpected wolfSSH auth-contract feature profile"
|
||||
#endif
|
||||
#define WLOG(...) ((void)0)
|
||||
#define WMEMSET memset
|
||||
#define WMEMCPY memcpy
|
||||
#define WSTRNCAT strncat
|
||||
#define XSTRLEN strlen
|
||||
#define BOOLEAN_SZ 1
|
||||
#define UINT32_SZ 4
|
||||
#define LENGTH_SZ 4
|
||||
#define MSG_ID_SZ 1
|
||||
#define MAX_AUTH_STRING 80
|
||||
#define WOLFSSH_MAX_PROMPTS 8
|
||||
#define WC_MAX_DIGEST_SIZE 64
|
||||
#define min(a,b) ((a) < (b) ? (a) : (b))
|
||||
enum { WS_SUCCESS = 0, WS_ERROR = -1, WS_BUFFER_E = -2,
|
||||
WS_BAD_ARGUMENT = -3, WS_USER_AUTH_E = -4, WS_AUTH_PENDING = -5,
|
||||
WS_INVALID_ALGO_ID = -6, WS_CRYPTO_FAILED = -7, WS_BAD_USAGE = -8,
|
||||
WS_WANT_WRITE = -9, WS_REKEYING = -10, WS_INVALID_CHANID = -11,
|
||||
WS_WINDOW_FULL = -12 };
|
||||
|
||||
enum { ID_NONE, ID_UNKNOWN, ID_USERAUTH_PASSWORD, ID_USERAUTH_KEYBOARD,
|
||||
ID_USERAUTH_PUBLICKEY, ID_ED25519, ID_ECDSA_SHA2_NISTP256,
|
||||
ID_ECDSA_SHA2_NISTP384, ID_ECDSA_SHA2_NISTP521 };
|
||||
enum { WOLFSSH_ENDPOINT_SERVER, CLIENT_USERAUTH_DONE = 20,
|
||||
MSGID_USERAUTH_REQUEST, MSGID_USERAUTH_INFO_REQUEST, MSGID_CHANNEL_DATA,
|
||||
WS_CHANNEL_ID_SELF };
|
||||
enum wc_HashType { WC_HASH_TYPE_SHA };
|
||||
typedef int wc_HashAlg;
|
||||
typedef struct WOLFSSH WOLFSSH;
|
||||
typedef struct {
|
||||
int (*userAuthCb)(byte, WS_UserAuthData *, void *);
|
||||
int (*userAuthResultCb)(byte, WS_UserAuthData *, void *);
|
||||
int (*keyboardAuthCb)(WS_UserAuthData_Keyboard *, void *);
|
||||
int (*userAuthTypesCb)(WOLFSSH *, void *);
|
||||
} WOLFSSH_CTX;
|
||||
struct WOLFSSH {
|
||||
WOLFSSH_CTX *ctx;
|
||||
void *userAuthCtx, *userAuthResultCtx, *keyboardAuthCtx;
|
||||
int clientState, isKeying, error;
|
||||
byte sessionId[32];
|
||||
word32 sessionIdSz;
|
||||
struct { byte *buffer; word32 length, plainSz; } outputBuffer;
|
||||
struct { word32 promptCount; } kbAuth;
|
||||
};
|
||||
typedef struct {
|
||||
word32 peerWindowSz, peerMaxPacketSz, maxPacketSz, peerChannel;
|
||||
} WOLFSSH_CHANNEL;
|
||||
static WOLFSSH_CHANNEL channel;
|
||||
static const byte cannedKeyAlgoClient[] = { ID_ED25519, ID_ECDSA_SHA2_NISTP256 };
|
||||
static const word32 cannedKeyAlgoClientSz = sizeof(cannedKeyAlgoClient);
|
||||
static char events[64];
|
||||
static unsigned event_count, groups;
|
||||
static int auth_return, crypto_return, result_return, send_return;
|
||||
static int expect_new_password;
|
||||
static void event(char c) { assert(event_count + 1 < sizeof(events)); events[event_count++] = c; }
|
||||
static void ato32(const byte *b, word32 *v) {
|
||||
*v = (word32)b[0] << 24 | (word32)b[1] << 16 | (word32)b[2] << 8 | b[3];
|
||||
}
|
||||
static void c32toa(word32 v, byte *b) {
|
||||
b[0] = v >> 24; b[1] = v >> 16; b[2] = v >> 8; b[3] = v;
|
||||
}
|
||||
static byte NameToId(const char *s, word32 n) {
|
||||
static const struct { const char *s; byte id; } names[] = {
|
||||
{"none", ID_NONE}, {"password", ID_USERAUTH_PASSWORD},
|
||||
{"keyboard-interactive", ID_USERAUTH_KEYBOARD}, {"publickey", ID_USERAUTH_PUBLICKEY},
|
||||
{"ssh-ed25519", ID_ED25519}, {"ecdsa-sha2-nistp256", ID_ECDSA_SHA2_NISTP256}
|
||||
};
|
||||
for (unsigned i = 0; i < sizeof(names)/sizeof(names[0]); ++i)
|
||||
if (strlen(names[i].s) == n && !memcmp(s, names[i].s, n)) return names[i].id;
|
||||
return ID_UNKNOWN;
|
||||
}
|
||||
static byte MatchIdLists(int side, const byte *id, word32 count, const byte *list, word32 n) {
|
||||
for (word32 i = 0; i < n; ++i) if (*id == list[i]) return *id;
|
||||
return ID_UNKNOWN;
|
||||
}
|
||||
static int wolfSSH_SetUsernameRaw(WOLFSSH *s, const byte *u, word32 n) { return WS_SUCCESS; }
|
||||
static int SendUserAuthFailure(WOLFSSH *s, byte partial) { event('F'); return WS_SUCCESS; }
|
||||
static int SendUserAuthPkOk(WOLFSSH *s, const byte *a, word32 an, const byte *k, word32 kn) {
|
||||
event('P'); return WS_SUCCESS;
|
||||
}
|
||||
static int DoUserAuthRequestEd25519(WOLFSSH *s, WS_UserAuthData_PublicKey *p, WS_UserAuthData *a) {
|
||||
event('C'); return crypto_return;
|
||||
}
|
||||
static int DoUserAuthRequestEcc(WOLFSSH *s, WS_UserAuthData_PublicKey *p,
|
||||
enum wc_HashType h, byte *d, word32 n) {
|
||||
event('C'); return crypto_return;
|
||||
}
|
||||
static enum wc_HashType HashForId(byte id) { return WC_HASH_TYPE_SHA; }
|
||||
static int wc_HashGetDigestSize(enum wc_HashType h) { return 32; }
|
||||
static int wc_HashInit(wc_HashAlg *h, enum wc_HashType id) { event('H'); return 0; }
|
||||
static int HashUpdate(wc_HashAlg *h, enum wc_HashType id, const byte *b, word32 n) { return 0; }
|
||||
static int wc_HashFinal(wc_HashAlg *h, enum wc_HashType id, byte *b) { return 0; }
|
||||
static void wc_HashFree(wc_HashAlg *h, enum wc_HashType id) {}
|
||||
static int PrepareUserAuthRequestKeyboard(WOLFSSH *s, word32 *n, WS_UserAuthData *a) {
|
||||
event('Q'); return WS_SUCCESS;
|
||||
}
|
||||
static int BuildUserAuthRequestKeyboard(WOLFSSH *s, byte *b, word32 *n, WS_UserAuthData *a) {
|
||||
event('B'); return WS_SUCCESS;
|
||||
}
|
||||
static int PreparePacket(WOLFSSH *s, word32 n) { event('T'); return WS_SUCCESS; }
|
||||
static int BundlePacket(WOLFSSH *s) { event('B'); return WS_SUCCESS; }
|
||||
static int wolfSSH_SendPacket(WOLFSSH *s) { event('S'); s->error = send_return; return send_return; }
|
||||
static void PurgePacket(WOLFSSH *s) { event('X'); }
|
||||
static WOLFSSH_CHANNEL *ChannelFind(WOLFSSH *s, word32 id, int kind) { return &channel; }
|
||||
|
||||
#include "actual.c"
|
||||
|
||||
static int authorize(byte method, WS_UserAuthData *a, void *ctx) {
|
||||
event('A');
|
||||
assert(method == a->type);
|
||||
assert(a->usernameSz == 4 && !memcmp(a->username, "test", 4));
|
||||
if (method == WOLFSSH_USERAUTH_PASSWORD) {
|
||||
assert(a->sf.password.hasNewPassword == expect_new_password);
|
||||
assert(a->sf.password.passwordSz == 5);
|
||||
assert(!memcmp(a->sf.password.password, "dummy", 5));
|
||||
}
|
||||
return auth_return;
|
||||
}
|
||||
static int result(byte outcome, WS_UserAuthData *a, void *ctx) {
|
||||
assert(a->type == WOLFSSH_USERAUTH_PUBLICKEY && a->sf.publicKey.hasSignature);
|
||||
event(outcome == WOLFSSH_USERAUTH_SUCCESS ? 'R' : 'r');
|
||||
return result_return;
|
||||
}
|
||||
/* Models the parent's registered rejecting prompt callback, not app accounting. */
|
||||
static int reject_keyboard(WS_UserAuthData_Keyboard *k, void *ctx) {
|
||||
event('K'); memset(k, 0, sizeof(*k)); return WS_ERROR;
|
||||
}
|
||||
static int allowed(WOLFSSH *s, void *ctx) {
|
||||
return WOLFSSH_USERAUTH_PASSWORD | WOLFSSH_USERAUTH_PUBLICKEY;
|
||||
}
|
||||
static byte output[1024], packet[1024];
|
||||
static word32 length;
|
||||
static WOLFSSH_CTX context = { authorize, result, reject_keyboard, allowed };
|
||||
static WOLFSSH ssh;
|
||||
static void reset(void) {
|
||||
memset(&ssh, 0, sizeof(ssh)); memset(output, 0, sizeof(output));
|
||||
memset(events, 0, sizeof(events)); event_count = 0;
|
||||
ssh.ctx = &context; ssh.outputBuffer.buffer = output; ssh.sessionIdSz = 32;
|
||||
auth_return = WOLFSSH_USERAUTH_SUCCESS; crypto_return = WS_SUCCESS;
|
||||
result_return = WS_SUCCESS; send_return = WS_SUCCESS; expect_new_password = 0;
|
||||
length = 0;
|
||||
}
|
||||
static void blob(const void *s, word32 n) {
|
||||
assert(length + 4 + n <= sizeof(packet));
|
||||
c32toa(n, packet + length); length += 4;
|
||||
memcpy(packet + length, s, n); length += n;
|
||||
}
|
||||
static void string(const char *s) { blob(s, (word32)strlen(s)); }
|
||||
static void request(const char *method) { string("test"); string("ssh-connection"); string(method); }
|
||||
static void key_request(int signed_key, const char *algorithm) {
|
||||
request("publickey"); packet[length++] = signed_key; string(algorithm);
|
||||
byte nested[128]; word32 n = (word32)strlen(algorithm);
|
||||
c32toa(n, nested); memcpy(nested + 4, algorithm, n); nested[4 + n] = 42;
|
||||
blob(nested, n + 5);
|
||||
if (signed_key) {
|
||||
c32toa(1, nested + 4 + n); nested[8 + n] = 42;
|
||||
blob(nested, n + 9);
|
||||
}
|
||||
}
|
||||
static int dispatch(void) { word32 idx = 0; return DoUserAuthRequest(&ssh, packet, length, &idx); }
|
||||
static void check(const char *trace, int done) {
|
||||
assert(!strcmp(events, trace));
|
||||
assert((ssh.clientState == CLIENT_USERAUTH_DONE) == done); ++groups;
|
||||
}
|
||||
int main(void) {
|
||||
const int rejected[] = { WOLFSSH_USERAUTH_INVALID_PUBLICKEY,
|
||||
WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_REJECTED,
|
||||
WOLFSSH_USERAUTH_INVALID_USER, WOLFSSH_USERAUTH_INVALID_AUTHTYPE };
|
||||
const char *algorithms[] = { "ssh-ed25519", "ecdsa-sha2-nistp256" };
|
||||
for (unsigned a = 0; a < 2; ++a) {
|
||||
for (unsigned r = 0; r < sizeof(rejected)/sizeof(rejected[0]); ++r) {
|
||||
reset(); key_request(1, algorithms[a]); auth_return = rejected[r];
|
||||
assert(dispatch() == WS_SUCCESS); check("AF", 0);
|
||||
}
|
||||
reset(); key_request(0, algorithms[a]); assert(dispatch() == WS_SUCCESS); check("AP", 0);
|
||||
reset(); key_request(0, algorithms[a]); auth_return = WOLFSSH_USERAUTH_INVALID_PUBLICKEY;
|
||||
assert(dispatch() == WS_SUCCESS); check("AF", 0);
|
||||
reset(); key_request(1, algorithms[a]); crypto_return = WS_CRYPTO_FAILED;
|
||||
result_return = WS_ERROR; /* Failure-result return is ignored. */
|
||||
assert(dispatch() == WS_SUCCESS); check(a ? "AHCrF" : "ACrF", 0);
|
||||
reset(); key_request(1, algorithms[a]); assert(dispatch() == WS_SUCCESS);
|
||||
check(a ? "AHCR" : "ACR", 1);
|
||||
reset(); key_request(1, algorithms[a]); result_return = WS_ERROR;
|
||||
assert(dispatch() == WS_SUCCESS); check(a ? "AHCRF" : "ACRF", 0);
|
||||
reset(); key_request(1, algorithms[a]); auth_return = WOLFSSH_USERAUTH_WOULD_BLOCK;
|
||||
assert(dispatch() == WS_AUTH_PENDING); check("A", 0);
|
||||
}
|
||||
for (int fail = 0; fail < 2; ++fail) {
|
||||
reset(); request("password"); packet[length++] = 0; string("dummy");
|
||||
auth_return = fail ? WOLFSSH_USERAUTH_INVALID_PASSWORD : WOLFSSH_USERAUTH_SUCCESS;
|
||||
assert(dispatch() == WS_SUCCESS); check(fail ? "AF" : "A", !fail);
|
||||
}
|
||||
reset(); request("password"); packet[length++] = 1; string("dummy"); string("new-dummy");
|
||||
expect_new_password = 1; auth_return = WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
|
||||
assert(dispatch() == WS_SUCCESS); check("AF", 0);
|
||||
/* Actual parser still calls auth when the new-password length is truncated. */
|
||||
reset(); request("password"); packet[length++] = 1; string("dummy");
|
||||
expect_new_password = 1; auth_return = WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
|
||||
assert(dispatch() == WS_SUCCESS); check("AF", 0);
|
||||
const char *unsupported[] = { "none", "unrecognized" };
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
reset(); request(unsupported[i]); assert(dispatch() == WS_SUCCESS); check("F", 0);
|
||||
}
|
||||
reset(); key_request(1, "unsupported-key"); assert(dispatch() == WS_SUCCESS); check("F", 0);
|
||||
reset(); key_request(1, "ssh-ed25519"); --length;
|
||||
assert(dispatch() == WS_BUFFER_E); check("", 0);
|
||||
reset(); request("keyboard-interactive"); string(""); string("");
|
||||
assert(context.keyboardAuthCb != NULL); assert(dispatch() == WS_ERROR); check("KX", 0);
|
||||
/* The library writes the message byte even on rejection, but does not send. */
|
||||
assert(ssh.outputBuffer.length == 0 && output[0] == MSGID_USERAUTH_INFO_REQUEST);
|
||||
char methods[MAX_AUTH_STRING]; int n = GetAllowedAuth(&ssh, methods);
|
||||
methods[n] = '\0'; assert(!strcmp(methods, "publickey,password")); ++groups;
|
||||
|
||||
/* Execute actual copy/positive-consumed path, including deferred network send. */
|
||||
for (int deferred = 0; deferred < 2; ++deferred) {
|
||||
reset(); byte data[] = { 11, 22, 33, 44, 55 };
|
||||
channel = (WOLFSSH_CHANNEL){ 100, 3, 10, 7 };
|
||||
send_return = deferred ? WS_WANT_WRITE : WS_SUCCESS;
|
||||
assert(SendChannelData(&ssh, 1, data, sizeof(data)) == 3);
|
||||
assert(!memcmp(output + 9, data, 3)); memset(data, 0, 3);
|
||||
assert(output[9] == 11 && output[10] == 22 && output[11] == 33);
|
||||
assert(data[3] == 44 && channel.peerWindowSz == 97);
|
||||
assert(ssh.outputBuffer.plainSz == (deferred ? 3U : 0U)); check("TBS", 0);
|
||||
}
|
||||
reset(); ssh.outputBuffer.plainSz = 2; send_return = WS_WANT_WRITE;
|
||||
byte data[] = { 1, 2 }; assert(SendChannelData(&ssh, 1, data, 2) == WS_WANT_WRITE);
|
||||
assert(output[9] == 0); check("S", 0);
|
||||
printf("PASS: %u actual wolfSSH parser/control-flow cases\n", groups);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute extracted, hash-pinned wolfSSH control flow; no downloads or build writes."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
VENDOR = ROOT / "managed_components/wolfssl__wolfssh"
|
||||
REVIEWED_SHA256 = "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9"
|
||||
|
||||
|
||||
def extract(source, name):
|
||||
# Mask comments/strings without changing offsets, then balance actual braces.
|
||||
masked = re.sub(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'',
|
||||
lambda m: " " * len(m[0]), source, flags=re.S)
|
||||
matches = list(re.finditer(r"(?m)^(?:static )?(?:int|void|byte|word32)\s+" +
|
||||
re.escape(name) + r"\s*\([^;{}]*\)\s*\{", masked))
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(f"Expected one definition of {name}, found {len(matches)}")
|
||||
start = matches[0].start()
|
||||
brace = masked.index("{", start)
|
||||
depth = 1
|
||||
end = brace + 1
|
||||
while depth:
|
||||
depth += (masked[end] == "{") - (masked[end] == "}")
|
||||
end += 1
|
||||
return source[start:end] + "\n"
|
||||
|
||||
|
||||
def check_build_profile(database):
|
||||
entries = json.loads(database.read_text())
|
||||
entry = next(e for e in entries if Path(e["file"]).resolve() ==
|
||||
(VENDOR / "src/internal.c").resolve())
|
||||
args = entry.get("arguments") or shlex.split(entry["command"])
|
||||
# Strip output/dependency-writing flags: this must only preprocess to stdout.
|
||||
clean = []
|
||||
skip = False
|
||||
for arg in args:
|
||||
if skip:
|
||||
skip = False
|
||||
elif arg in ("-o", "-MF", "-MT", "-MQ"):
|
||||
skip = True
|
||||
elif arg not in ("-c", "-MD", "-MMD", "-MP"):
|
||||
clean.append(arg)
|
||||
result = subprocess.run(clean + ["-E", "-dM"], cwd=entry["directory"],
|
||||
capture_output=True, text=True, check=True, timeout=30,
|
||||
env={**os.environ, "CCACHE_DISABLE": "1"})
|
||||
macros = dict(re.findall(r'^#define (\w+)(?: (.*))?$', result.stdout, re.M))
|
||||
if macros.get("LIBWOLFSSH_VERSION_HEX") != "0x01004020":
|
||||
raise RuntimeError("Resolved wolfSSH version differs from reviewed version")
|
||||
absent = ("WOLFSSH_CERTS", "WOLFSSH_ALLOW_USERAUTH_NONE", "WOLFSSH_NO_ECDSA",
|
||||
"WOLFSSH_NO_ED25519", "NO_FAILURE_ON_REJECTED")
|
||||
if "WOLFSSH_NO_RSA" not in macros or any(m in macros for m in absent):
|
||||
raise RuntimeError("Resolved wolfSSH auth feature profile changed; re-audit")
|
||||
print("PASS: actual compiler preprocessing matches reviewed auth feature profile", flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
databases = sorted((ROOT / ".pio/build").glob("*/compile_commands.json"))
|
||||
default_database = databases[0] if len(databases) == 1 else ROOT / "compile_commands.json"
|
||||
parser.add_argument("--compile-commands", type=Path, default=default_database)
|
||||
parser.add_argument("--host-only", action="store_true",
|
||||
help="explicitly skip production compile-command feature verification")
|
||||
options = parser.parse_args()
|
||||
raw = (VENDOR / "src/internal.c").read_bytes()
|
||||
actual = hashlib.sha256(raw).hexdigest()
|
||||
if actual != REVIEWED_SHA256:
|
||||
raise RuntimeError(f"wolfSSH internal.c changed: {actual}; re-audit before updating hash")
|
||||
version = (VENDOR / "wolfssh/version.h").read_text()
|
||||
if not re.search(r'#define\s+LIBWOLFSSH_VERSION_HEX\s+0x01004020\b', version):
|
||||
raise RuntimeError("Expected wolfSSH 1.4.20 header")
|
||||
if not re.search(r'#define\s+LIBWOLFSSH_VERSION_STRING\s+"1\.4\.20"', version):
|
||||
raise RuntimeError("Unexpected wolfSSH version string")
|
||||
manifest = (ROOT / "src/idf_component.yml").read_text()
|
||||
if not re.search(r'^\s*wolfssl/wolfssh:\s*"1\.4\.20"\s*$', manifest, re.M):
|
||||
raise RuntimeError("Application must pin wolfSSH exactly to 1.4.20")
|
||||
|
||||
if options.host_only:
|
||||
print("SKIP: production feature verification (--host-only)", flush=True)
|
||||
else:
|
||||
check_build_profile(options.compile_commands)
|
||||
|
||||
source = raw.decode()
|
||||
# Use the installed public callback data layouts, not hand-maintained copies.
|
||||
header = (VENDOR / "wolfssh/ssh.h").read_text()
|
||||
types = header[header.index("typedef struct WS_UserAuthData_Password {"):
|
||||
header.index("} WS_UserAuthData;") + len("} WS_UserAuthData;")]
|
||||
results_start = header.index("enum WS_UserAuthResults")
|
||||
types += "\n" + header[results_start:header.index("};", results_start) + 2]
|
||||
types += "\n" + "\n".join(re.findall(
|
||||
r'^#define WOLFSSH_USERAUTH_(?:PASSWORD|PUBLICKEY|KEYBOARD|NONE)\s+.*$', header, re.M))
|
||||
names = ["GetBoolean", "GetUint32", "GetSize", "GetStringRef",
|
||||
"DoUserAuthRequestPassword", "DoUserAuthRequestPublicKey",
|
||||
"SendUserAuthKeyboardRequest", "DoUserAuthRequest", "GetAllowedAuth",
|
||||
"SendChannelData"]
|
||||
extracted = "\n".join(extract(source, name) for name in names)
|
||||
with tempfile.TemporaryDirectory(prefix="wolfssh-auth-contract-") as temp:
|
||||
temp = Path(temp)
|
||||
(temp / "auth_types.h").write_text(types)
|
||||
(temp / "actual.c").write_text(extracted)
|
||||
binary = temp / "contract"
|
||||
cc = shlex.split(os.environ.get("CC", "cc"))
|
||||
subprocess.run(cc + ["-std=c99", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-I", str(temp),
|
||||
str(HERE / "contract.c"), "-o", str(binary)],
|
||||
check=True, timeout=30, env={**os.environ, "CCACHE_DISABLE": "1"})
|
||||
subprocess.run([str(binary)], check=True, timeout=10)
|
||||
print("PASS: installed source SHA-256, version header and exact application pin")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user