Public Access
Share a vault with a team, without the server holding a key
M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the append-only key log served for clients to check it against, team-owned vaults, and vault key grants wrapped by a client and stored opaquely by the server. VaultAccessService resolves team membership to PermissionFlags, so a viewer may pull and may not push; the desktop client reads and syncs every vault it holds a key for, and a real TEAMS screen replaces the one that said it did not exist. No migration: team, team_membership, vault.team_id and vault_key_grant have all been there since the first one, which is what carrying two unused tables bought. Membership is authorisation. A grant is access. The obvious model is one concept — "access", with a role attached, handed out by the server — and this architecture cannot implement it: a vault key is sealed to each member's X25519 key, and only a client holding the plaintext can seal it for somebody else. So "give Bob access" decomposes into a database write and a wrap, which happen on different machines. Adding a member makes the server serve them the vault; it cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime and the vault appears in their list saying it is waiting for a key, because hiding it until a grant existed would have been tidier and would have implied the server was the thing granting access. The screen says the same thing after every add, in the status line. ADR 0009 records the whole decision. Sharing verifies or refuses. A directory lookup is a claim by the server about a third party's public key, and wrapping to an unverified claim hands the vault to whoever made it — no amount of transport security helps, because the server is inside the threat model. KeyLogAudit reads the whole log, recomputes every entry's hash from its own contents, checks the chain from genesis, and refuses unless the offered key appears in it unchanged. There is no override flag: one that exists gets used on the day the log is briefly unreachable, and the resulting grant is indistinguishable from a correct one afterwards. What it still cannot promise is that the key is the right person's, so the fingerprint comes back for an out-of-band comparison and the success message says so every time. A test corrupts the fake server's log by one byte and watches the client refuse rather than warn. The roles are only the ones that are enforceable. There is no ConnectOnly, despite the design asking for one and TeamRole having room: SSH terminates on the client, so a session needs the credential's plaintext on that machine, and "may connect but may not read the key" cannot be enforced here. Shipping it as an option in a dropdown would have been a lie. Connect rides along with Read and is documented as an interface hint. Removal is named for what it does — it revokes grants and flags the vault for rekey, and claims nothing about what is already on somebody's laptop. Three things are deliberately absent, and each is a refusal rather than an omission. The rekey itself, because re-wrapping every item's data key under a new vault key needs a client holding the current one; the server records that a rotation is owed and the interface reports it, which is more honest than a button that only appears to do it. Ownership transfer, because allowing an owner to be removed without one leaves a team nobody can administer. And cross-vault host key trust: a pin in a team vault is listed but not consulted at connect time, because any member with Write could otherwise pre-approve a fingerprint another member's client then trusts silently for a host in their own vault. Scoping trust properly needs a scope on the SSH connect path, which IKnownHostStore has not got; until then the narrow direction is the safe one and the cost is in the README rather than hidden. Reading now spans vaults and writing still does not. Every list on the vault and hosts screens covers each vault the keyring opened, rows carry the vault they came from, and an edit goes back to that vault rather than to the active one — writing it to the active vault would fork the item and only show up when a colleague wondered why their change never arrived. A new item goes wherever a picker says, defaulting to the personal vault and never moving on its own, because an item filed into a team's vault is visible to that team and moving it back means deleting and retyping. The sidebar heading stops naming one vault once there are two, and each row names its own. The server checks what it can and nothing it cannot. It will not record a grant for a key its recipient no longer holds, for a superseded generation, or for somebody who is not in the team — each of those would otherwise surface days later at the far end as a tag failure indistinguishable from corruption. It does not verify the wrap or the signature, and the grant service says so: that would be a convenience and never the boundary, and would put an asymmetric implementation on a machine that is supposed to hold no keys. Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so a team created a moment earlier was missing from the list it had just been added to. And syncing every vault turned a failure from an exception into a report, which made a background pass announce an unreachable vault once a minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone exists to prevent. The fact is recorded and the message swallowed, as it was before; pressing Sync still names the vault and the reason. Also fixes a build break this branch started with: QuickConnectTests was never updated when M2 added ISftpSessionFactory to the shell's constructor, so nothing built at all.
This commit is contained in:
@@ -56,6 +56,36 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
|
||||
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
|
||||
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
|
||||
|
||||
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
|
||||
// nothing to wrap and no signature to attribute it with. There is no search here — see
|
||||
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
|
||||
"GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False",
|
||||
|
||||
// The other half of the same decision: the directory says what a key is, this is how a client
|
||||
// checks that claim against a chain the server cannot rewrite without every other client
|
||||
// noticing. Nothing in it is secret.
|
||||
"GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False",
|
||||
|
||||
// Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before
|
||||
// they have set a vault up must still be able to see the team they are now in.
|
||||
"GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False",
|
||||
"GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False",
|
||||
"POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False",
|
||||
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
|
||||
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
|
||||
|
||||
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
|
||||
// in it, and neither is reachable without a key of one's own.
|
||||
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
|
||||
"POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False",
|
||||
|
||||
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
|
||||
// sharing graph — and the two writes are gated on Share inside the handler, which this table
|
||||
// cannot see. See VaultGrantEndpoints.
|
||||
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
|
||||
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
|
||||
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
|
||||
|
||||
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
|
||||
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
|
||||
// MapHealthChecks constrains no verb, hence ANY.
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, membership and the vault key grants that make a team vault readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The half of M3 that runs on the server, which is the half that decides <em>what will be served</em>.
|
||||
/// Whether a member can decrypt what they are served is decided by holding a key, and no test here can
|
||||
/// assert it — that lives in the client suite, where a key exists. The two are separate on purpose and
|
||||
/// these tests are written to keep them separate: none of them checks that a wrapped key is right,
|
||||
/// because the server cannot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cases worth having are the ones where a mistake would be invisible. A member removed but still
|
||||
/// served; a viewer allowed to push; a vault visible to a team it does not belong to; a grant accepted
|
||||
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
|
||||
/// outside.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
{
|
||||
private const string TeamsUrl = "/api/v1/teams";
|
||||
private const string EnrollUrl = "/api/v1/me/enrollment";
|
||||
|
||||
[Fact]
|
||||
public async Task CreatingATeam_MakesTheCallerItsOwner()
|
||||
{
|
||||
var client = await EnrolledClientAsync("team-owner");
|
||||
|
||||
var team = await CreateTeamAsync(client, "Platform");
|
||||
|
||||
team.Role.ShouldBe(TeamMemberRole.Owner);
|
||||
team.MemberCount.ShouldBe(1);
|
||||
team.VaultCount.ShouldBe(0);
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
|
||||
|
||||
listed.ShouldContain(row => row.TeamId == team.TeamId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same body twice, as a client whose response was lost would send it. Enrollment behaves this way
|
||||
/// and a team create has the same shape — a client-chosen id — so it has to behave the same or a lost
|
||||
/// response leaves somebody with two teams under one name.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RepeatingACreate_ReturnsTheSameTeamRatherThanASecondOne()
|
||||
{
|
||||
var client = await EnrolledClientAsync("team-retry");
|
||||
|
||||
var request = new CreateTeamRequest(
|
||||
Guid.CreateVersion7(), "Retry", $"retry-{Guid.CreateVersion7():N}", null);
|
||||
|
||||
var first = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
|
||||
var second = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
|
||||
|
||||
second.TeamId.ShouldBe(first.TeamId);
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
|
||||
|
||||
listed.Count(row => row.TeamId == first.TeamId).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASlugAlreadyInUse_IsRefusedWithItsOwnCode()
|
||||
{
|
||||
var client = await EnrolledClientAsync("team-slug");
|
||||
var slug = $"taken-{Guid.CreateVersion7():N}";
|
||||
|
||||
await PostAsync<CreateTeamRequest, TeamSummary>(
|
||||
client, TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "First", slug, null));
|
||||
|
||||
var response = await client.PostContractAsync(
|
||||
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.TeamSlugTaken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A team somebody is not in answers 404, not 403. Distinguishing them would let a caller confirm
|
||||
/// which team ids exist, and team ids travel in URLs.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATeamTheCallerIsNotIn_IsIndistinguishableFromOneThatDoesNotExist()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("team-private-owner");
|
||||
var stranger = await EnrolledClientAsync("team-private-stranger");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Private");
|
||||
|
||||
var real = await stranger.GetAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var invented = await stranger.GetAsync(
|
||||
new Uri($"{TeamsUrl}/{Guid.CreateVersion7()}/members", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
invented.StatusCode.ShouldBe(real.StatusCode);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
|
||||
/// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
|
||||
/// design — the server can grant access to the ciphertext and cannot grant the ability to read it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddedMember_SeesTheTeamVaultWithNoKeyUntilSomebodyWrapsOne()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("grant-owner", "owner@example.com");
|
||||
var member = await EnrolledClientAsync("grant-member", "member@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Sharing");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "member@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var me = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
||||
var vault = me.Vaults.SingleOrDefault(summary => summary.VaultId == vaultId);
|
||||
|
||||
vault.ShouldNotBeNull("membership is what makes a team vault visible");
|
||||
vault.WrappedVaultKey.ShouldBeNull("and it is not what makes it readable");
|
||||
vault.IsPersonal.ShouldBeFalse();
|
||||
vault.TeamId.ShouldBe(team.TeamId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A viewer may read the vault and may not write to it. The failure this guards is the quiet one: a
|
||||
/// role that resolved to the wrong flags would let somebody who was added to look at a vault change
|
||||
/// what everybody else connects with.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AViewer_MayPullAndMayNotPush()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("viewer-owner", "vowner@example.com");
|
||||
var viewer = await EnrolledClientAsync("viewer-member", "viewer@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Read only");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "viewer@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Viewer);
|
||||
|
||||
var pull = await viewer.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
|
||||
|
||||
pull.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var push = await viewer.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
|
||||
|
||||
push.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
|
||||
var problem = await push.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.Forbidden);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// And the reverse, which is what removal has to mean: the vault stops being served at all. Note what
|
||||
/// is <em>not</em> asserted — that they have forgotten anything. They have not, and ADR 0001 says so.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARemovedMember_StopsBeingServedTheTeamsVault()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("removal-owner", "rowner@example.com");
|
||||
var member = await EnrolledClientAsync("removal-member", "rmember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Departures");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "rmember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var before = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
||||
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
|
||||
|
||||
var removed = await owner.DeleteAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
var after = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
||||
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
|
||||
|
||||
var pull = await member.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
|
||||
|
||||
pull.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Removing a member leaves the vault flagged for rekey, which is a promise the server records and
|
||||
/// cannot keep on its own: rekeying re-wraps every item's data key and only a client holding the
|
||||
/// current one can do that. The flag is what the interface reads to say so; M5 is what acts on it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RemovingAMember_FlagsTheTeamsVaultsForRekey()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("rekey-owner", "kowner@example.com");
|
||||
await EnrolledClientAsync("rekey-member", "kmember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Rekeys");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "kmember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
await owner.DeleteAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
|
||||
|
||||
grants.RekeyRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The owner cannot be removed or demoted, because nothing can appoint a replacement yet. Refused
|
||||
/// with a code the client can act on rather than a bare 400, since "you cannot do that" and "you did
|
||||
/// that wrong" lead somewhere different.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheOwner_CannotBeRemoved()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Sole");
|
||||
var me = await ReadAsync<MeResponse>(owner, "/api/v1/me");
|
||||
|
||||
var response = await owner.DeleteAsync(
|
||||
new Uri($"{TeamsUrl}/{team.TeamId}/members/{me.UserId}", UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.LastTeamOwner);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
|
||||
/// does nothing, because the access check will go on refusing them the vault — and a sharing screen
|
||||
/// listing a grant whose holder cannot fetch anything is worse than an error.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGrantToANonMember_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("outsider-owner", "oowner@example.com");
|
||||
await EnrolledClientAsync("outsider", "outsider@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Closed");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "outsider@example.com");
|
||||
|
||||
var response = await owner.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/grants",
|
||||
new IssueVaultGrantRequest(
|
||||
entry.UserId,
|
||||
entry.Fingerprint,
|
||||
KeyGeneration: 1,
|
||||
WrappedVaultKey: new byte[110],
|
||||
KeyLogHead: new byte[32],
|
||||
GrantSignature: new byte[64],
|
||||
GrantedAt: DateTimeOffset.UnixEpoch));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.InvalidVaultGrant);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A fingerprint that is not the recipient's current key is refused. The server cannot tell whether
|
||||
/// the wrap contains the right key — nothing on that machine can — but it can tell that this grant
|
||||
/// was made for a key nobody holds, which would otherwise surface at the far end days later as a tag
|
||||
/// failure indistinguishable from corruption.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGrantForAKeyTheRecipientDoesNotHold_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("stale-owner", "sowner@example.com");
|
||||
await EnrolledClientAsync("stale-member", "smember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Stale");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var entry = await LookupAsync(owner, "smember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var response = await owner.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/grants",
|
||||
new IssueVaultGrantRequest(
|
||||
entry.UserId,
|
||||
RecipientKeyFingerprint: new byte[32],
|
||||
KeyGeneration: 1,
|
||||
WrappedVaultKey: new byte[110],
|
||||
KeyLogHead: new byte[32],
|
||||
GrantSignature: new byte[64],
|
||||
GrantedAt: DateTimeOffset.UnixEpoch));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The directory has no search. Asserting it rather than trusting the implementation, because a
|
||||
/// prefix match added later for convenience turns a server that stores addresses in plaintext into a
|
||||
/// way to enumerate an organisation's staff.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheDirectory_MatchesAnExactAddressAndNothingElse()
|
||||
{
|
||||
var client = await EnrolledClientAsync("directory-self", "findme@example.com");
|
||||
var address = addresses["findme@example.com"];
|
||||
|
||||
var exact = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
|
||||
|
||||
exact.Count.ShouldBe(1);
|
||||
|
||||
// Case-insensitive, because the column is citext and two addresses differing only in case are
|
||||
// one account. That is a match, not a search.
|
||||
var cased = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address.ToUpperInvariant())}");
|
||||
|
||||
cased.Count.ShouldBe(1);
|
||||
|
||||
// The address with its last character removed. A directory that answered this would be a way to
|
||||
// walk an organisation's staff list out of a server that stores addresses in plaintext.
|
||||
var prefix = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address[..^1])}");
|
||||
|
||||
prefix.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The key log has to verify from genesis with the hashes the server publishes, because that is the
|
||||
/// whole of what a client can check. A chain that only the server could reproduce would make key
|
||||
/// transparency a claim rather than a mechanism.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheKeyLog_ChainsFromGenesisWithTheHashesItPublishes()
|
||||
{
|
||||
var client = await EnrolledClientAsync("keylog-reader", "keylog@example.com");
|
||||
|
||||
var page = await ReadAsync<KeyLogPage>(client, "/api/v1/keylog?after=0");
|
||||
|
||||
page.Entries.ShouldNotBeEmpty();
|
||||
|
||||
var previous = Crypto.KeyLogChain.CreateGenesisPreviousHash();
|
||||
|
||||
foreach (var entry in page.Entries)
|
||||
{
|
||||
entry.PreviousHash.ShouldBe(previous);
|
||||
|
||||
Crypto.KeyLogChain.ComputeEntryHash(
|
||||
entry.PreviousHash,
|
||||
entry.UserId,
|
||||
entry.Generation,
|
||||
entry.EncryptionPublicKey,
|
||||
entry.SigningPublicKey,
|
||||
entry.StatementSignature,
|
||||
entry.CreatedAt).ShouldBe(entry.Hash);
|
||||
|
||||
previous = entry.Hash;
|
||||
}
|
||||
|
||||
// And the head the page reports is the last link, or a client that paged to the end could not
|
||||
// tell whether it had seen the whole log.
|
||||
if (!page.HasMore)
|
||||
{
|
||||
page.Head.ShouldBe(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
|
||||
{
|
||||
var unique = $"{subject}-{Guid.CreateVersion7():N}";
|
||||
var address = email is null ? null : $"{Guid.CreateVersion7():N}-{email}";
|
||||
|
||||
using var enrollment = new TestEnrollment(fixture.IdentityProvider, unique, address);
|
||||
|
||||
var client = fixture.CreateClientFor(unique, address);
|
||||
|
||||
var response = await client.PostContractAsync(EnrollUrl, enrollment.Build());
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// The address is remembered on the client so a later directory lookup can name it: the tests
|
||||
// uniquify addresses so that runs against a shared container cannot collide.
|
||||
if (address is not null)
|
||||
{
|
||||
addresses[email!] = address;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
|
||||
private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <remarks>
|
||||
/// The slug is generated rather than derived from the name, because a slug is lowercase letters,
|
||||
/// digits and hyphens and a display name is not — deriving one would make these tests depend on a
|
||||
/// transformation the product does not perform. It is uniquified because the container is shared
|
||||
/// across every class in this assembly and the slug is unique deployment-wide.
|
||||
/// </remarks>
|
||||
private Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
|
||||
PostAsync<CreateTeamRequest, TeamSummary>(
|
||||
client,
|
||||
TeamsUrl,
|
||||
new CreateTeamRequest(
|
||||
Guid.CreateVersion7(), name, $"team-{Guid.CreateVersion7():N}", null));
|
||||
|
||||
/// <remarks>
|
||||
/// The wrapped key and the signature are the right shape and nothing more. The server stores both
|
||||
/// opaquely and verifies neither — see docs/crypto.md §6 — so a real seal here would be testing the
|
||||
/// crypto library rather than the endpoint.
|
||||
/// </remarks>
|
||||
private async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
|
||||
{
|
||||
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
|
||||
client,
|
||||
$"{TeamsUrl}/{teamId}/vaults",
|
||||
new CreateTeamVaultRequest(
|
||||
Guid.CreateVersion7(),
|
||||
"Team vault",
|
||||
WrappedVaultKey: new byte[110],
|
||||
GrantSignature: new byte[64],
|
||||
GrantedAt: DateTimeOffset.UnixEpoch));
|
||||
|
||||
return vault.VaultId;
|
||||
}
|
||||
|
||||
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
|
||||
{
|
||||
var address = addresses.GetValueOrDefault(email, email);
|
||||
|
||||
var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
|
||||
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
|
||||
|
||||
return found.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static async Task AddMemberAsync(
|
||||
HttpClient client,
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
TeamMemberRole role)
|
||||
{
|
||||
var response = await client.PostContractAsync(
|
||||
$"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
|
||||
HttpClient client,
|
||||
string url,
|
||||
TRequest body)
|
||||
{
|
||||
var response = await client.PostContractAsync(url, body);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadContractAsync<TResponse>())!;
|
||||
}
|
||||
|
||||
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
|
||||
{
|
||||
var response = await client.GetAsync(
|
||||
new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return (await response.Content.ReadContractAsync<T>())!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The team enums on the wire and the ones in the domain have to agree, and nothing but this makes them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same hazard <c>EntityTypeAlignmentTests</c> exists for, one feature along and with a worse failure.
|
||||
/// <c>TeamService</c> maps <see cref="TeamMemberRole"/> to <see cref="TeamRole"/> member by member, so a
|
||||
/// renumbering on one side does not fail to compile — it silently changes what a role means. Someone
|
||||
/// added as a viewer would come back as an admin, or the reverse, on the next deployment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Mapped by <em>name</em> in the service and asserted by <em>value</em> here, which is the pairing that
|
||||
/// catches the mistake: the service would go on compiling after a renumbering, and this would not go on
|
||||
/// passing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TeamEnumAlignmentTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryWireRole_HasADomainRoleWithTheSameValue()
|
||||
{
|
||||
((int)TeamMemberRole.Unspecified).ShouldBe((int)TeamRole.Unspecified);
|
||||
((int)TeamMemberRole.Viewer).ShouldBe((int)TeamRole.Viewer);
|
||||
((int)TeamMemberRole.Member).ShouldBe((int)TeamRole.Member);
|
||||
((int)TeamMemberRole.Admin).ShouldBe((int)TeamRole.Admin);
|
||||
((int)TeamMemberRole.Owner).ShouldBe((int)TeamRole.Owner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheTwoRoleEnums_HaveTheSameNumberOfMembers() =>
|
||||
Enum.GetValues<TeamMemberRole>().Length.ShouldBe(Enum.GetValues<TeamRole>().Length);
|
||||
|
||||
[Fact]
|
||||
public void EveryWireMembershipStatus_HasADomainStatusWithTheSameValue()
|
||||
{
|
||||
((int)TeamMemberStatus.Unspecified).ShouldBe((int)MembershipStatus.Unspecified);
|
||||
((int)TeamMemberStatus.Invited).ShouldBe((int)MembershipStatus.Invited);
|
||||
((int)TeamMemberStatus.Active).ShouldBe((int)MembershipStatus.Active);
|
||||
((int)TeamMemberStatus.Revoked).ShouldBe((int)MembershipStatus.Revoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryWireGrantState_HasADomainStateWithTheSameValue()
|
||||
{
|
||||
((int)VaultGrantState.Unspecified).ShouldBe((int)GrantState.Unspecified);
|
||||
((int)VaultGrantState.Active).ShouldBe((int)GrantState.Active);
|
||||
((int)VaultGrantState.AwaitingRewrap).ShouldBe((int)GrantState.AwaitingRewrap);
|
||||
((int)VaultGrantState.Revoked).ShouldBe((int)GrantState.Revoked);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>VaultSummary.Permissions</c> is an opaque int on the wire, on purpose — the flags live in
|
||||
/// <c>DodoSSH.Domain</c> and no client project references that assembly. So the client repeats the one
|
||||
/// bit it needs as a literal in <c>StoredVault.CanWrite</c>, and this pins the value it copied.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted here rather than against <c>StoredVault</c> itself, which would mean a server test project
|
||||
/// taking a reference on a client assembly to check a constant. The consequence of drift is worth the
|
||||
/// literal either way: a Save button offered to somebody who is only a viewer of a team vault, ending
|
||||
/// in a 403 they can do nothing about — or no Save button for somebody who may write.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheWriteFlag_IsTheBitTheClientCopied()
|
||||
{
|
||||
((int)PermissionFlags.Read).ShouldBe(1 << 0);
|
||||
((int)PermissionFlags.Write).ShouldBe(1 << 1);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
Substitute.For<IDeviceKeyStore>(),
|
||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||
TimeProvider.System,
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile)
|
||||
{
|
||||
// The state the palette is only ever open in. Assigned rather than reached through the unlock
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The team, directory and grant half of the fake server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The key log is real.</b> Entries are chained with <see cref="KeyLogChain.ComputeEntryHash"/> exactly
|
||||
/// as the server chains them, because the client refuses to wrap a vault key to a directory answer that
|
||||
/// does not appear in a log whose chain verifies — so a fake that returned a plausible-looking log would
|
||||
/// make every sharing test pass against a check that was never exercised. It also means a test can break
|
||||
/// the chain deliberately and watch the client refuse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
|
||||
/// tested against the real one in <c>DodoSSH.Api.Tests</c>; what the shell needs from here is that a team
|
||||
/// can be created, a member added, and a vault key wrapped and recorded.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
|
||||
{
|
||||
private readonly List<TeamSummary> teams = [];
|
||||
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
|
||||
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
|
||||
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
|
||||
private readonly List<KeyLogRecord> keyLog = [];
|
||||
private readonly List<DirectoryEntry> directory = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITeamApi Teams => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDirectoryApi Directory => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultGrantApi Grants => this;
|
||||
|
||||
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
|
||||
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The switch a test flips to prove the client refuses rather than shares. A fake with no way to be
|
||||
/// wrong can only ever confirm the happy path.
|
||||
/// </remarks>
|
||||
internal bool CorruptKeyLog { get; set; }
|
||||
|
||||
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
|
||||
/// <returns>Their user id.</returns>
|
||||
internal Guid AddAccount(string email, string displayName)
|
||||
{
|
||||
var userId = Guid.CreateVersion7();
|
||||
|
||||
// Real keys rather than filler: the client recomputes the fingerprint over both halves and refuses
|
||||
// an entry whose fingerprint does not match, so random bytes would fail for the wrong reason.
|
||||
using var bundle = UserSecretBundle.Create(DateTimeOffset.UnixEpoch);
|
||||
|
||||
var sequence = AppendKeyLog(
|
||||
userId, bundle.EncryptionPublicKey, bundle.SigningPublicKey, new byte[64]);
|
||||
|
||||
directory.Add(new DirectoryEntry(
|
||||
userId,
|
||||
email,
|
||||
displayName,
|
||||
bundle.EncryptionPublicKey,
|
||||
bundle.SigningPublicKey,
|
||||
DshCrypto.ComputeFingerprint(bundle.EncryptionPublicKey, bundle.SigningPublicKey),
|
||||
KeyGeneration: 1,
|
||||
KeyLogSequence: sequence));
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamSummary> CreateTeamAsync(
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var team = new TeamSummary(
|
||||
request.TeamId,
|
||||
request.Name,
|
||||
request.Slug,
|
||||
request.Description,
|
||||
TeamMemberRole.Owner,
|
||||
MemberCount: 1,
|
||||
VaultCount: 0,
|
||||
DateTimeOffset.UnixEpoch);
|
||||
|
||||
teams.Add(team);
|
||||
|
||||
members[team.TeamId] =
|
||||
[
|
||||
new TeamMemberSummary(
|
||||
UserId,
|
||||
"alice@example.com",
|
||||
"Alice Example",
|
||||
TeamMemberRole.Owner,
|
||||
TeamMemberStatus.Active,
|
||||
IsEnrolled: true,
|
||||
DateTimeOffset.UnixEpoch),
|
||||
];
|
||||
|
||||
return Task.FromResult(team);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
|
||||
members.TryGetValue(teamId, out var list) ? [.. list] : []);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = directory.Find(candidate => candidate.UserId == request.UserId)
|
||||
?? throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidTeam,
|
||||
"No such account on this server.");
|
||||
|
||||
var member = new TeamMemberSummary(
|
||||
entry.UserId,
|
||||
entry.Email,
|
||||
entry.DisplayName,
|
||||
request.Role,
|
||||
TeamMemberStatus.Active,
|
||||
IsEnrolled: true,
|
||||
DateTimeOffset.UnixEpoch);
|
||||
|
||||
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(member);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = members.GetValueOrDefault(teamId, []);
|
||||
var index = list.FindIndex(member => member.UserId == userId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidTeam,
|
||||
"That account is not an active member of this team.");
|
||||
}
|
||||
|
||||
list[index] = list[index] with { Role = request.Role };
|
||||
|
||||
return Task.FromResult(list[index]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RemoveTeamMemberAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = members.GetValueOrDefault(teamId, []);
|
||||
var removed = list.RemoveAll(member => member.UserId == userId) > 0;
|
||||
|
||||
// Every grant they held from this team goes with them, as the real service revokes them in the
|
||||
// same transaction. A fake that removed the membership and left the grants would let a test
|
||||
// "prove" a revocation that had not happened.
|
||||
foreach (var vaultId in teamVaults.Values
|
||||
.Where(vault => vault.TeamId == teamId)
|
||||
.Select(vault => vault.VaultId))
|
||||
{
|
||||
grants.Remove((vaultId, userId));
|
||||
}
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vault = new VaultSummary(
|
||||
request.VaultId,
|
||||
request.Name,
|
||||
IsPersonal: false,
|
||||
TeamId: teamId,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 31,
|
||||
request.WrappedVaultKey,
|
||||
RekeyRequired: false);
|
||||
|
||||
teamVaults[vault.VaultId] = vault;
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(vault);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<DirectoryEntry>>(
|
||||
[
|
||||
.. directory.Where(entry =>
|
||||
string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
|
||||
]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(directory.Find(entry => entry.UserId == userId));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<KeyLogPage> ReadKeyLogAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var page = keyLog.Where(entry => entry.Sequence > afterSequence).ToList();
|
||||
|
||||
if (CorruptKeyLog && page.Count > 0)
|
||||
{
|
||||
// One byte, in the field the chain is built from. Enough to break the link and nothing else,
|
||||
// which is what a tampered log would look like.
|
||||
var last = page[^1];
|
||||
page[^1] = last with { EncryptionPublicKey = [.. last.EncryptionPublicKey.Reverse()] };
|
||||
}
|
||||
|
||||
var head = keyLog.Count == 0
|
||||
? KeyLogChain.CreateGenesisPreviousHash()
|
||||
: keyLog[^1].Hash;
|
||||
|
||||
return Task.FromResult(new KeyLogPage(page, keyLog.Count, head, HasMore: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new VaultGrantsResponse(
|
||||
vaultId,
|
||||
KeyGeneration: 1,
|
||||
RekeyRequired: false,
|
||||
Grants:
|
||||
[
|
||||
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
|
||||
new VaultGrantSummary(
|
||||
entry.Key.UserId,
|
||||
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
|
||||
null,
|
||||
KeyGeneration: 1,
|
||||
VaultGrantState.Active,
|
||||
UserId,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
null)),
|
||||
]));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task IssueVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
grants[(vaultId, request.RecipientUserId)] = request;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RevokeVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(grants.Remove((vaultId, userId)));
|
||||
|
||||
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
|
||||
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
|
||||
{
|
||||
if (directory.Exists(entry => entry.UserId == UserId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sequence = AppendKeyLog(
|
||||
UserId, statement.EncryptionPublicKey, statement.SigningPublicKey, statementSignature);
|
||||
|
||||
directory.Add(new DirectoryEntry(
|
||||
UserId,
|
||||
"alice@example.com",
|
||||
"Alice Example",
|
||||
statement.EncryptionPublicKey,
|
||||
statement.SigningPublicKey,
|
||||
DshCrypto.ComputeFingerprint(statement.EncryptionPublicKey, statement.SigningPublicKey),
|
||||
statement.KeyGeneration,
|
||||
sequence));
|
||||
}
|
||||
|
||||
/// <summary>Appends a key log entry, chained as the real log chains it.</summary>
|
||||
private long AppendKeyLog(
|
||||
Guid userId,
|
||||
byte[] encryptionPublicKey,
|
||||
byte[] signingPublicKey,
|
||||
byte[] statementSignature)
|
||||
{
|
||||
var previous = keyLog.Count == 0
|
||||
? KeyLogChain.CreateGenesisPreviousHash()
|
||||
: keyLog[^1].Hash;
|
||||
|
||||
var createdAt = KeyLogChain.TruncateTimestamp(DateTimeOffset.UnixEpoch);
|
||||
var sequence = keyLog.Count + 1;
|
||||
|
||||
var hash = KeyLogChain.ComputeEntryHash(
|
||||
previous, userId, 1, encryptionPublicKey, signingPublicKey, statementSignature, createdAt);
|
||||
|
||||
keyLog.Add(new KeyLogRecord(
|
||||
sequence,
|
||||
userId,
|
||||
Generation: 1,
|
||||
encryptionPublicKey,
|
||||
signingPublicKey,
|
||||
statementSignature,
|
||||
previous,
|
||||
hash,
|
||||
createdAt));
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private void Recount(Guid teamId)
|
||||
{
|
||||
var index = teams.FindIndex(team => team.TeamId == teamId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
teams[index] = teams[index] with
|
||||
{
|
||||
MemberCount = members.GetValueOrDefault(teamId, []).Count,
|
||||
VaultCount = teamVaults.Values.Count(vault => vault.TeamId == teamId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
|
||||
/// version checks.
|
||||
/// </remarks>
|
||||
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
||||
internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
||||
{
|
||||
private readonly List<SyncChange> log = [];
|
||||
|
||||
@@ -104,7 +104,11 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
KeyGeneration: statement?.KeyGeneration,
|
||||
WrappedPrivateKey: wrappedPrivateKey,
|
||||
KdfParameters: kdfParameters,
|
||||
Vaults: personalVault is null ? [] : [personalVault]));
|
||||
|
||||
// Team vaults alongside the personal one, in the order the real /me returns them: this is
|
||||
// where a vault somebody shared arrives, and a fake that only ever reported the personal one
|
||||
// would make a refresh that admits a new vault untestable.
|
||||
Vaults: personalVault is null ? [] : [personalVault, .. teamVaults.Values]));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EnrollmentResponse> EnrollAsync(
|
||||
@@ -117,6 +121,10 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
wrappedPrivateKey = request.WrappedPrivateKey;
|
||||
kdfParameters = request.KdfParameters;
|
||||
|
||||
// The enrolling account joins the directory and the key log, as it does on the real server. Both
|
||||
// are what a later share reads: this client verifies its own entry as part of verifying anyone's.
|
||||
RegisterSelf(request.Statement, request.StatementSignature);
|
||||
|
||||
personalVault = new VaultSummary(
|
||||
request.PersonalVault.VaultId,
|
||||
request.PersonalVault.Name,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
// see the csproj for why it is shared rather than reimplemented.
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The reason this suite exists rather than leaving teams to the server's own tests is that the
|
||||
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
|
||||
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
|
||||
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
|
||||
/// lookup with a key of their own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
|
||||
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
|
||||
/// sharing works, not that verification does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TeamSharingTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeVaultServer server = new();
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
private string directory = null!;
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultKnownHostStore knownHosts = null!;
|
||||
private FakeDeviceKeyStore deviceKeys = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
|
||||
|
||||
var paths = new ClientPaths(directory);
|
||||
|
||||
caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||
knownHosts = new VaultKnownHostStore();
|
||||
deviceKeys = new FakeDeviceKeyStore();
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(
|
||||
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
ssh,
|
||||
TimeProvider.System);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
deviceKeys,
|
||||
(_, _) => Task.FromResult<IVaultServer>(server),
|
||||
TimeProvider.System,
|
||||
NSubstitute.Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
caches.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A cache file the process has not finished releasing. The directory is under the temp path
|
||||
// and named per run, so leaving it costs a few kilobytes and never collides.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole point of a team, in one test. Note what the status line says after the add and before
|
||||
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
|
||||
/// than let a user believe the credential is already with their colleague.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
teams.Vaults.Count.ShouldBe(1, teams.Status);
|
||||
|
||||
teams.InviteEmail = "bob@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Members.Count.ShouldBe(2, teams.Status);
|
||||
teams.Status.ShouldContain("cannot read anything yet");
|
||||
|
||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
||||
teams.SelectedVault = teams.Vaults[0];
|
||||
|
||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = teams.Vaults[0].VaultId;
|
||||
|
||||
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
||||
teams.Status.ShouldContain("Shared");
|
||||
|
||||
// The one thing verification cannot promise, said in the same breath as the success.
|
||||
teams.Status.ShouldContain("fingerprint", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The test this whole design exists for. A server that wants to read a team's vault only has to
|
||||
/// answer one directory lookup with a key it holds the private half of — so the client reads the
|
||||
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
|
||||
/// offered is in there unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
|
||||
/// worse than no check at all, because the interface would have said it was verified.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.InviteEmail = "mallory@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
||||
teams.SelectedVault = teams.Vaults[0];
|
||||
|
||||
server.CorruptKeyLog = true;
|
||||
|
||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
server.IssuedGrants.ShouldBeEmpty();
|
||||
teams.Status.ShouldContain("Did not share");
|
||||
teams.Status.ShouldContain("key log");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A vault created here is usable here, without a relock. The key was generated in this process, so
|
||||
/// making the user lock and unlock to reach the vault they just made would be asking them to work
|
||||
/// around bookkeeping.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = teams.Vaults[0].VaultId;
|
||||
var session = shell.Vault!.Session;
|
||||
|
||||
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
|
||||
|
||||
// And it is offered as somewhere to file a new item, which is what makes it worth having.
|
||||
await shell.Vault.LoadAsync(Token);
|
||||
|
||||
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
|
||||
shell.Vault.HasVaultChoice.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
|
||||
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
|
||||
/// with a half-typed host on screen would move it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostFiledIntoATeamVault_StaysThere()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var teamVaultId = teams.Vaults[0].VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
// Moved back after the editor opened. The host must still land in the team's vault.
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var row = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
row.VaultId.ShouldBe(teamVaultId);
|
||||
}
|
||||
|
||||
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
||||
{
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
teams.NewTeamCommand.Execute(null);
|
||||
teams.NewTeamName = name;
|
||||
teams.NewTeamSlug = slug;
|
||||
|
||||
await teams.CreateTeamCommand.ExecuteAsync(null);
|
||||
|
||||
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
|
||||
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
|
||||
/// that entry is what the client verifies its own directory answer against.
|
||||
/// </remarks>
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await shell.StartAsync(Token);
|
||||
await shell.SignInCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
shell.ConfirmPassphrase = Passphrase;
|
||||
await shell.EnrollCommand.ExecuteAsync(null);
|
||||
|
||||
shell.RecoveryCodeWrittenDown = true;
|
||||
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,7 @@ public sealed class SessionLifecycleTests : IAsyncLifetime
|
||||
await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
|
||||
|
||||
var transport = new EmptySyncApi();
|
||||
var report = await session.SyncAsync(transport, Token);
|
||||
var report = await session.SyncAsync(transport, session.ActiveVaultId, Token);
|
||||
|
||||
report.Pushed.ShouldBe(1);
|
||||
transport.PushCount.ShouldBe(1);
|
||||
|
||||
@@ -84,7 +84,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
||||
var host = BuildHost(keyId);
|
||||
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
|
||||
|
||||
var pushed = await laptop.SyncAsync(connection.Sync, Token);
|
||||
var pushed = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
|
||||
pushed.Pushed.ShouldBe(2);
|
||||
pushed.NeedsAttention.ShouldBeFalse();
|
||||
|
||||
@@ -96,7 +96,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
||||
// item type exists to make and the only place it is proved through a real server.
|
||||
var pin = await OpenAShellAsync(laptop, host);
|
||||
|
||||
var trusted = await laptop.SyncAsync(connection.Sync, Token);
|
||||
var trusted = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
|
||||
trusted.Pushed.ShouldBe(1, "the host key the user approved at the prompt");
|
||||
trusted.NeedsAttention.ShouldBeFalse();
|
||||
|
||||
@@ -234,7 +234,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
||||
var desktop = await UnlockAsync(desktopCache);
|
||||
await using var session = desktop;
|
||||
|
||||
var pulled = await desktop.SyncAsync(connection.Sync, Token);
|
||||
var pulled = await desktop.SyncAsync(connection.Sync, desktop.ActiveVaultId, Token);
|
||||
pulled.Pulled.ShouldBe(3, "the host, the key and the approved host key, in one pass");
|
||||
|
||||
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
|
||||
|
||||
Reference in New Issue
Block a user