1b76c51fbb2263473fe85a1c5f14ce0d48d991ab
159
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a628762cd1 |
Add /me and enrollment with the identity-provider key binding (M1)
The last backend piece of M1. A client can now log in, discover it must enroll, publish its identity key, and get a usable personal vault. Enrollment is one indivisible act. One transaction writes the key, its wraps, the device, the key log entry, the vault and the vault key grant, because none of them is useful alone: a key with no vault leaves a user unable to store anything, and a vault with no grant is a container nobody can ever open -- including its owner, since only the client can wrap the key and it has already moved on. Two independent checks run, and neither substitutes for the other. The Ed25519 self-signature proves possession of the private key. The identity-provider binding proves whose key it is: the client hashed its statement, used the hash as an OIDC nonce, and the resulting ID token is the provider's signature over exactly those public keys. This server cannot mint that signature, so it cannot invent a key for a user who never enrolled -- which is the attack that would otherwise let an operator read every vault by publishing its own key as yours. The binding token is stored verbatim, not just summarised. Clients must repeat the check against the provider's JWKS fetched directly, and storing only our conclusion would ask them to trust the server about the one question the design exists to avoid trusting it about. Key log appends take a deployment-wide advisory lock. The falsification matters more than the passing test: with the lock removed, Enroll_ConcurrentEnrollmentsByDifferentUsers_LeaveAnUnbrokenChain fails with entry 11 linked to the wrong predecessor. Different users trip no unique index, so without serialising they all read the same head and the chain forks -- indistinguishable from the key substitution the log exists to make detectable, and permanent, because the log is append-only. Enrollment is idempotent. Vault ids and keys are client-chosen, so a client whose response was lost re-sends the identical body and gets the identical result. Without that, a lost response leaves a user enrolled against a vault they never learned the id of. Contract change, breaking the v0.1 freeze deliberately. EnrollmentRequest had DevicePublicKey but no wrap to go with it, which is unsatisfiable: only the holder of the secret bundle can seal it, so the server could never fill the gap. Added DeviceWrappedPrivateKey, and PersonalVault so enrollment can be atomic rather than leaving an unopenable vault behind two endpoints that do not exist yet. No client exists and no package is published, which is exactly when PublicAPI.Unshipped.txt expects this. Sync now requires the Enrolled policy, which until now was a stub whose name promised a check it never made. The sync denial tests use enrolled intruders instead of unenrolled ones -- an unenrolled caller is stopped before the vault check runs, which would have left those tests passing without exercising the thing they exist to prove. Also fixed: omitting kdfParameters from the JSON body was a 500. A record's non-nullable parameters are a compile-time promise, not a runtime one. 268 tests pass, zero warnings on a clean rebuild, format clean. |
||
|
|
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. |
||
|
|
98d29bff37 |
Add HTTP integration harness and the sync authorization matrix (M1)
27 end-to-end tests over the real HTTP pipeline, against a PostgreSQL container and a stubbed identity provider. This closes the gap the previous commit flagged. Authentication is genuinely exercised, not bypassed. StubIdentityProvider serves real OIDC discovery and JWKS via WireMock and signs tokens with a real RSA key, so the application's own JwtBearer pipeline validates issuer, audience, signature, lifetime and claims. A TestAuthHandler that short-circuits authentication would hide exactly the claim-mapping mistakes that cause real authorization holes. Proven by rejecting: no token, a foreign signing key, the wrong audience, the wrong issuer, and an expired token. Authorization denials — the tests that matter most: - Another user's vault is 404, not 403, for both pull and push. A distinct "exists but forbidden" answer is an existence oracle for other tenants' vault ids. - A denied push writes nothing: no host row and no change-log entry. A denial that still mutated state would be worse than no check at all. - A team vault is denied until M3 rather than falling through to a permissive default. Behaviour covered: push/pull round trip, cursor advance (and that an empty pull does not rewind the cursor, which would replay history), tampered cursor rejection, stale-version conflict returning server state without overwriting, operation-id replay reported Duplicate and applied once, a mixed batch applying the good and reporting the bad, relay field enforcement both ways, delete clearing the relay address, tombstones carrying no payload, and JIT provisioning happening exactly once. Two configuration problems found by running it: - appsettings.json carried empty-string placeholders for the connection string and OIDC authority. Under minimal hosting those beat anything a test registers via ConfigureAppConfiguration, because Program.cs adds its own sources after that callback runs. Removed them outright — an empty placeholder turns "not configured" into "configured as empty", which defeats failing fast. Tests now use DODOSSH_ environment variables, which Program.cs adds last. - My first fix for minting an expired test token derived notBefore from the expiry, which put nbf fourteen minutes in the future for normal tokens and made every valid token 401. It needs the earlier of now-1min and exp-1min. Verified: 0 warnings on a clean rebuild, 173 tests pass (up from 146), format clean. |
||
|
|
3829217e8a |
Add sync engine: cursors, push/pull, and the advisory-lock ordering proof (M1)
The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE — so one place enforces revisions, the change log and access control. The concurrency hazard, now proven rather than asserted: bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A can take sequence 5 while B takes 6 and commits first. A reader polling in between sees only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it would pass just as happily if the interleaving never occurred — then shows pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps. Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly worse than an error a client can resync from. Rejected: tampered tag, tampered payload, foreign signing key, a legitimately-issued cursor from another vault, truncation, and hostile input (never throws — cursors come from clients). Push semantics: - 200 even on partial failure, with per-operation status, so one stale item cannot block everything a client queued while offline. - Conflict returns the server's current row for client-side three-way merge. The server cannot merge ciphertext, so never last-writer-wins. - opId receipts make retries exactly-once per operation, not per batch — a client retrying a partially-overlapping batch after a timeout would otherwise double-apply what landed. - A tombstone beats a late upsert, and delete clears hostname/port: leaving the address would keep the server able to resolve a host the user believes they deleted. - Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather than a constraint violation surfacing as a 500. Authorization goes through IVaultAccessService, which returns the same answer for "absent" and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids. Team vaults are explicitly denied until M3 rather than falling through to a permissive default. JIT provisioning keys on (issuer, subject), never email, and handles the concurrent-first-request race via the unique index. Renamed two domain types: Host -> SshHost, because Host collides with Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange -> VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every use site would have been permanent friction. Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type names. The snapshot was regenerated and the emitted DDL diffed against the previous artifacts/schema/v0.1.sql to confirm the rename produced no schema change. Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing parallelization limits, which is why MA0004 is suppressed in test projects. Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean. Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two routes. The service-layer authorization and the concurrency property are covered. |
||
|
|
d3b14e6bc0 |
Add configuration, OIDC auth wiring and discovery endpoints (M1)
Options, JWT bearer validation, the /meta and .well-known endpoints, and a dev compose stack with Keycloak. Verified end to end: compose up, migrate, run, both discovery endpoints return correct payloads, and readiness reports the schema current. Configuration: - Strongly-typed options for Server, Oidc, Relay and Sync, all ValidateOnStart. A self-hosted server that boots half-configured and fails later per-request is far harder to diagnose than one that refuses to start and names the bad setting. - Cross-field validation the annotations cannot express: relay needs a WebSocketUrl when enabled, idle timeout must be under max session duration, item payload cap under batch cap. - Startup warnings for combinations that are individually valid but dangerous together: RequireHttpsMetadata false outside Development, and AllowEmailLinking (which turns any token bearing a victim's email into account takeover, hence default false). Auth: - JwtBearer with ClockSkew cut to 30s from the 5-minute default; five minutes of slack on a credential granting vault ciphertext access is more than any clock needs. - IncludeErrorDetails off, and a FallbackPolicy so an endpoint without an explicit policy still requires a caller rather than silently being public. Discovery, per ADR 0002: - /api/v1/meta reports versions, features and push caps. - /.well-known/dodossh-configuration is the onboarding story: the user types one server URL and the client discovers OIDC authority, client id, scopes and relay endpoint. Two environment problems found by actually running the stack: - PostgreSQL 18 changed its data mount point. Mounting /var/lib/postgresql/data — correct through 17 — makes the image refuse to start; 18+ wants a single mount at /var/lib/postgresql with the cluster in a subdirectory. - Keycloak moved to host port 18080. An unrelated Apache Tomcat on this machine holds 127.0.0.1:8080, and a loopback-specific bind beats Docker's 0.0.0.0 publish for "localhost". It presents as Keycloak 404ing every realm while its own log says the import succeeded, which is a genuinely misleading failure. Also: CA1848 is enforced, not advisory — warnings are errors, so the .editorconfig comment claiming otherwise was wrong. Startup and health logging now uses [LoggerMessage]. And a clean rebuild is back to zero warnings; the incremental build had been hiding 40 in test projects (banned Guid.NewGuid, an obsolete Testcontainers constructor, and two analyzer families that are genuinely noise under a test host). Verified: 0 warnings on a clean rebuild, 122 tests pass, format clean. |
||
|
|
eaf68c86b0 |
Add data model, DbContext and initial migration (M1)
Schema for identity, vaults, grants, hosts and the sync change log, verified against a real PostgreSQL 18 container rather than an in-memory provider: partial unique indexes, CHECK constraints, citext and identity-always columns are all provider behaviour that an in-memory fake would not exercise. Invariants pushed into the database, so they hold even when application code has a bug: - ck_host_relay_target is a security boundary, not tidiness. A host may carry a plaintext hostname and port ONLY when relay is deliberately enabled. Both directions are tested; the important one is that relay-disabled hosts cannot carry an address, since otherwise a bug would silently give the server infrastructure visibility it was never granted. - ck_vault_owner: exactly one of owner_user_id or team_id, or permission resolution would have no defined answer. - ck_vault_key_grant_recipient: member grants name a user; recovery and escrow grants are wrapped to a key and must not. - ck_user_key_wrap_kdf: a password-derived wrap without its parameters is permanently unopenable, so a partial write is rejected outright. Present from the first migration on purpose: - GrantKind (Member/Recovery/Escrow). Recovery cannot be bolted on later — every vault created before it existed would be unrecoverable by design. - team and team_membership, though team features are M3. Adding them later would mean introducing a foreign key on a live vault table. - Host.ContentKeyId, reserved for per-item content keys wrapped to individual users. - user_key as its own table, so key rotation does not require altering the user row. Two things verified rather than assumed: - Npgsql's UseXminAsConcurrencyToken helper no longer exists in EF 10, so xmin is mapped directly in XminConcurrency. The generated migration *looks* like it creates an xmin column; it does not. Confirmed by inspecting pg_attribute (attnum -2, a system column) and by grepping the emitted DDL. A test pins both, because had it created a real column PostgreSQL would have rejected the name. - EF Core is now pinned centrally. The Npgsql provider asks for 10.0.4 while EntityFrameworkCore.Design pulls 10.0.10, and because Design is PrivateAssets=all that higher version does not flow to referencing projects — producing a CS1705 in any test project referencing Infrastructure. Also commits artifacts/schema/v0.1.sql, the idempotent script, as the baseline for future upgrade tests. Verified: 0 warnings, 122 tests pass (27 new against Postgres), format clean. |
||
|
|
06d04b490b |
Freeze DodoSSH.Contracts v0.1 (M1)
The second M1 gate. This assembly, not the OpenAPI document, is the client's contract, so PublicApiAnalyzers now tracks all 540 public members: a renamed DTO property becomes a build error rather than a runtime deserialisation failure on someone's laptop. Contract surface: - EncryptedPayload carries the envelope plus the KeyGeneration and AadVersion columns needed to recompute AAD, since AAD is derived from the row rather than transmitted. - Sync: push with per-operation status (Applied/Conflict/Forbidden/Invalid/Duplicate) so one stale item cannot block a whole offline queue; a Conflict returns the server's row for client-side three-way merge, because the server cannot merge ciphertext. - Enrollment: KeyStatement whose hash becomes the OIDC nonce, so the identity provider signs over the public keys and this server cannot fabricate a key for a user who never enrolled. - Meta and .well-known configuration: capability negotiation instead of URL versioning, which is what a self-hosted product needs when client and server upgrade independently. - SyncPlaintextFields deliberately has no label or name field. ACL admin runs client-side where names can be decrypted, so the server never needs a searchable title. Two design problems found by writing the tests rather than assuming: - Hand-constructing JsonSerializerOptions and merely pointing its resolver at the context silently discards every source-generated setting. JsonSerializerDefaults.Web replaces NumberHandling.Strict with AllowReadingFromString, so "1" would be accepted where 1 is meant — invisible until two implementations disagree. Callers now use ResponseOptions or StrictRequestOptions; StrictRequestOptions is derived by copying so it cannot drift. - StrictRequestOptions had a static-initialisation cycle: it read the generated Default property from the same type's initialiser and got null. Now lazy. Requests reject unmapped members so a client typo is a 400; responses tolerate them so an older client can read a newer server. Enums cross the wire as strings, so reordering one cannot silently reinterpret stored data. Also: excluded source-generator output from PublicApiAnalyzers. The JSON generator emits a public member per serialisable type, which would have added hundreds of mechanical entries and drowned the ones describing the actual wire contract. And disabled MA0048's one-type-per-file rule: splitting SyncPullRequest from SyncPullResponse makes a reviewer open two files to understand one endpoint. Verified: 0 warnings, 95 tests pass, format clean. |
||
|
|
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. |
||
|
|
3a81f3c90b |
Restructure into src/tests and add build foundation (M0)
Moves the scaffold to src/DodoSSH.Api and establishes the repo conventions the rest
of the milestones build on.
Structure:
- src/{Contracts,Crypto,Domain,Infrastructure,Api}, tests/{Contracts,Crypto,Domain}.Tests
- DodoSSH.slnx rewritten with src/ and tests/ solution folders
Build:
- Directory.Build.props centralises TFM, nullable, deterministic builds and
TreatWarningsAsErrors; Directory.Packages.props pins every version centrally
- packages.lock.json committed so CI restores in locked mode
- NuGet.config clears machine-level sources, which both fixes NU1507 under central
package management and makes restore reproducible off this machine
- Microsoft.OpenApi pinned to 2.11.0: ASP.NET Core 10.0.10 resolves 2.0.0, which is
covered by GHSA-v5pm-xwqc-g5wc (high, patched in 2.7.5)
Analyzers:
- AnalysisLevel is Recommended, not All. With warnings-as-errors, All turns opinionated
naming rules into build breaks and trains people to blanket-suppress.
- BannedSymbols.txt bans DateTime.UtcNow (TimeProvider), Guid.NewGuid (CreateVersion7),
sync-over-async, MD5/SHA1, PBKDF2 and SecureString
- CA1711/CA1724 disabled: both are .NET Framework CAS-era naming rules
- PublicApiAnalyzers on Contracts only, since that assembly is the client's real contract
API:
- weather-forecast template removed
- UseHttpsRedirection removed; TLS terminates at the reverse proxy and redirecting
behind one causes loops
- /healthz/{live,ready,startup}. Liveness deliberately checks no dependencies so a
transient database outage cannot restart the container and kill live SSH sessions.
Notes:
- No coverage collector yet. Microsoft.Testing.Extensions.CodeCoverage pulls an MTP 1.x
MSBuild extension that throws TypeLoadException against the MTP 2.3.x xunit.v3 brings.
Coverage gates are an M3 concern; revisit with an MTP 2.x-aligned version then.
Verified: dotnet build (0 warnings), 17 tests pass, format check clean, API serves
health and OpenAPI endpoints.
|