5fccd5382447da62d66173c51a70b5faab3332cb
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5fccd53824 |
Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in a container through a real PTY, the real pump, the real loopback WebSocket with its token and origin checks, and a ClientWebSocket standing in for the page: the login banner arrives, typed input round-trips, and `stty size` reports the 100x30 the session asked for. The only untested link left is xterm drawing bytes it was handed. The WebView is de-risked on Windows, which was the plan's largest risk. Not by assertion: with the app running there is an established TCP connection from msedgewebview2 to the data plane port, so WebView2 launched, navigated to the loopback page, executed terminal.js, and completed the WebSocket handshake against the real token and origin checks. Linux remains unproven and the package's own release notes now corroborate the concern -- Linux uses a WPE backend, and it ships a NativeWebDialog described as useful where embedded WebViews may be unavailable. Two bugs found by building it, both of which would have shipped: - ShellStream.Write buffers and needs an explicit Flush. Without one a keystroke is accepted, reported as written, and never reaches the remote: the terminal displays output perfectly and simply stops responding to input. SSH.NET's own WriteLine flushes, which is why the earlier spike never hit it. Found by isolating the pump against real SSH and reading BytesRead=51 -- banner and prompt through, nothing after. - The Windows app manifest needs a supportedOS list, or Avalonia's native control host fails outright and the terminal never starts. Also fixed a genuinely flaky test I happened to catch: SyncCursorTests tampered with the *last* base64url character, whose low bits the decoder ignores when the input length is not a multiple of three -- so a tampered cursor sometimes decoded to identical bytes and verified. It failed roughly one run in thirty, depending on a random key. Now tampers the penultimate character, which is fully significant at every length; 40 consecutive runs are clean. xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather than built with npm, so a clean clone needs only the .NET SDK. Provenance and licences are recorded next to them, along with the UMD global names terminal.js depends on -- a bundle that switched to ES modules would load without error and leave Terminal undefined. The renderer acknowledges output from term.write's completion callback, not on receipt. Acknowledging early would return flow-control credit for bytes the screen has not caught up with, which is the one thing the credit window exists to measure. TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia dependency, and having it there is what let the end-to-end test exist at all. 404 tests pass, zero warnings on a clean rebuild, format clean. |
||
|
|
94f66be5e8 |
Add the OIDC client: PKCE loopback sign-in and the key binding flow
Authorization Code with PKCE on a loopback redirect, per RFC 6749, RFC 7636 and RFC 8252. Zero package references: the flow is fully specified, and the one thing a library would own for us -- nonce generation and validation -- is exactly what the key binding needs to control. Duende's OidcClient generates and validates its own nonce as an internal detail, and the binding requires the nonce be a specific value: the hash of the key statement being enrolled. Fighting that is worse than owning the flow. AuthorizeKeyBindingAsync is the client half of the primary trust anchor. It runs a second authorization with nonce set to the statement hash and prompt=login, so the ID token that returns is the provider's signature over exactly those public keys, attesting to a user present now rather than to a session opened at some unknown earlier time. It requests only openid -- a second refresh token would be one more long-lived credential for no benefit -- and rejects a token whose nonce is not the one it asked for, because enrolling that would store evidence verifying against keys we are not publishing. The nonce is read without validating the ID token's signature. Sanctioned by OIDC Core 3.1.3.7: for a token received by direct communication with the token endpoint, TLS server authentication may stand in for signature checking. That reasoning does not extend to another user's binding, which arrives via the DodoSSH server and must be verified against JWKS fetched directly -- the directory work in M3. Raw TcpListener rather than HttpListener for the redirect: an ephemeral port can be bound and read atomically instead of picking one and hoping it is still free, there is no HTTP.SYS URL-ACL question on Windows, and the whole surface is one request line. It answers 404 on other paths and keeps waiting, because a browser asks for /favicon.ico first and treating that as the callback would abort every sign-in. 127.0.0.1 rather than localhost: RFC 8252 permits either, but the name resolves through the hosts file. 20 tests, driving the real listener over TCP with a fake browser that actually fetches the redirect -- injecting a fabricated callback would skip the parsing, path filtering and response writing that can break. Mostly negative, because the loopback port is reachable by every local process: a response with the wrong state is rejected *and* never reaches the token endpoint, metadata declaring an issuer other than its own authority is rejected (RFC 8414 3.3, without which a mix-up attack works), a provider offering only 'plain' is fatal rather than a silent downgrade, and the verifier sent is checked against the challenge advertised so PKCE is not theatre that only fails in production. Two bugs caught by writing the tests: the authorize URL builder dropped client_id entirely after a refactor, and CancellationTokenSource.CancelAfter has no TimeProvider overload -- so the browser timeout is now constructed with the clock and a test can advance it instead of waiting five minutes. |
||
|
|
e65d738912 |
Add the client key hierarchy: bundle, master key, vault and item keys
Everything crypto.md section 3 describes below the identity key, which is what the desktop client needs before it can enroll or store anything. DshAad gives every descriptor in the specification a named constructor. The AAD binding is the most valuable structural property in the design -- it is what stops a server holding every ciphertext from pasting one row's bytes onto another, rolling a row back to a superseded generation, or replaying a revoked grant -- and all of it depends on callers getting purpose, resource type and ids right at every single call site. Hand-constructing descriptors makes that a matter of care; picking a method name makes it a matter of spelling. UserSecretBundle holds private keys in libsodium's guarded, mlocked allocations rather than a byte[], so they are not paged out and do not land in a core dump. They are created exportable, deliberately: re-wrapping the same bundle for a passphrase change or a new device needs to re-encode it, and the alternative -- a long-lived managed array so the keys need not be exportable -- keeps the identical secret in strictly worse memory. Every export is into a buffer zeroed before the method returns. Two spec changes, both found by implementing it, which is the argument for writing code before calling a spec frozen: - MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK cannot be expanded with SHA-512 at all. Widening it keeps the specified primitive; the alternatives were dropping to SHA-256 or adding an Extract step that conditions nothing. - The bundle encoding is a fixed 92-byte layout rather than canonical CBOR. Canonicality is not load-bearing here -- unlike a key statement the bundle is never hashed or signed, only encrypted -- so CBOR's one advantage does not apply, while its canonicalisation rules are a real source of cross-implementation disagreement. It also costs a dependency System.Formats.Cbor is not in the shared framework. Safe to change now and not later: no bundle has ever been stored. 53 new tests. The encoding is checked against an independent codec written in the test rather than by round-tripping production code against itself -- a round trip passes just as happily when both directions are wrong the same way, and this format cannot change after one bundle is stored. The pinned 92-byte hex constant is the golden vector for the layout. Most of the rest are negative, because a binding is only demonstrated by the substitutions that fail: a wrap for another user, a grant from a superseded generation, a payload pasted onto another item, a metadata blob offered as a payload, a version rolled back. |
||
|
|
885fb17bdc |
Clear the SSH gate: window-change reaches the remote, and licence as MIT
Licence is MIT, set solution-wide rather than only on the packable project: DodoSSH.Contracts is published so clients can build against it, and a package with no licence expression is one a corporate policy scanner rejects outright. The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0 exposes ShellStream.ChangeWindowSize, but a method existing is not the remote observing it, so the tests read `stty size` back from a real sshd after resizing rather than asserting the call did not throw. Repeated resizes each take effect too, which matters because dragging a window edge produces a stream of them. The IChannelSession fallback is not needed. Also verified against a real sshd: password and public-key auth, that the host key arrives as a raw blob we can fingerprint ourselves rather than reading SSH.NET's MD5 property, and that refusing the key via CanTrust actually aborts the connection -- without which the TOFU dialog would be decoration. Kept as a permanent suite, not deleted after the spike. An upgrade that silently stopped sending the request would present as wrapped output only after a resize, which is easy to misattribute to the terminal emulator. Two bugs in the test itself, both worth naming because either would have been read as "resize does not work": - A PTY emits CRLF, and the anchored regex rejected the CR. The output visibly contained `24 80` while the match failed. - Each read can begin with output still buffered from the previous command, including its size line. Taking the first match would have reported the pre-resize size. platform-flags.md now records window-change as resolved rather than unverified -- a stale flag is worse than none -- plus the three real SSH.NET limits found on the way: ShellStream does not override ReadAsync so every idle session parks a pool thread, one connection cannot serve both SshClient and SftpClient, and agent forwarding needs an upstream change. |
||
|
|
b7325b78ca |
Record the platform flags that were only in conversation
Development and testing are Windows-only, so anything unverified elsewhere needs to be written down or it gets assumed to work. Several of these have already cost time once: PostgreSQL 18 moving its data directory silently gives a carried-over compose file an empty volume, and a loopback-bound Tomcat beat Docker's 0.0.0.0 publish for `localhost`, making every Keycloak realm 404 while the container looked healthy. The largest entry is the Avalonia WebView on Linux, which remains the biggest risk in the plan and is why the terminal sits behind ITerminalHost. Also records two things this milestone deliberately left undone -- no rate limiting on the enrollment and sync write paths until M2, and /me not touching last_seen_at_utc -- so neither reads later as an oversight. |
||
|
|
d2a2ed8a29 |
Specify the key statement encoding and key log chain (crypto.md 7.1, 7.2)
Section 7 always required "a canonical, length-prefixed encoding" for signatures without ever specifying one. That gap had to be closed before enrollment could exist: the client hashes the key statement and uses the result as an OIDC nonce, so the provider signs over those exact bytes. Two implementations disagreeing by one byte produce two nonces and an enrollment nobody can verify -- and it only shows up against a real provider, never in a local test. JSON cannot be the hashed form. Property order, number formatting, Unicode escaping and whitespace all vary between serialisers. So the statement is transmitted as JSON and hashed as a fixed binary encoding, and the two are independent by construction. Three details are load-bearing rather than stylistic: - The presence byte before each string is what makes the encoding injective. Without it an absent email and an empty one encode identically, and two different statements share a binding. - Timestamps truncate to milliseconds. PostgreSQL stores microseconds, so a statement that has been through the database must still hash to what the client hashed. The same applies to the key log, where an entry that cannot reproduce its own hash after being read back makes the chain unverifiable. - The key log entry hash deliberately excludes the database sequence. It is unknown until the insert runs, and order already follows the hash links -- so a renumbered or gapped sequence column cannot silently reorder history. KeyStatementFields is separate from Contracts.KeyStatement on purpose: one may gain JSON fields freely, the other cannot change without invalidating every stored binding, and Crypto must not depend on the contract assembly. KeyStatementDriftTests makes a field added to one and not the other a build failure, because a wire field outside the binding is unauthenticated data the server can change undetected. 54 new tests and two new golden vector sections. The vectors pin the absent-versus-empty email case and confirm that an offset-bearing sub-millisecond timestamp encodes identically to its truncated UTC form. Only additions to vectors.json; nothing existing moved. |
||
|
|
b15af836a3 |
Freeze DSH1 crypto specification and implement the core (M1)
docs/crypto.md is now the normative, frozen specification. This had to land before anything else in M1: the server holds ciphertext and no keys, so it can never re-encrypt, and a format change after users hold data is a coordinated client rewrite with no rollback. Specification: - DSH1 envelope layout, canonical 64-byte AAD encoding, SealTo construction, key hierarchy, Argon2id profiles, fingerprints, and the change rules for each version field. - AAD encoding is fixed-width binary rather than delimited string concatenation, so no field value can forge a field boundary. This supersedes the illustrative form sketched in ADR 0001, which now points here. - UUIDs are RFC 4122 big-endian. Guid.ToByteArray() emits the first three groups little-endian and would have made our ciphertext unreadable by any other implementation of this spec, failing only at a cross-implementation boundary. Verified rather than assumed: - PrimitiveAvailabilityTests proves X25519, Ed25519, XChaCha20-Poly1305, Argon2id and HKDF-SHA512 all function on net10.0. NSec 26.4.0 targets net9.0 and is consumed by forward compatibility; this closes one of the two package questions the plan flagged. - Argon2Profile exists because NSec's MemorySize is in KIBIBYTES, not bytes. Passing bytes gives either a 256 GiB allocation or a 256 KiB KDF that cracks instantly. The type takes mebibytes so the unit cannot be got wrong at a call site. Found by benchmarking: the first measurements were ~1000x too slow, which turned out to be 19 GiB of work. - Parameters measured, not guessed: 256 MiB/t=4 is 323 ms on this machine; the table of candidates is in the spec. Implementation and tests (83 total, up from 17): - AadDescriptor, DshEnvelope, DshCrypto (Seal/Open/SealTo/OpenSealed/fingerprints). - Decryption returns null rather than throwing: ciphertext comes from a server that is explicitly not trusted, so a failed tag is an expected outcome. - Envelope readers reject unknown algorithms and any non-zero flag bit, so an envelope that is not fully understood fails closed. - Executable form of the spec's substitution claims: a server cannot move ciphertext between resources, roll back a key generation or item version, repurpose a payload as metadata, or confuse the two constructions. - Golden vectors in tests/fixtures/crypto/vectors.json guard the format. Mutation-checked: a one-byte schema version change trips four tests including the guard. Two build-infrastructure bugs found and fixed along the way: - .editorconfig forced camelCase on const and static readonly fields. PascalCase is the .NET convention for both; the config was wrong, not the code. - The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild rewrites to /_/... under deterministic source paths. It passed locally and would have failed only in CI. Now copied to the output directory and read from there. |
||
|
|
ce43f397a6 |
Add ADRs 0001-0006 and README (M0)
Records the decisions the milestone plan already made, with their costs stated rather than only their benefits: - 0001 e2ee-trust-model: key hierarchy, the AAD-to-row binding that stops the server moving ciphertext between rows, and the four-layer public-key trust story. States plainly that revocation is not retroactive, that Connect cannot be a security boundary, and that the IdP becomes a key-distribution trust root. - 0002 minimal-apis: feature modules with explicit registration; capability negotiation instead of Asp.Versioning, since client and server upgrade independently when self-hosted. - 0003 sync-protocol: single write path, revision cursors, and the bigserial pre-commit sequence gap that silently corrupts sync — plus the per-vault advisory lock that fixes it and the test that must prove it. - 0004 relay-authorization: relay forwards bytes rather than terminating SSH, so zero-knowledge survives; server-resolved target IPs in the ticket to defeat DNS rebinding; why host addresses must be plaintext when relay is enabled. - 0005 no-application-layer: why the usual Application/mediator layer earns nothing here, with the trigger that would make us revisit it. - 0006 observability-stack: OTel plus built-in ILogger; liveness excludes dependencies so a database blip cannot restart the container and kill live SSH sessions. Also adds a README covering layout, build, enforced conventions and milestones. |