Public Access
Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same place, but three were one branch changing what the other had moved or renamed, and those are the ones worth reading. The shell keeps both new fields and both constructor lines: the connection recorder this branch built and the teams view model main did. Where main put a teams load inside OnScreenChanged, it now sits beside the logs refresh rather than inside RaiseSurfaceState — this branch extracted that notification block and it is called from two properties, so a screen-specific side effect in there would fire on every terminal switch as well. Main gave four row types a vault id and a vault name, and this branch had moved one of them — KnownHostRowViewModel — into its own file when the pinned keys became a screen. Git resolved that as "deleted here, modified there" and took the delete, which compiles as long as nobody looks: the moved copy still had the two-argument constructor and the call site had grown to four. Carried over by hand, along with the ordering the pins list now does on them. The status line's quiet rule was the subtle one. Main extracted it into IsWorthReporting; this branch had changed the same condition to read item counts rather than raw ones, because every user action queues a log entry a moment later and this machine reads its own entries back on the next pull. Take main's structure and the merge builds, passes, and silently restores a bug this branch existed partly to fix — every save's message overwritten a second after it appears. The method now reads PulledItems and PushedItems, with the reason in its remarks. Two conflicts were prose that had gone stale rather than code. The keychain screen's comment said team vaults are refused by the server's access service, which was true when it was written and is not now; main's replacement stands, in this branch's vocabulary. The design-gaps row for groups was claimed by both — real host groups here, per-vault headings there — and they are different things, so both rows stay and the difference is stated: a group is a shelf the user chose, a vault is who can read the item. One defect the tests found and the compiler could not. Generating a key opens the same editor as pasting one, but not through NewKey — so it never set the target vault main added, and a generated key was filed into whatever vault was edited last, or none. Both key-generation tests failed on it. Fixed where the editor opens, with the reason recorded there. One gap is left deliberately and is written down rather than half-built. Hosts, keys, credentials and pins are read across every vault this session holds a key for; groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar already shows for a group that has been deleted — but closing it needs a vault id on every group row for rename and delete, and a way to tell two vaults' identically-named groups apart under a layout with one heading per group. Both are worth doing and neither is a merge's business. It is in the remarks on ReloadGroupsAsync and in docs/design-import-gaps.md. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1282 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -46,13 +46,23 @@ public interface IVaultAccessService
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the
|
||||
/// <c>v_user_vault_permission</c> view and per-resource ACLs arrive in M3 — this is the one place
|
||||
/// that changes, which is why every caller goes through it rather than comparing owner ids inline.
|
||||
/// Two rules, and only two. A personal vault answers to its owner. A team vault answers to the
|
||||
/// team's active members, with the role deciding how much. Everything else is denied, which is what
|
||||
/// keeps an unimplemented ownership kind from falling through to a permissive default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A team vault is explicitly denied for now rather than falling through to a permissive default.
|
||||
/// Failing closed on an unimplemented path is the only safe direction.
|
||||
/// <b>Permission is not the same thing as readability.</b> This service decides what the
|
||||
/// <em>server</em> will serve; whether the caller can decrypt what it serves depends on holding a
|
||||
/// vault key grant, which the server cannot produce and cannot verify. A member with Read and no
|
||||
/// grant is a normal, temporary state — they have just been added, or the vault has been rekeyed —
|
||||
/// and <c>VaultSummary.WrappedVaultKey</c> is null for exactly that case. Conflating the two here
|
||||
/// would mean a newly added member's vault silently vanished from their list instead of appearing
|
||||
/// and saying it is waiting for a key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately not a database view. <c>v_user_vault_permission</c> was sketched for this, and the
|
||||
/// rules turned out to be sixteen lines of C# that both methods share — a view would have put the
|
||||
/// authorization model somewhere migrations own and tests cannot reach without a container.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
|
||||
@@ -80,14 +90,23 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
|
||||
return VaultAccess.Denied;
|
||||
}
|
||||
|
||||
if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId)
|
||||
if (vault.OwnerKind == VaultOwnerKind.Personal)
|
||||
{
|
||||
return new VaultAccess(vault, OwnerPermissions);
|
||||
return vault.OwnerUserId == userId
|
||||
? new VaultAccess(vault, OwnerPermissions)
|
||||
: VaultAccess.Denied;
|
||||
}
|
||||
|
||||
// Team vaults are not readable until M3 wires up membership and grants. Denying is the
|
||||
// correct behaviour in the meantime.
|
||||
return VaultAccess.Denied;
|
||||
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
|
||||
{
|
||||
return VaultAccess.Denied;
|
||||
}
|
||||
|
||||
var role = await FindRoleAsync(userId, teamId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return role is { } granted
|
||||
? new VaultAccess(vault, ForRole(granted))
|
||||
: VaultAccess.Denied;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -95,16 +114,98 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Personal ownership only, matching ResolveAsync. When M3 adds the
|
||||
// v_user_vault_permission view, both methods change together and neither can drift.
|
||||
// The memberships first, then one pass over the vaults. The alternative — a join per vault —
|
||||
// would be the same answer at more round trips, and a user belongs to a handful of teams.
|
||||
var roles = await database.TeamMemberships
|
||||
.Where(m => m.UserId == userId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.ToDictionaryAsync(m => m.TeamId, m => m.Role, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var teamIds = roles.Keys.ToArray();
|
||||
|
||||
var vaults = await database.Vaults
|
||||
.Where(v => v.OwnerKind == VaultOwnerKind.Personal
|
||||
&& v.OwnerUserId == userId
|
||||
&& v.DeletedAtUtc == null)
|
||||
.Where(v => v.DeletedAtUtc == null
|
||||
&& ((v.OwnerKind == VaultOwnerKind.Personal && v.OwnerUserId == userId)
|
||||
|| (v.OwnerKind == VaultOwnerKind.Team
|
||||
&& v.TeamId != null
|
||||
&& teamIds.Contains(v.TeamId.Value))))
|
||||
.OrderBy(v => v.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))];
|
||||
var accessible = new List<VaultAccess>(vaults.Count);
|
||||
|
||||
foreach (var vault in vaults)
|
||||
{
|
||||
if (vault.OwnerKind == VaultOwnerKind.Personal)
|
||||
{
|
||||
accessible.Add(new VaultAccess(vault, OwnerPermissions));
|
||||
continue;
|
||||
}
|
||||
|
||||
// The dictionary lookup cannot miss — the query filtered on the same set — but a role
|
||||
// that somehow is not there must not become a permissive default.
|
||||
if (vault.TeamId is { } teamId && roles.TryGetValue(teamId, out var role))
|
||||
{
|
||||
accessible.Add(new VaultAccess(vault, ForRole(role)));
|
||||
}
|
||||
}
|
||||
|
||||
return accessible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a team role onto vault permissions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Union-only, with no Deny: evaluation stays monotonic and testable, and restriction is
|
||||
/// expressed by granting narrowly. See <see cref="PermissionFlags"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="PermissionFlags.Connect"/> rides along with Read for every role that has it,
|
||||
/// because it is a user-interface hint rather than a boundary — a role that could read a private
|
||||
/// key but was refused Connect would be describing a restriction this architecture cannot
|
||||
/// enforce. Granting it to a viewer is honest about that; withholding it would not be.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Owner and Admin resolve identically here on purpose. What separates them is what they may do
|
||||
/// to the <em>team</em> — appoint owners, delete it — which is not a vault permission and is
|
||||
/// checked where those operations live.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static PermissionFlags ForRole(TeamRole role) => role switch
|
||||
{
|
||||
TeamRole.Viewer => PermissionFlags.Read | PermissionFlags.Connect,
|
||||
TeamRole.Member => PermissionFlags.Read | PermissionFlags.Connect | PermissionFlags.Write,
|
||||
TeamRole.Admin or TeamRole.Owner => OwnerPermissions,
|
||||
|
||||
// Unspecified, or a value written by a newer server. Failing closed is the only safe
|
||||
// direction for a role this build does not understand.
|
||||
_ => PermissionFlags.None,
|
||||
};
|
||||
|
||||
/// <remarks>
|
||||
/// Only an <see cref="MembershipStatus.Active"/> membership confers anything. An invited member
|
||||
/// has not accepted and a revoked one has been removed; neither is a state in which the server
|
||||
/// should be serving ciphertext.
|
||||
/// </remarks>
|
||||
private async Task<TeamRole?> FindRoleAsync(
|
||||
Guid userId,
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await database.TeamMemberships
|
||||
.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == userId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return membership?.Role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The public-key directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is where a client gets the key it is about to wrap a vault key to, so its shape is a security
|
||||
/// decision rather than a convenience one. Two rules follow from that.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lookup by exact email, never by prefix.</b> There is no search, no wildcard and no listing of
|
||||
/// everybody. A caller has to already know the address, which keeps this from being a way to
|
||||
/// enumerate an organisation's staff out of a server that stores their addresses in plaintext.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lookup by id is restricted to people the caller shares a team with.</b> Ids come from a member
|
||||
/// list the caller can already read, so nothing is hidden that they cannot reach another way — but an
|
||||
/// unrestricted id lookup would turn a leaked id from any source into a directory hit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this returns is <em>evidence</em>, not authority. A client must check the identity-provider
|
||||
/// binding, compare against any fingerprint it has pinned, and confirm the key log head before
|
||||
/// wrapping anything. Trusting the directory's word is the one mistake that undoes end-to-end
|
||||
/// encryption entirely; see ADR 0001 and <c>DirectoryEntry</c>'s own remarks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DirectoryService(DodoDbContext database)
|
||||
{
|
||||
/// <summary>Looks a user up by exact email address.</summary>
|
||||
internal async Task<IReadOnlyList<DirectoryEntry>> FindByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalised = email.Trim();
|
||||
|
||||
if (normalised.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// The email column is citext, so this comparison is case-insensitive in the database and the
|
||||
// partial unique index on it means at most one row can match. Written as a list anyway
|
||||
// because the response shape must not have to change if a second issuer ever shares one.
|
||||
var users = await database.Users
|
||||
.Where(u => u.Email == normalised
|
||||
&& u.DeletedAtUtc == null
|
||||
&& u.Status == UserStatus.Active)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Looks up accounts the caller shares an active team with.</summary>
|
||||
internal async Task<IReadOnlyList<DirectoryEntry>> FindTeammatesAsync(
|
||||
Guid callerId,
|
||||
IReadOnlyList<Guid> userIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (userIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var teamIds = await database.TeamMemberships
|
||||
.Where(m => m.UserId == callerId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.Select(m => m.TeamId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (teamIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var visible = await database.TeamMemberships
|
||||
.Where(m => teamIds.Contains(m.TeamId)
|
||||
&& userIds.Contains(m.UserId)
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.Select(m => m.UserId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var users = await database.Users
|
||||
.Where(u => visible.Contains(u.Id) && u.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A user with no current key is dropped rather than returned with empty key fields. The entry
|
||||
/// exists to be wrapped to, and one carrying no key is something a caller would have to remember
|
||||
/// to check for — which is the kind of check that gets forgotten exactly once.
|
||||
/// </remarks>
|
||||
private async Task<IReadOnlyList<DirectoryEntry>> BuildAsync(
|
||||
List<UserAccount> users,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (users.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var userIds = users.Select(u => u.Id).ToArray();
|
||||
|
||||
var keys = await database.UserKeys
|
||||
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// The log position of the statement that introduced each key, so a client can compare what
|
||||
// it is told here against the append-only chain rather than taking this response on trust.
|
||||
var keyIds = keys.Select(k => k.UserId).ToArray();
|
||||
|
||||
var sequences = await database.KeyLog
|
||||
.Where(e => keyIds.Contains(e.UserId))
|
||||
.GroupBy(e => new { e.UserId, e.Generation })
|
||||
.Select(g => new { g.Key.UserId, g.Key.Generation, Sequence = g.Min(e => e.Sequence) })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entries = new List<DirectoryEntry>(users.Count);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var key = keys.Find(k => k.UserId == user.Id);
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sequence = sequences
|
||||
.Find(s => s.UserId == user.Id && s.Generation == key.Generation)?
|
||||
.Sequence ?? 0;
|
||||
|
||||
entries.Add(new DirectoryEntry(
|
||||
UserId: user.Id,
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
EncryptionPublicKey: key.EncryptionPublicKey,
|
||||
SigningPublicKey: key.SigningPublicKey,
|
||||
Fingerprint: key.FingerprintSha256,
|
||||
KeyGeneration: key.Generation,
|
||||
KeyLogSequence: sequence));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,117 @@ internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, Enrollment
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the public keys a vault key can be wrapped to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A GET with query parameters rather than a request DTO, because <c>BodyOnlyRequestBinder</c> binds
|
||||
/// bodies and nothing else — deliberately, so that a query string can never overwrite a body field —
|
||||
/// and this call has no body to speak of. The parameters are read one at a time, as route values are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Exactly one of <c>email</c> and <c>userId</c> is expected. They are separate parameters rather than
|
||||
/// one polymorphic term because they answer to different rules: an email may name anybody enrolled
|
||||
/// here, an id only somebody the caller shares a team with. See <see cref="DirectoryService"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class LookupDirectoryEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
DirectoryService directory)
|
||||
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/directory");
|
||||
|
||||
// Enrolled. The answer exists to be wrapped to, and a caller with no identity key of their
|
||||
// own has nothing to wrap and no signature to attribute it with.
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("LookupDirectory")
|
||||
.WithSummary("Looks up a user's published identity keys, by exact email or by id.")
|
||||
.WithTags("Identity"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var email = HttpContext.Request.Query["email"].ToString();
|
||||
var rawUserId = HttpContext.Request.Query["userId"].ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return TypedResults.Ok(await directory.FindByEmailAsync(email, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
if (Guid.TryParse(rawUserId, out var userId))
|
||||
{
|
||||
return TypedResults.Ok(
|
||||
await directory.FindTeammatesAsync(user.Id, [userId], ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
// An empty result would be indistinguishable from "nobody has that address", which is a
|
||||
// different fact and one a client would go on to act on.
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest,
|
||||
ProblemCodes.MalformedRequest,
|
||||
"Supply either an exact 'email' or a 'userId'. This directory has no search.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the append-only key log, so a client can verify a public key rather than trust one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Paged with <c>after</c> and <c>limit</c> on the query string, read one at a time as route values
|
||||
/// are — see <see cref="LookupDirectoryEndpoint"/> for why this endpoint has no request DTO.
|
||||
/// </remarks>
|
||||
internal sealed class ReadKeyLogEndpoint(KeyLogService keyLog)
|
||||
: EndpointWithoutRequest<Ok<KeyLogPage>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/keylog");
|
||||
|
||||
// Enrolled rather than authenticated, matching the directory it exists to check. Nothing here
|
||||
// is secret, but a caller with no key of their own has nothing to verify against.
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ReadKeyLog")
|
||||
.WithSummary("Reads the append-only key log, with its current head.")
|
||||
.WithTags("Identity"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Ok<KeyLogPage>> ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
// A malformed value reads as "from the beginning" rather than as an error. The log is public
|
||||
// and ordered, so the worst a bad cursor costs is a larger response — and a 400 here would
|
||||
// make a client's own paging bug look like a server refusal.
|
||||
_ = long.TryParse(
|
||||
HttpContext.Request.Query["after"],
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var after);
|
||||
|
||||
int? limit = int.TryParse(
|
||||
HttpContext.Request.Query["limit"],
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
return TypedResults.Ok(await keyLog.ReadAsync(after, limit, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registers a device key so this machine can unlock without the passphrase.</summary>
|
||||
/// <remarks>
|
||||
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Serves the append-only key log.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Readable by every enrolled caller, in full. That is the point of it: a log only one party can read
|
||||
/// proves nothing, and the whole mechanism is that independent clients compare what they were shown.
|
||||
/// Nothing here is secret — public keys, signatures over them, and hashes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server never edits this table and this service never writes to it. Appends happen in exactly
|
||||
/// one place, under a deployment-wide advisory lock, inside the enrollment transaction; see
|
||||
/// <see cref="EnrollmentService"/> and docs/crypto.md §7.2 for why serialising them is load-bearing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class KeyLogService(DodoDbContext database)
|
||||
{
|
||||
/// <summary>Largest page served, whatever a caller asks for.</summary>
|
||||
/// <remarks>
|
||||
/// A client verifying the chain has to read every entry in order, so paging is a transfer-size
|
||||
/// concern rather than a filter. The cap is generous because skipping entries is not an option:
|
||||
/// a gap breaks the link and the verification fails, correctly, on data that was fine.
|
||||
/// </remarks>
|
||||
private const int MaxPageSize = 500;
|
||||
|
||||
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
|
||||
internal async Task<KeyLogPage> ReadAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var take = Math.Clamp(limit ?? MaxPageSize, 1, MaxPageSize);
|
||||
|
||||
var entries = await database.KeyLog
|
||||
.Where(e => e.Sequence > afterSequence)
|
||||
.OrderBy(e => e.Sequence)
|
||||
|
||||
// One more than asked for, so "is there another page" is answered by what came back
|
||||
// rather than by a second count that could disagree with it.
|
||||
.Take(take + 1)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var hasMore = entries.Count > take;
|
||||
|
||||
if (hasMore)
|
||||
{
|
||||
entries.RemoveAt(entries.Count - 1);
|
||||
}
|
||||
|
||||
var head = await database.KeyLog
|
||||
.OrderByDescending(e => e.Sequence)
|
||||
.Select(e => new { e.Sequence, e.Hash })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new KeyLogPage(
|
||||
Entries:
|
||||
[
|
||||
.. entries.Select(e => new KeyLogRecord(
|
||||
e.Sequence,
|
||||
e.UserId,
|
||||
e.Generation,
|
||||
e.EncryptionPublicKey,
|
||||
e.SigningPublicKey,
|
||||
e.StatementSignature,
|
||||
e.PreviousHash,
|
||||
e.Hash,
|
||||
e.CreatedAtUtc)),
|
||||
],
|
||||
|
||||
HeadSequence: head?.Sequence ?? 0,
|
||||
|
||||
// The genesis predecessor for an empty log, which is the same value the first entry will
|
||||
// record. A client comparing heads therefore needs no special case for "nothing yet".
|
||||
Head: head?.Hash ?? KeyLogChain.CreateGenesisPreviousHash(),
|
||||
|
||||
HasMore: hasMore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>Creates a team, with the caller as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// 200 rather than 201, for the reason enrollment gives: the id is chosen by the client, so a retried
|
||||
/// request returns the identical team and there is no single moment of creation to point a Location
|
||||
/// header at.
|
||||
/// </remarks>
|
||||
internal sealed class CreateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: Endpoint<CreateTeamRequest, Results<Ok<TeamSummary>, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/teams");
|
||||
|
||||
// Enrolled, not merely authenticated. Somebody who has not published an identity key cannot
|
||||
// be wrapped a vault key, so a team they created would be one they could never share
|
||||
// anything into — and the flag they would hit instead is a 400 from the grant endpoint,
|
||||
// several steps later, about a request that was fine.
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("CreateTeam")
|
||||
.WithSummary("Creates a team, with the caller as its owner.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<TeamSummary>, ProblemHttpResult>> ExecuteAsync(
|
||||
CreateTeamRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
return TypedResults.Ok(await teams.CreateAsync(user, req, ct).ConfigureAwait(false));
|
||||
}
|
||||
catch (TeamSlugTakenException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status409Conflict, ProblemCodes.TeamSlugTaken, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lists the teams the caller belongs to.</summary>
|
||||
internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: EndpointWithoutRequest<Ok<IReadOnlyList<TeamSummary>>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/teams");
|
||||
|
||||
// Authenticated rather than enrolled: reading which teams you are in needs no key, and a
|
||||
// member who has just been added should be able to see that before they set a vault up.
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ListTeams")
|
||||
.WithSummary("Lists the teams the caller belongs to.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Ok<IReadOnlyList<TeamSummary>>> ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(await teams.ListAsync(user, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lists a team's members.</summary>
|
||||
internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/teams/{teamId:guid}/members");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ListTeamMembers")
|
||||
.WithSummary("Lists a team's members.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
// 404 for a team that is not there and one the caller is not in, identically. See
|
||||
// VaultAccessService for why the two must not be distinguishable.
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(await teams.ListMembersAsync(teamId, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds a member to a team.</summary>
|
||||
internal sealed class AddTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: Endpoint<AddTeamMemberRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/teams/{teamId:guid}/members");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("AddTeamMember")
|
||||
.WithSummary("Adds a member to a team.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
AddTeamMemberRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
// 403 rather than 404 here: the team is visible to this caller, so refusing by name leaks
|
||||
// nothing and "you are not an admin" is a far more useful answer than "no such team".
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can add members.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var member = await teams.AddMemberAsync(user, teamId, req, ct).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(member);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
internal sealed class ChangeTeamMemberRoleEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: Endpoint<ChangeTeamMemberRoleRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
// PUT rather than PATCH. The body is the whole of what a role is, so this replaces it
|
||||
// outright and is idempotent; PATCH would promise a partial update of a single scalar.
|
||||
Put("/api/v1/teams/{teamId:guid}/members/{userId:guid}/role");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ChangeTeamMemberRole")
|
||||
.WithSummary("Changes a member's role.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
ChangeTeamMemberRoleRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
var memberId = Route<Guid>("userId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can change roles.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var member = await teams
|
||||
.ChangeRoleAsync(user, teamId, memberId, req, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(member);
|
||||
}
|
||||
catch (LastTeamOwnerException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a member from a team.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A member may remove themselves — leaving a team needs nobody's permission — but not while they
|
||||
/// own it. Everyone else needs to be an admin.
|
||||
/// </remarks>
|
||||
internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/v1/teams/{teamId:guid}/members/{userId:guid}");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("RemoveTeamMember")
|
||||
.WithSummary("Removes a member from a team, revoking their vault key grants.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
var memberId = Route<Guid>("userId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister && memberId != user.Id)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can remove other members.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await teams.RemoveMemberAsync(user, teamId, memberId, ct).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (LastTeamOwnerException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by a team.</summary>
|
||||
internal sealed class CreateTeamVaultEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
TeamService teams,
|
||||
VaultGrantService grants)
|
||||
: Endpoint<CreateTeamVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/teams/{teamId:guid}/vaults");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("CreateTeamVault")
|
||||
.WithSummary("Creates a vault owned by a team, with the creator's key grant.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CreateTeamVaultRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can create a vault in it.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var vault = await grants
|
||||
.CreateTeamVaultAsync(user, access.Team!, req, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(vault);
|
||||
}
|
||||
catch (VaultGrantInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// A team create or membership change was structurally unacceptable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message is returned to the caller. Keep it about the shape of their own request, and never
|
||||
/// about accounts or teams they cannot already see — "no such user" is safe when the caller supplied
|
||||
/// the id from a directory lookup they just made, and is an enumeration oracle everywhere else.
|
||||
/// </remarks>
|
||||
internal sealed class TeamInvalidException(string message) : Exception(message);
|
||||
|
||||
/// <summary>The requested slug is already in use.</summary>
|
||||
/// <remarks>
|
||||
/// Its own type because it is the one create failure the caller could not have predicted from their
|
||||
/// own input, and the only one whose remedy is choosing a different value rather than fixing one.
|
||||
/// </remarks>
|
||||
internal sealed class TeamSlugTakenException(string message) : Exception(message);
|
||||
|
||||
/// <summary>The change would leave a team with no owner.</summary>
|
||||
/// <remarks>
|
||||
/// Refused rather than allowed: a team with no owner has nobody who can appoint one, so the only
|
||||
/// route back would be an operator editing the database by hand.
|
||||
/// </remarks>
|
||||
internal sealed class LastTeamOwnerException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// A vault key grant was rejected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Never about the wrapped key's contents. The server cannot open it, so a grant sealing garbage is
|
||||
/// accepted here and fails at the recipient as a tag failure, with the signature naming who issued
|
||||
/// it. See docs/crypto.md §6.
|
||||
/// </remarks>
|
||||
internal sealed class VaultGrantInvalidException(string message) : Exception(message);
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated log events for teams, membership and vault key grants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ids, roles and outcomes only. Never a wrapped key, a signature or a fingerprint: the sharing graph
|
||||
/// is already visible to the operator (docs/crypto.md §10) and there is nothing to gain by adding key
|
||||
/// material to what a log aggregator keeps.
|
||||
/// </remarks>
|
||||
internal static partial class TeamLog
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 2101,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Created team {TeamId} for user {UserId}.")]
|
||||
internal static partial void TeamCreated(ILogger logger, Guid teamId, Guid userId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2102,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Added user {MemberId} to team {TeamId} as {Role}, by {ActorId}.")]
|
||||
internal static partial void MemberAdded(
|
||||
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2103,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Changed user {MemberId} in team {TeamId} to {Role}, by {ActorId}.")]
|
||||
internal static partial void MemberRoleChanged(
|
||||
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
|
||||
|
||||
/// <remarks>
|
||||
/// Warning rather than information, and it names the grant count. Removal is the operation whose
|
||||
/// consequences are least like what the word implies — it blocks future reads and returns nothing
|
||||
/// already downloaded — so it is the one worth being able to find in a log afterwards.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2104,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Removed user {MemberId} from team {TeamId} by {ActorId}; revoked {GrantCount} vault "
|
||||
+ "key grant(s). Vaults are flagged for rekey; already-downloaded data is unaffected.")]
|
||||
internal static partial void MemberRemoved(
|
||||
ILogger logger, Guid teamId, Guid memberId, Guid actorId, int grantCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2105,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Created team vault {VaultId} for team {TeamId}, by {ActorId}.")]
|
||||
internal static partial void TeamVaultCreated(
|
||||
ILogger logger, Guid vaultId, Guid teamId, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2106,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Issued a key grant on vault {VaultId} generation {KeyGeneration} to {RecipientId}, "
|
||||
+ "by {ActorId}.")]
|
||||
internal static partial void GrantIssued(
|
||||
ILogger logger, Guid vaultId, int keyGeneration, Guid recipientId, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2107,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Revoked the key grant on vault {VaultId} held by {RecipientId}, by {ActorId}. "
|
||||
+ "Blocks future reads only; see ADR 0001.")]
|
||||
internal static partial void GrantRevoked(
|
||||
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>The result of a team access check.</summary>
|
||||
/// <param name="Team">The team, when the caller is an active member.</param>
|
||||
/// <param name="Role">The caller's role.</param>
|
||||
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
|
||||
{
|
||||
/// <summary>Whether the caller is in this team at all.</summary>
|
||||
public bool Granted => Team is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the caller may manage members and vaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The team-level counterpart of <c>PermissionFlags.Admin</c>, and deliberately not derived from
|
||||
/// it: those flags describe a vault, and adding a member is not an operation on any vault.
|
||||
/// </remarks>
|
||||
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
|
||||
|
||||
/// <summary>Denied access.</summary>
|
||||
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teams and their membership.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Membership is authorization; a key grant is access.</b> Everything in this class moves rows
|
||||
/// that decide what the <em>server</em> will serve. None of it can make a vault readable, because
|
||||
/// making a vault readable means wrapping its key to somebody's public key and only a client holding
|
||||
/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so:
|
||||
/// add them here, then share the vault key from a machine that has one. Collapsing the two would
|
||||
/// require the server to hold a key, which is the one thing this design is built to avoid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The reverse direction is the honest half of the same split. Removing a member revokes their
|
||||
/// grants and flags every team vault for rekey, and that blocks <em>future</em> reads only. Anything
|
||||
/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See
|
||||
/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would
|
||||
/// imply more than it delivers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TeamService(
|
||||
DodoDbContext database,
|
||||
TimeProvider clock,
|
||||
ILogger<TeamService> logger)
|
||||
{
|
||||
/// <summary>Longest acceptable slug. Matches the column.</summary>
|
||||
private const int MaxSlugLength = 128;
|
||||
|
||||
/// <summary>Longest acceptable display name. Matches the column.</summary>
|
||||
private const int MaxNameLength = 256;
|
||||
|
||||
/// <summary>Longest acceptable description. Matches the column.</summary>
|
||||
private const int MaxDescriptionLength = 2048;
|
||||
|
||||
/// <summary>Creates a team, with the caller as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was
|
||||
/// lost can be re-sent verbatim and returns the same team rather than creating a second one under
|
||||
/// a name the user meant to type once. A different body under the same id is a client that has
|
||||
/// lost track of its own state and is refused rather than silently reinterpreted.
|
||||
/// </remarks>
|
||||
internal async Task<TeamSummary> CreateAsync(
|
||||
UserAccount user,
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
|
||||
var slug = RequireSlug(request.Slug);
|
||||
var description = OptionalText(request.Description, MaxDescriptionLength);
|
||||
|
||||
if (request.TeamId == Guid.Empty)
|
||||
{
|
||||
throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client.");
|
||||
}
|
||||
|
||||
var existing = await database.Teams
|
||||
.SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return await ResolveExistingAsync(user, existing, name, slug, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var team = AddTeamWithOwner(user, request.TeamId, name, slug, description);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The partial unique index on slug. Reported as its own code because it is the one
|
||||
// failure the caller could not have foreseen from their own input.
|
||||
throw new TeamSlugTakenException(
|
||||
$"The slug '{slug}' is already in use. Choose another.");
|
||||
}
|
||||
|
||||
TeamLog.TeamCreated(logger, team.Id, user.Id);
|
||||
|
||||
return new TeamSummary(
|
||||
team.Id, team.Name, team.Slug, team.Description,
|
||||
TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the team row and the creator's owner membership.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two together, never one: a team with no members has nobody who can add any, and the row
|
||||
/// would have to be found and fixed by hand.
|
||||
/// </remarks>
|
||||
private Team AddTeamWithOwner(
|
||||
UserAccount user,
|
||||
Guid teamId,
|
||||
string name,
|
||||
string slug,
|
||||
string? description)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var team = new Team
|
||||
{
|
||||
Id = teamId,
|
||||
Name = name,
|
||||
Slug = slug,
|
||||
Description = description,
|
||||
CreatedByUserId = user.Id,
|
||||
CreatedAtUtc = now,
|
||||
};
|
||||
|
||||
database.Teams.Add(team);
|
||||
|
||||
database.TeamMemberships.Add(new TeamMembership
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TeamId = team.Id,
|
||||
UserId = user.Id,
|
||||
Role = TeamRole.Owner,
|
||||
Status = MembershipStatus.Active,
|
||||
JoinedAtUtc = now,
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
/// <summary>Lists the teams the caller is an active member of.</summary>
|
||||
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
|
||||
UserAccount user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => m.UserId == user.Id
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (memberships.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var teamIds = memberships.Select(m => m.TeamId).ToArray();
|
||||
|
||||
var teams = await database.Teams
|
||||
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
|
||||
.OrderBy(t => t.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var memberCounts = await database.TeamMemberships
|
||||
.Where(m => teamIds.Contains(m.TeamId)
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.GroupBy(m => m.TeamId)
|
||||
.Select(g => new { TeamId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var vaultCounts = await database.Vaults
|
||||
.Where(v => v.OwnerKind == VaultOwnerKind.Team
|
||||
&& v.TeamId != null
|
||||
&& teamIds.Contains(v.TeamId.Value)
|
||||
&& v.DeletedAtUtc == null)
|
||||
.GroupBy(v => v.TeamId!.Value)
|
||||
.Select(g => new { TeamId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return
|
||||
[
|
||||
.. teams.Select(team => new TeamSummary(
|
||||
team.Id,
|
||||
team.Name,
|
||||
team.Slug,
|
||||
team.Description,
|
||||
ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role),
|
||||
memberCounts.GetValueOrDefault(team.Id),
|
||||
vaultCounts.GetValueOrDefault(team.Id),
|
||||
team.CreatedAtUtc)),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists a team's members.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Available to every member, not only to admins. Whoever is about to be handed a vault key needs
|
||||
/// to know who else already holds one, and a directory that only administrators can read makes
|
||||
/// the sharing graph less visible to the people it is about than it is to the operator — who can
|
||||
/// read it straight out of the database either way.
|
||||
/// </remarks>
|
||||
internal async Task<IReadOnlyList<TeamMemberSummary>> ListMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => m.TeamId == teamId && m.DeletedAtUtc == null)
|
||||
.Include(m => m.User)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (memberships.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var userIds = memberships.Select(m => m.UserId).ToArray();
|
||||
|
||||
var enrolled = await database.UserKeys
|
||||
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
|
||||
.Select(k => k.UserId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var enrolledIds = enrolled.ToHashSet();
|
||||
|
||||
return
|
||||
[
|
||||
.. memberships
|
||||
.OrderByDescending(m => m.Role)
|
||||
.ThenBy(m => m.CreatedAtUtc)
|
||||
.Select(m => new TeamMemberSummary(
|
||||
m.UserId,
|
||||
m.User?.Email,
|
||||
m.User?.DisplayName,
|
||||
ToContract(m.Role),
|
||||
ToContract(m.Status),
|
||||
enrolledIds.Contains(m.UserId),
|
||||
m.JoinedAtUtc)),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Adds a member, or reactivates one who was removed.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
|
||||
/// somebody else is a transfer rather than an addition — a different operation with a different
|
||||
/// confirmation, and not one M3 offers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
|
||||
/// which is what keeps historic audit entries resolvable to one membership. It does <em>not</em>
|
||||
/// restore their revoked key grants: those were wrapped to a generation the vault has since been
|
||||
/// flagged to leave behind, and a member holding Share has to wrap the key afresh.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<TeamMemberSummary> AddMemberAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var role = ToDomain(request.Role);
|
||||
|
||||
if (role is TeamRole.Unspecified or TeamRole.Owner)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"Add a member as viewer, member or admin. Ownership is sole and is not transferred "
|
||||
+ "by adding somebody.");
|
||||
}
|
||||
|
||||
var target = await database.Users
|
||||
.SingleOrDefaultAsync(
|
||||
u => u.Id == request.UserId && u.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
|
||||
// Safe to be specific: the caller supplied this id from a directory lookup they just
|
||||
// made, so it confirms nothing they did not already know.
|
||||
?? throw new TeamInvalidException(
|
||||
"No such account on this server. A member has to sign in here once before they can "
|
||||
+ "be added — that is what creates the account and publishes the key a vault would "
|
||||
+ "be shared with.");
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var membership = await database.TeamMemberships
|
||||
.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId && m.UserId == target.Id && m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (membership is null)
|
||||
{
|
||||
membership = new TeamMembership
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TeamId = teamId,
|
||||
UserId = target.Id,
|
||||
InvitedByUserId = actor.Id,
|
||||
CreatedAtUtc = now,
|
||||
};
|
||||
|
||||
database.TeamMemberships.Add(membership);
|
||||
}
|
||||
else if (membership.Status == MembershipStatus.Active)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"That account is already a member of this team. Change their role instead.");
|
||||
}
|
||||
|
||||
membership.Role = role;
|
||||
membership.Status = MembershipStatus.Active;
|
||||
membership.JoinedAtUtc = now;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id);
|
||||
|
||||
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enrollment is looked up rather than inferred, because it is the one field on a member row that
|
||||
/// is about them and not about the membership: somebody can be added on Monday and set their
|
||||
/// vault up on Tuesday, and the interface has to stop offering to share with them in between.
|
||||
/// </remarks>
|
||||
private async Task<TeamMemberSummary> DescribeAsync(
|
||||
UserAccount user,
|
||||
TeamMembership membership,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isEnrolled = await database.UserKeys
|
||||
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new TeamMemberSummary(
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.DisplayName,
|
||||
ToContract(membership.Role),
|
||||
ToContract(membership.Status),
|
||||
isEnrolled,
|
||||
membership.JoinedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
internal async Task<TeamMemberSummary> ChangeRoleAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var role = ToDomain(request.Role);
|
||||
|
||||
if (role is TeamRole.Unspecified or TeamRole.Owner)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"A member may be made a viewer, a member or an admin. Ownership is sole and is not "
|
||||
+ "granted this way.");
|
||||
}
|
||||
|
||||
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Demoting the owner is what would leave the team ownerless, and there is no transfer to
|
||||
// do it through yet. Refused with the code a client can act on rather than a bare 400.
|
||||
if (membership.Role == TeamRole.Owner)
|
||||
{
|
||||
throw new LastTeamOwnerException(
|
||||
"This team's owner cannot be demoted, because nothing can appoint a replacement yet.");
|
||||
}
|
||||
|
||||
membership.Role = role;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id);
|
||||
|
||||
// The account cannot be missing — a membership has a foreign key to it — but the query is
|
||||
// written to tolerate it rather than to assert, because a null here would become an
|
||||
// exception on a change that has already been committed.
|
||||
var user = await database.Users
|
||||
.SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return user is null
|
||||
? new TeamMemberSummary(
|
||||
memberId, null, null, ToContract(role), ToContract(membership.Status), false,
|
||||
membership.JoinedAtUtc)
|
||||
: await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a member, revoking every vault key grant they hold from this team.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One transaction, because the two halves are not separable: a membership revoked without its
|
||||
/// grants leaves a departed member holding a key the server will happily keep serving, and grants
|
||||
/// revoked without the membership leaves an active member whose vaults have silently stopped
|
||||
/// opening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every affected vault is flagged <c>RekeyRequired</c> rather than rekeyed. A rekey re-wraps
|
||||
/// every item's data key under a new vault key and can only be performed by a client that holds
|
||||
/// the current one; the server can record that one is owed and nothing more. That is M5's key
|
||||
/// rotation, and until it lands the flag is what the interface reads to say so out loud.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task RemoveMemberAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (membership.Role == TeamRole.Owner)
|
||||
{
|
||||
throw new LastTeamOwnerException(
|
||||
"This team's owner cannot be removed. Ownership transfer is not implemented, so "
|
||||
+ "removing them would leave the team with nobody who can manage it.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
var revoked = await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
membership.Status = MembershipStatus.Revoked;
|
||||
membership.DeletedAtUtc = now;
|
||||
|
||||
var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return count;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);
|
||||
}
|
||||
|
||||
/// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
|
||||
private async Task<int> RevokeTeamGrantsAsync(
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vaults = await database.Vaults
|
||||
.Where(v => v.TeamId == teamId
|
||||
&& v.OwnerKind == VaultOwnerKind.Team
|
||||
&& v.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (vaults.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var vaultIds = vaults.Select(v => v.Id).ToArray();
|
||||
|
||||
var grants = await database.VaultKeyGrants
|
||||
.Where(g => vaultIds.Contains(g.VaultId)
|
||||
&& g.RecipientUserId == memberId
|
||||
&& g.RevokedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
grant.State = GrantState.Revoked;
|
||||
grant.RevokedAtUtc = now;
|
||||
}
|
||||
|
||||
// Flagged whether or not this member held a grant. Somebody who was a member without a key
|
||||
// still saw the vault's existence, its item count and its plaintext columns, and the vault's
|
||||
// key is what a rekey would change — so "they never had a grant" is not a reason to leave the
|
||||
// flag clear.
|
||||
foreach (var vault in vaults)
|
||||
{
|
||||
vault.RekeyRequired = true;
|
||||
vault.RekeyReason = RekeyReason.MemberRemoved;
|
||||
vault.UpdatedAtUtc = now;
|
||||
}
|
||||
|
||||
return grants.Count;
|
||||
}
|
||||
|
||||
/// <summary>Reads the caller's own membership, for authorization checks.</summary>
|
||||
internal Task<TeamMembership?> FindActiveMembershipAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
database.TeamMemberships.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == userId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves what the caller may do with a team.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Answers <see cref="TeamAccess.Denied"/> identically for a team that does not exist and one the
|
||||
/// caller is not in, for the reason <c>VaultAccessService</c> gives: a distinct "exists but
|
||||
/// forbidden" is an oracle for other tenants' team ids.
|
||||
/// </remarks>
|
||||
internal async Task<TeamAccess> ResolveAsync(
|
||||
Guid userId,
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var team = await database.Teams
|
||||
.SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (team is null)
|
||||
{
|
||||
return TeamAccess.Denied;
|
||||
}
|
||||
|
||||
var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role);
|
||||
}
|
||||
|
||||
private async Task<TeamMembership> RequireMembershipAsync(
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await database.TeamMemberships
|
||||
.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == memberId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return membership
|
||||
?? throw new TeamInvalidException("That account is not an active member of this team.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
|
||||
/// else under an id that is already taken is refused: silently returning somebody else's team
|
||||
/// would be an existence oracle, and returning a differently-named one would tell a client its
|
||||
/// rename succeeded when nothing changed.
|
||||
/// </remarks>
|
||||
private async Task<TeamSummary> ResolveExistingAsync(
|
||||
UserAccount user,
|
||||
Team existing,
|
||||
string name,
|
||||
string slug,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var isRetry = membership?.Role == TeamRole.Owner
|
||||
&& existing.DeletedAtUtc == null
|
||||
&& string.Equals(existing.Name, name, StringComparison.Ordinal)
|
||||
&& string.Equals(existing.Slug, slug, StringComparison.Ordinal);
|
||||
|
||||
if (!isRetry)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"That team id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
var memberCount = await database.TeamMemberships
|
||||
.CountAsync(
|
||||
m => m.TeamId == existing.Id
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var vaultCount = await database.Vaults
|
||||
.CountAsync(
|
||||
v => v.TeamId == existing.Id && v.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new TeamSummary(
|
||||
existing.Id, existing.Name, existing.Slug, existing.Description,
|
||||
TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a slug.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
|
||||
/// purpose: the column is <c>citext</c>, so a slug differing only in case is the same slug, and a
|
||||
/// value that renders differently from how it compares is how two teams end up looking distinct
|
||||
/// in a list and colliding on insert.
|
||||
/// </remarks>
|
||||
private static string RequireSlug(string? value)
|
||||
{
|
||||
var slug = (value ?? string.Empty).Trim();
|
||||
|
||||
if (slug.Length is 0 or > MaxSlugLength)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
$"A slug of 1 to {MaxSlugLength} characters is required.");
|
||||
}
|
||||
|
||||
var previousWasHyphen = false;
|
||||
|
||||
for (var index = 0; index < slug.Length; index++)
|
||||
{
|
||||
var character = slug[index];
|
||||
var isHyphen = character == '-';
|
||||
|
||||
var acceptable = (character is >= 'a' and <= 'z')
|
||||
|| (character is >= '0' and <= '9')
|
||||
|| isHyphen;
|
||||
|
||||
if (!acceptable
|
||||
|| (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1)))
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"A slug is lowercase letters, digits and single hyphens, and cannot start or end "
|
||||
+ "with a hyphen.");
|
||||
}
|
||||
|
||||
previousWasHyphen = isHyphen;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
private static string RequireText(string? value, string field, int maxLength)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
|
||||
if (text.Length == 0 || text.Length > maxLength)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{field} is required, and at most {maxLength} characters."));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string? OptionalText(string? value, int maxLength)
|
||||
{
|
||||
var text = value?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.Length > maxLength)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"A description is at most {maxLength} characters."));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A plain cast, which is why <c>TeamMemberRole</c> pins the same numeric values as
|
||||
/// <see cref="TeamRole"/> and a test asserts it. An unknown value becomes
|
||||
/// <see cref="TeamRole.Unspecified"/> rather than a silent cast to a role nobody defined, so a
|
||||
/// newer client's role is refused instead of resolving to whatever bit pattern it happens to be.
|
||||
/// </remarks>
|
||||
private static TeamRole ToDomain(TeamMemberRole role) => role switch
|
||||
{
|
||||
TeamMemberRole.Viewer => TeamRole.Viewer,
|
||||
TeamMemberRole.Member => TeamRole.Member,
|
||||
TeamMemberRole.Admin => TeamRole.Admin,
|
||||
TeamMemberRole.Owner => TeamRole.Owner,
|
||||
_ => TeamRole.Unspecified,
|
||||
};
|
||||
|
||||
private static TeamMemberRole ToContract(TeamRole role) => role switch
|
||||
{
|
||||
TeamRole.Viewer => TeamMemberRole.Viewer,
|
||||
TeamRole.Member => TeamMemberRole.Member,
|
||||
TeamRole.Admin => TeamMemberRole.Admin,
|
||||
TeamRole.Owner => TeamMemberRole.Owner,
|
||||
_ => TeamMemberRole.Unspecified,
|
||||
};
|
||||
|
||||
private static TeamMemberStatus ToContract(MembershipStatus status) => status switch
|
||||
{
|
||||
MembershipStatus.Invited => TeamMemberStatus.Invited,
|
||||
MembershipStatus.Active => TeamMemberStatus.Active,
|
||||
MembershipStatus.Revoked => TeamMemberStatus.Revoked,
|
||||
_ => TeamMemberStatus.Unspecified,
|
||||
};
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception) =>
|
||||
string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>Lists who can open a vault.</summary>
|
||||
/// <remarks>
|
||||
/// Read, not Share. Every member who can read a vault can already see the sharing graph — the server
|
||||
/// stores it in plaintext and says so in docs/crypto.md §10 — so gating this on Share would hide from
|
||||
/// the people it is about something the operator can read either way.
|
||||
/// </remarks>
|
||||
internal sealed class ListVaultGrantsEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
VaultGrantService grants)
|
||||
: EndpointWithoutRequest<Results<Ok<VaultGrantsResponse>, NotFound>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/vaults/{vaultId:guid}/grants");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ListVaultGrants")
|
||||
.WithSummary("Lists who holds a key to this vault.")
|
||||
.WithTags("Vaults"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<VaultGrantsResponse>, NotFound>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var access = await vaultAccess
|
||||
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(await grants.ListGrantsAsync(access.Vault!, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wraps this vault's key to another member.</summary>
|
||||
/// <remarks>
|
||||
/// The one call in this API whose body the server can neither produce nor check. It stores a sealed
|
||||
/// key and a signature over a tuple it never verifies — see docs/crypto.md §6 and §7 — which is
|
||||
/// exactly why sharing is a client operation with a server-side record rather than a server feature.
|
||||
/// </remarks>
|
||||
internal sealed class IssueVaultGrantEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
VaultGrantService grants)
|
||||
: Endpoint<IssueVaultGrantRequest, Results<NoContent, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/vaults/{vaultId:guid}/grants");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("IssueVaultGrant")
|
||||
.WithSummary("Records a vault key wrapped to another member.")
|
||||
.WithTags("Vaults"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
IssueVaultGrantRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var access = await vaultAccess
|
||||
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.Permissions.HasFlag(PermissionFlags.Share))
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"You do not have permission to share this vault.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await grants.IssueGrantAsync(user, access.Vault!, req, ct).ConfigureAwait(false);
|
||||
|
||||
// 204. There is nothing to return that the caller does not already hold — it produced
|
||||
// the wrap — and echoing the sealed key back would put it on the wire twice for nothing.
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (VaultGrantInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Withdraws a member's key to this vault.</summary>
|
||||
/// <remarks>
|
||||
/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device
|
||||
/// revocation gives: "revoked" is what the user reads, and reading it about the wrong account is
|
||||
/// worse than being told to look again. A caller driving towards "they cannot read this any more"
|
||||
/// can treat 404 as having arrived.
|
||||
/// </remarks>
|
||||
internal sealed class RevokeVaultGrantEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
VaultGrantService grants)
|
||||
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/v1/vaults/{vaultId:guid}/grants/{userId:guid}");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("RevokeVaultGrant")
|
||||
.WithSummary("Withdraws a member's key to this vault. Blocks future reads only.")
|
||||
.WithTags("Vaults"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var access = await vaultAccess
|
||||
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.Permissions.HasFlag(PermissionFlags.Share))
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"You do not have permission to share this vault.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var revoked = await grants
|
||||
.RevokeGrantAsync(user, access.Vault!, Route<Guid>("userId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
|
||||
}
|
||||
catch (VaultGrantInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Team vaults and the key grants that make them readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything here stores bytes it cannot interpret. A wrapped vault key is sealed to a recipient's
|
||||
/// X25519 key, and a grant signature is Ed25519 over a tuple this server never verifies — the two
|
||||
/// together are what let a client detect a fabricated grant, and moving either check onto the server
|
||||
/// would make it a convenience rather than the boundary. See docs/crypto.md §6 and §7.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What the server <em>can</em> check is that a grant is not obviously useless: that the recipient is
|
||||
/// enrolled, that the fingerprint names their current key, and that the generation is the vault's
|
||||
/// current one. Each of those would otherwise surface at the far end as a tag failure the recipient
|
||||
/// reads as data corruption, days later, with nothing pointing at the grant that caused it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VaultGrantService(
|
||||
DodoDbContext database,
|
||||
TimeProvider clock,
|
||||
ILogger<VaultGrantService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Largest wrapped vault key accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>SealTo</c> envelope over a 32-byte key is 6 + 32 + 24 + 48 = 110 bytes. The cap is loose
|
||||
/// enough to survive a future envelope — the reserved hybrid seal in docs/crypto.md §8 is far
|
||||
/// larger — and tight enough that this column cannot be used as free storage on a server that
|
||||
/// stores it without being able to read it.
|
||||
/// </remarks>
|
||||
private const int MaxWrappedKeyBytes = 4096;
|
||||
|
||||
/// <summary>Creates a vault owned by a team, with the creator's own grant.</summary>
|
||||
/// <remarks>
|
||||
/// The vault and its first grant are written together, for the reason enrollment gives about a
|
||||
/// personal vault: a vault with no grant is a container nobody can ever open, including whoever
|
||||
/// created it, because only a client can wrap the key and it has already moved on.
|
||||
/// </remarks>
|
||||
internal async Task<VaultSummary> CreateTeamVaultAsync(
|
||||
UserAccount user,
|
||||
Team team,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var name = RequireVaultName(request.Name);
|
||||
|
||||
if (request.VaultId == Guid.Empty)
|
||||
{
|
||||
throw new TeamInvalidException("A vault id is required. Generate a UUIDv7 on the client.");
|
||||
}
|
||||
|
||||
RequireWrappedKey(request.WrappedVaultKey);
|
||||
RequireSignature(request.GrantSignature);
|
||||
|
||||
var key = await RequireCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var taken = await database.Vaults
|
||||
.AnyAsync(v => v.Id == request.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (taken)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||
// the same id, which must not surface as a 500 about a constraint.
|
||||
throw new TeamInvalidException(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||
|
||||
return new VaultSummary(
|
||||
VaultId: request.VaultId,
|
||||
Name: name,
|
||||
IsPersonal: false,
|
||||
TeamId: team.Id,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 0,
|
||||
WrappedVaultKey: request.WrappedVaultKey,
|
||||
RekeyRequired: false);
|
||||
}
|
||||
|
||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||
private void AddVaultWithSelfGrant(
|
||||
UserAccount user,
|
||||
Team team,
|
||||
CreateTeamVaultRequest request,
|
||||
string name,
|
||||
UserKey key,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
database.Vaults.Add(new Vault
|
||||
{
|
||||
Id = request.VaultId,
|
||||
Name = name,
|
||||
OwnerKind = VaultOwnerKind.Team,
|
||||
TeamId = team.Id,
|
||||
KeyGeneration = 1,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
});
|
||||
|
||||
database.VaultKeyGrants.Add(new VaultKeyGrant
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = request.VaultId,
|
||||
KeyGeneration = 1,
|
||||
Kind = GrantKind.Member,
|
||||
RecipientUserId = user.Id,
|
||||
RecipientKeyFingerprint = key.FingerprintSha256,
|
||||
WrappedKey = request.WrappedVaultKey,
|
||||
GranterUserId = user.Id,
|
||||
GranterKeyFingerprint = key.FingerprintSha256,
|
||||
|
||||
// No key log head, exactly as a personal vault's self-grant carries none: there is no
|
||||
// third party whose key could have been substituted here.
|
||||
KeyLogHead = null,
|
||||
Signature = request.GrantSignature,
|
||||
State = GrantState.Active,
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
}
|
||||
|
||||
private static string RequireVaultName(string? value)
|
||||
{
|
||||
var name = (value ?? string.Empty).Trim();
|
||||
|
||||
return name.Length is 0 or > 256
|
||||
? throw new TeamInvalidException("A vault name of 1 to 256 characters is required.")
|
||||
: name;
|
||||
}
|
||||
|
||||
/// <summary>Lists who can open a vault.</summary>
|
||||
internal async Task<VaultGrantsResponse> ListGrantsAsync(
|
||||
Vault vault,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var grants = await database.VaultKeyGrants
|
||||
.Where(g => g.VaultId == vault.Id && g.Kind == GrantKind.Member)
|
||||
.Include(g => g.RecipientUser)
|
||||
.OrderBy(g => g.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new VaultGrantsResponse(
|
||||
VaultId: vault.Id,
|
||||
KeyGeneration: (uint)vault.KeyGeneration,
|
||||
RekeyRequired: vault.RekeyRequired,
|
||||
Grants:
|
||||
[
|
||||
.. grants.Select(g => new VaultGrantSummary(
|
||||
g.RecipientUserId!.Value,
|
||||
g.RecipientUser?.Email,
|
||||
g.RecipientUser?.DisplayName,
|
||||
(uint)g.KeyGeneration,
|
||||
ToContract(g.State),
|
||||
g.GranterUserId,
|
||||
g.CreatedAtUtc,
|
||||
g.RevokedAtUtc)),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>Wraps a vault key to another member.</summary>
|
||||
/// <remarks>
|
||||
/// Re-issuing to a recipient who already holds a live grant replaces it in place rather than
|
||||
/// inserting a second row, because the unique index permits exactly one live grant per recipient
|
||||
/// per generation — and because the operation somebody is actually performing when they do this
|
||||
/// is "wrap it again", after a rotation or a botched first attempt.
|
||||
/// </remarks>
|
||||
internal async Task IssueGrantAsync(
|
||||
UserAccount actor,
|
||||
Vault vault,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await RequireIssuableAsync(vault, request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = await database.VaultKeyGrants
|
||||
.SingleOrDefaultAsync(
|
||||
g => g.VaultId == vault.Id
|
||||
&& g.KeyGeneration == vault.KeyGeneration
|
||||
&& g.RecipientUserId == request.RecipientUserId
|
||||
&& g.RevokedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var grant = existing ?? new VaultKeyGrant
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = vault.Id,
|
||||
KeyGeneration = vault.KeyGeneration,
|
||||
Kind = GrantKind.Member,
|
||||
RecipientUserId = request.RecipientUserId,
|
||||
CreatedAtUtc = clock.GetUtcNow(),
|
||||
};
|
||||
|
||||
grant.RecipientKeyFingerprint = request.RecipientKeyFingerprint;
|
||||
grant.WrappedKey = request.WrappedVaultKey;
|
||||
grant.GranterUserId = actor.Id;
|
||||
|
||||
// Taken from the server's own view of the caller's key rather than from the request. The
|
||||
// client signed over the same value, so an honest client is unaffected; a field that could
|
||||
// disagree with reality is one a reader would have to decide which copy to believe.
|
||||
grant.GranterKeyFingerprint = granterKey.FingerprintSha256;
|
||||
|
||||
grant.KeyLogHead = request.KeyLogHead;
|
||||
grant.Signature = request.GrantSignature;
|
||||
grant.State = GrantState.Active;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
database.VaultKeyGrants.Add(grant);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.GrantIssued(
|
||||
logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything that can be checked about a grant without holding the vault key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// None of this verifies that the wrap contains the right key — nothing on this machine can. Each
|
||||
/// check exists because failing it would otherwise surface at the recipient as a tag failure they
|
||||
/// read as data corruption, long after the request that caused it.
|
||||
/// </remarks>
|
||||
private async Task RequireIssuableAsync(
|
||||
Vault vault,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Team vaults only. A personal vault has exactly one subject who may reach it, so a grant on
|
||||
// one would seal a key to somebody the access check will go on refusing — a row that looks
|
||||
// like sharing and is not. Moving the items into a team vault is the operation that shares.
|
||||
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"Only a team vault can be shared. A personal vault is reachable by its owner alone, "
|
||||
+ "so a grant on one would seal a key to somebody who still could not fetch it.");
|
||||
}
|
||||
|
||||
RequireWrappedKey(request.WrappedVaultKey);
|
||||
RequireSignature(request.GrantSignature);
|
||||
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
|
||||
RequireDigest(request.KeyLogHead, "key log head");
|
||||
|
||||
if (request.KeyGeneration != (uint)vault.KeyGeneration)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
|
||||
+ $"{request.KeyGeneration} would open nothing.");
|
||||
}
|
||||
|
||||
var member = await database.TeamMemberships
|
||||
.AnyAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == request.RecipientUserId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!member)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"That account is not an active member of the team that owns this vault. Add them to "
|
||||
+ "the team first — a key wrapped to somebody the server will refuse to serve is a "
|
||||
+ "grant that does nothing.");
|
||||
}
|
||||
|
||||
var recipientKey = await RequireCurrentKeyAsync(request.RecipientUserId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// The fingerprint the client signed over must be the key the recipient actually holds.
|
||||
// Otherwise the grant is sealed to a superseded key, opens nothing, and surfaces at the far
|
||||
// end as an unexplained decryption failure rather than as the mistake it is.
|
||||
if (!CryptographicOperations.FixedTimeEquals(
|
||||
recipientKey.FingerprintSha256, request.RecipientKeyFingerprint))
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"The fingerprint does not name the recipient's current identity key. Re-read the "
|
||||
+ "directory and wrap the key again — theirs has been rotated since you fetched it.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a member's key grant.
|
||||
/// </summary>
|
||||
/// <returns>Whether there was a live grant to withdraw.</returns>
|
||||
/// <remarks>
|
||||
/// Blocks future reads and nothing else. Anything the recipient has already pulled is on their
|
||||
/// machine and stays there, which is why the vault is flagged for rekey and why the honest
|
||||
/// remediation for a departure is rotating the SSH credential itself. See ADR 0001.
|
||||
/// </remarks>
|
||||
internal async Task<bool> RevokeGrantAsync(
|
||||
UserAccount actor,
|
||||
Vault vault,
|
||||
Guid recipientUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (recipientUserId == actor.Id)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"You cannot withdraw your own key. It would leave you unable to read a vault you can "
|
||||
+ "still write to, and nothing here can hand it back.");
|
||||
}
|
||||
|
||||
var grants = await database.VaultKeyGrants
|
||||
.Where(g => g.VaultId == vault.Id
|
||||
&& g.RecipientUserId == recipientUserId
|
||||
&& g.RevokedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (grants.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
grant.State = GrantState.Revoked;
|
||||
grant.RevokedAtUtc = now;
|
||||
}
|
||||
|
||||
vault.RekeyRequired = true;
|
||||
vault.RekeyReason = RekeyReason.Requested;
|
||||
vault.UpdatedAtUtc = now;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<UserKey> RequireCurrentKeyAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = await database.UserKeys
|
||||
.SingleOrDefaultAsync(k => k.UserId == userId && k.IsCurrent, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return key
|
||||
?? throw new VaultGrantInvalidException(
|
||||
"That account has not published an identity key yet, so there is nothing to wrap a "
|
||||
+ "vault key to. They have to sign in and set up their vault first.");
|
||||
}
|
||||
|
||||
private static void RequireWrappedKey(byte[]? value)
|
||||
{
|
||||
if (value is null || value.Length == 0 || value.Length > MaxWrappedKeyBytes)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
$"A wrapped vault key of 1 to {MaxWrappedKeyBytes} bytes is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireSignature(byte[]? value)
|
||||
{
|
||||
if (value is null || value.Length != 64)
|
||||
{
|
||||
throw new VaultGrantInvalidException("An Ed25519 grant signature is 64 bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireDigest(byte[]? value, string field)
|
||||
{
|
||||
if (value is null || value.Length != 32)
|
||||
{
|
||||
throw new VaultGrantInvalidException($"A {field} is 32 bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
private static VaultGrantState ToContract(GrantState state) => state switch
|
||||
{
|
||||
GrantState.Active => VaultGrantState.Active,
|
||||
GrantState.AwaitingRewrap => VaultGrantState.AwaitingRewrap,
|
||||
GrantState.Revoked => VaultGrantState.Revoked,
|
||||
_ => VaultGrantState.Unspecified,
|
||||
};
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception) =>
|
||||
string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Api.Features.Identity;
|
||||
using DodoSSH.Api.Features.Sync;
|
||||
using DodoSSH.Api.Features.Teams;
|
||||
using DodoSSH.Api.Setup;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization.Policy;
|
||||
@@ -32,6 +33,10 @@ builder.Services.AddScoped<SyncService>();
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddScoped<EnrollmentService>();
|
||||
builder.Services.AddScoped<DeviceService>();
|
||||
builder.Services.AddScoped<DirectoryService>();
|
||||
builder.Services.AddScoped<KeyLogService>();
|
||||
builder.Services.AddScoped<TeamService>();
|
||||
builder.Services.AddScoped<VaultGrantService>();
|
||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DodoSSH.Api.Features.Identity;
|
||||
using DodoSSH.Api.Features.Meta;
|
||||
using DodoSSH.Api.Features.Sync;
|
||||
using DodoSSH.Api.Features.Teams;
|
||||
using DodoSSH.Contracts;
|
||||
using FastEndpoints;
|
||||
|
||||
@@ -38,15 +39,26 @@ internal static class EndpointRegistration
|
||||
typeof(EnrollEndpoint),
|
||||
typeof(RegisterDeviceEndpoint),
|
||||
typeof(RevokeDeviceEndpoint),
|
||||
typeof(LookupDirectoryEndpoint),
|
||||
typeof(ReadKeyLogEndpoint),
|
||||
typeof(SyncPullEndpoint),
|
||||
typeof(SyncPushEndpoint),
|
||||
typeof(CreateTeamEndpoint),
|
||||
typeof(ListTeamsEndpoint),
|
||||
typeof(ListTeamMembersEndpoint),
|
||||
typeof(AddTeamMemberEndpoint),
|
||||
typeof(ChangeTeamMemberRoleEndpoint),
|
||||
typeof(RemoveTeamMemberEndpoint),
|
||||
typeof(CreateTeamVaultEndpoint),
|
||||
typeof(ListVaultGrantsEndpoint),
|
||||
typeof(IssueVaultGrantEndpoint),
|
||||
typeof(RevokeVaultGrantEndpoint),
|
||||
|
||||
// Registered as each feature lands:
|
||||
// Identity — key rotation, passphrase change
|
||||
// Directory — public-key lookup
|
||||
// Vaults — grants, rekey, ACL
|
||||
// Vaults — rekey, per-item ACLs
|
||||
// Relay — tickets and the WebSocket
|
||||
// Teams, Audit, Admin
|
||||
// Audit, Admin
|
||||
});
|
||||
|
||||
/// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary>
|
||||
|
||||
@@ -58,6 +58,115 @@ public interface IAccountApi
|
||||
Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teams, their members, and the vaults they own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separated from <see cref="IVaultGrantApi"/> although the two are used together, because they are
|
||||
/// different kinds of act. Everything here changes what the <em>server</em> will serve and can be
|
||||
/// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked
|
||||
/// session has — so the two live behind different interfaces and are tested against different fakes.
|
||||
/// </remarks>
|
||||
public interface ITeamApi
|
||||
{
|
||||
/// <summary>Lists the teams the caller belongs to.</summary>
|
||||
Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates a team, with the caller as its owner.</summary>
|
||||
Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Lists a team's members.</summary>
|
||||
Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Adds a member to a team.</summary>
|
||||
Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a member, revoking every vault key grant they hold from this team.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Whether the team had that member. False means it did not, which a caller driving towards
|
||||
/// "they are not in this team" should treat as having arrived.
|
||||
/// </returns>
|
||||
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
|
||||
Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The public-key directory and the log that makes it checkable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two belong together and are used together: a directory answer is a claim, and the key log is
|
||||
/// what turns it into something a client can verify. Splitting them would make it possible to build a
|
||||
/// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that
|
||||
/// undoes end-to-end encryption entirely.
|
||||
/// </remarks>
|
||||
public interface IDirectoryApi
|
||||
{
|
||||
/// <summary>Looks a user up by exact email address. There is no search.</summary>
|
||||
Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Looks up an account the caller shares a team with.</summary>
|
||||
Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
|
||||
Task<KeyLogPage> ReadKeyLogAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vault key grants: who can open a vault, and the record of who let them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The wrapped key and the signature are produced by an unlocked session and are opaque to everything
|
||||
/// between it and the recipient, this interface included.
|
||||
/// </remarks>
|
||||
public interface IVaultGrantApi
|
||||
{
|
||||
/// <summary>Lists who holds a key to this vault.</summary>
|
||||
Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Records a vault key wrapped to another member.</summary>
|
||||
Task IssueVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a member's key to this vault.
|
||||
/// </summary>
|
||||
/// <returns>Whether there was a live grant to withdraw.</returns>
|
||||
/// <remarks>
|
||||
/// Blocks future reads and nothing else. Whatever they have already pulled is on their machine;
|
||||
/// the remediation for a departure is rotating the SSH credential. See ADR 0001.
|
||||
/// </remarks>
|
||||
Task<bool> RevokeVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
|
||||
/// </summary>
|
||||
@@ -99,13 +208,16 @@ public interface ISyncApi
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||
: IAccountApi, ISyncApi
|
||||
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
|
||||
{
|
||||
private const string MetaPath = "/api/v1/meta";
|
||||
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||
private const string MePath = "/api/v1/me";
|
||||
private const string EnrollmentPath = "/api/v1/me/enrollment";
|
||||
private const string DevicesPath = "/api/v1/me/devices";
|
||||
private const string DirectoryPath = "/api/v1/directory";
|
||||
private const string KeyLogPath = "/api/v1/keylog";
|
||||
private const string TeamsPath = "/api/v1/teams";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the server's capabilities, versions and limits.
|
||||
@@ -209,6 +321,168 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
DodoSshJsonContext.Default.SyncPushResponse,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Get,
|
||||
TeamsPath,
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListTeamSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamSummary> CreateTeamAsync(
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
TeamsPath,
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest),
|
||||
DodoSshJsonContext.Default.TeamSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Get,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest),
|
||||
DodoSshJsonContext.Default.TeamMemberSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Put,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest),
|
||||
DodoSshJsonContext.Default.TeamMemberSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RemoveTeamMemberAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
DeleteAsync(
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest),
|
||||
DodoSshJsonContext.Default.VaultSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Looks a user up by exact email address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The address is escaped into the query string, which is the one place in this client where a
|
||||
/// value a user typed reaches a URL. <see cref="Uri.EscapeDataString"/> rather than string
|
||||
/// concatenation: an unescaped <c>&</c> or <c>#</c> in an address would silently become a
|
||||
/// lookup for something else.
|
||||
/// </remarks>
|
||||
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(email);
|
||||
|
||||
return SendAsync(
|
||||
HttpMethod.Get,
|
||||
$"{DirectoryPath}?email={Uri.EscapeDataString(email)}",
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DirectoryEntry?> LookupByIdAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await SendAsync(
|
||||
HttpMethod.Get,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"),
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entries.Count == 0 ? null : entries[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<KeyLogPage> ReadKeyLogAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = limit is null
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}")
|
||||
: string.Create(
|
||||
CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}");
|
||||
|
||||
return SendAsync(
|
||||
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Get,
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
|
||||
null,
|
||||
DodoSshJsonContext.Default.VaultGrantsResponse,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task IssueVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendNoContentAsync(
|
||||
HttpMethod.Post,
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RevokeVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
DeleteAsync(
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"),
|
||||
cancellationToken);
|
||||
|
||||
private async Task<T> GetAnonymousAsync<T>(
|
||||
string path,
|
||||
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
|
||||
@@ -242,6 +516,37 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
|
||||
/// worth an exception; here it is the answer.
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Sends a request whose success carries no body.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
|
||||
/// not be recorded is a failure with a problem document behind it, so there is nothing here to
|
||||
/// translate into a return value.
|
||||
/// </remarks>
|
||||
private async Task SendNoContentAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
HttpContent? content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path) { Content = content };
|
||||
|
||||
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content
|
||||
.ReadAsStringAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
throw DodoSshApiException.FromResponse(response.StatusCode, body);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Api;
|
||||
|
||||
/// <summary>Why a directory entry was or was not accepted.</summary>
|
||||
public enum RecipientVerdict
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The key log verifies, and it introduces exactly the key the directory described.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the strongest statement a client can make without an out-of-band fingerprint check. It
|
||||
/// says the server has been consistent, not that the key is the right person's — see
|
||||
/// <see cref="VerifiedRecipient.Fingerprint"/> and ADR 0001.
|
||||
/// </remarks>
|
||||
Verified = 1,
|
||||
|
||||
/// <summary>No account with that address, or none the caller may look up.</summary>
|
||||
NotFound = 2,
|
||||
|
||||
/// <summary>The account exists but has published no identity key, so there is nothing to wrap to.</summary>
|
||||
NotEnrolled = 3,
|
||||
|
||||
/// <summary>
|
||||
/// The key log's hash chain does not verify.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Either the log has been edited or this build and the server disagree about how an entry is
|
||||
/// hashed. Both are refusals: wrapping a vault key against a log that cannot be checked is the
|
||||
/// same as not checking one.
|
||||
/// </remarks>
|
||||
ChainBroken = 4,
|
||||
|
||||
/// <summary>
|
||||
/// The log holds no entry matching the key the directory returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The exact case key transparency exists for. A server that wants to substitute a key it holds
|
||||
/// has to publish it in the append-only log to get past this, where every other client will see
|
||||
/// it.
|
||||
/// </remarks>
|
||||
NotInKeyLog = 5,
|
||||
|
||||
/// <summary>The fingerprint does not match the keys it is supposed to be over.</summary>
|
||||
FingerprintMismatch = 6,
|
||||
|
||||
/// <summary>
|
||||
/// The log introduces a newer generation for this user than the directory returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A rotation the directory has not caught up with, or a stale answer being served on purpose.
|
||||
/// Refused either way: a key wrapped to a superseded generation opens nothing, and the recipient
|
||||
/// reads that as corruption rather than as a race.
|
||||
/// </remarks>
|
||||
Superseded = 7,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A recipient whose published key has been checked against the key log.
|
||||
/// </summary>
|
||||
/// <param name="Entry">The directory entry, as returned.</param>
|
||||
/// <param name="KeyLogHead">
|
||||
/// The log head observed while verifying, to be recorded in the grant. This is what makes a forked
|
||||
/// view detectable: two clients handed different logs sign over different heads, and the mismatch
|
||||
/// surfaces the next time either touches a vault the other can see.
|
||||
/// </param>
|
||||
/// <param name="Fingerprint">
|
||||
/// The recipient's identity fingerprint, recomputed here rather than taken from the response.
|
||||
/// <para>
|
||||
/// <b>Show this to a human before sharing anything that matters.</b> Everything above proves the
|
||||
/// server has been internally consistent; only somebody comparing this value with the recipient over
|
||||
/// a channel the server does not control can prove it is the right person's key.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record VerifiedRecipient(
|
||||
DirectoryEntry Entry,
|
||||
byte[] KeyLogHead,
|
||||
byte[] Fingerprint);
|
||||
|
||||
/// <summary>The outcome of verifying a recipient.</summary>
|
||||
/// <param name="Verdict">What happened.</param>
|
||||
/// <param name="Recipient">The recipient, present only when verified.</param>
|
||||
/// <param name="Message">One line for a person. Never contains key material.</param>
|
||||
public sealed record RecipientVerification(
|
||||
RecipientVerdict Verdict,
|
||||
VerifiedRecipient? Recipient,
|
||||
string Message)
|
||||
{
|
||||
/// <summary>Whether a key came back that is safe to wrap to.</summary>
|
||||
public bool IsVerified => Verdict == RecipientVerdict.Verified && Recipient is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the whole key log, checks its hash chain, and decides whether a directory answer agrees
|
||||
/// with it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is the check that makes sharing safe to offer at all.</b> A directory lookup is a claim by
|
||||
/// the server about somebody else's public key; wrapping a vault key to an unverified claim hands the
|
||||
/// vault to whoever made it, and no amount of transport security helps, because the server is inside
|
||||
/// the threat model. See ADR 0001 and docs/crypto.md §7.2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The whole log is read from the beginning, every time, rather than from a cached cursor. It is
|
||||
/// small — one entry per identity key ever published, so a few hundred rows for a large deployment —
|
||||
/// and a client that verified only the tail would accept a chain whose earlier links it had never
|
||||
/// seen. Caching a verified prefix is a worthwhile optimisation and is deliberately not done yet:
|
||||
/// it needs somewhere to keep the prefix that the server cannot influence, and the client's
|
||||
/// preferences store does not exist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this cannot do is tell you the key belongs to the person you mean. A server that publishes a
|
||||
/// substituted key in the log passes every check here — it is now on the record, which is the whole
|
||||
/// mechanism: detectable, attributable, not prevented. The fingerprint comes back for a human to
|
||||
/// compare out of band, which is the only step that closes it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class KeyLogAudit
|
||||
{
|
||||
/// <summary>Entries requested per page.</summary>
|
||||
private const int PageSize = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Pages the whole log with the chain checked link by link.
|
||||
/// </summary>
|
||||
/// <returns>The verified log, or a null <c>Entries</c> when a link did not hold.</returns>
|
||||
public static async Task<AuditedKeyLog> ReadAsync(
|
||||
IDirectoryApi directory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(directory);
|
||||
|
||||
var entries = new List<KeyLogRecord>();
|
||||
var previous = KeyLogChain.CreateGenesisPreviousHash();
|
||||
var after = 0L;
|
||||
var head = previous;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var page = await directory.ReadKeyLogAsync(after, PageSize, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var entry in page.Entries)
|
||||
{
|
||||
if (!Links(entry, previous))
|
||||
{
|
||||
return new AuditedKeyLog(null, head);
|
||||
}
|
||||
|
||||
entries.Add(entry);
|
||||
previous = entry.Hash;
|
||||
after = entry.Sequence;
|
||||
}
|
||||
|
||||
head = page.Head;
|
||||
|
||||
if (!page.HasMore)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// A page that advanced nothing would loop for ever. It means the server is answering a
|
||||
// cursor it will not move past, which is a broken log from this side of the wire.
|
||||
if (page.Entries.Count == 0)
|
||||
{
|
||||
return new AuditedKeyLog(null, head);
|
||||
}
|
||||
}
|
||||
|
||||
// The last link has to be the head the server claims, or the log served and the log
|
||||
// summarised are two different things.
|
||||
return entries.Count > 0 && !CryptographicOperations.FixedTimeEquals(previous, head)
|
||||
? new AuditedKeyLog(null, head)
|
||||
: new AuditedKeyLog(entries, head);
|
||||
}
|
||||
|
||||
/// <summary>Decides whether a directory entry agrees with a verified log.</summary>
|
||||
public static RecipientVerification Verify(AuditedKeyLog log, DirectoryEntry? entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
|
||||
if (log.Entries is null)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.ChainBroken,
|
||||
null,
|
||||
"The server's key log does not verify. Nothing will be shared with anyone until it "
|
||||
+ "does — an unverifiable log is the same as no log.");
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.NotFound,
|
||||
null,
|
||||
"No account here has that address. They have to sign in to this server once before "
|
||||
+ "anything can be shared with them.");
|
||||
}
|
||||
|
||||
var fingerprint = DshCrypto.ComputeFingerprint(
|
||||
entry.EncryptionPublicKey, entry.SigningPublicKey);
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(fingerprint, entry.Fingerprint))
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.FingerprintMismatch,
|
||||
null,
|
||||
"The fingerprint the directory returned is not the fingerprint of the keys it "
|
||||
+ "returned with it.");
|
||||
}
|
||||
|
||||
return Compare(log, entry, fingerprint);
|
||||
}
|
||||
|
||||
/// <summary>Compares one directory entry with the log entries for that account.</summary>
|
||||
private static RecipientVerification Compare(
|
||||
AuditedKeyLog log,
|
||||
DirectoryEntry entry,
|
||||
byte[] fingerprint)
|
||||
{
|
||||
var forUser = log.Entries!.Where(e => e.UserId == entry.UserId).ToList();
|
||||
|
||||
if (forUser.Count == 0)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.NotEnrolled,
|
||||
null,
|
||||
"That account has published no identity key, so there is nothing to wrap a vault key "
|
||||
+ "to.");
|
||||
}
|
||||
|
||||
var latest = forUser.Max(e => e.Generation);
|
||||
|
||||
if (latest > entry.KeyGeneration)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.Superseded,
|
||||
null,
|
||||
$"The key log has generation {latest} for that account and the directory offered "
|
||||
+ $"{entry.KeyGeneration}. Wrapping to a superseded key would open nothing.");
|
||||
}
|
||||
|
||||
var matching = forUser.Find(e =>
|
||||
e.Generation == entry.KeyGeneration
|
||||
&& e.EncryptionPublicKey.AsSpan().SequenceEqual(entry.EncryptionPublicKey)
|
||||
&& e.SigningPublicKey.AsSpan().SequenceEqual(entry.SigningPublicKey));
|
||||
|
||||
if (matching is null)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.NotInKeyLog,
|
||||
null,
|
||||
"The key the directory returned does not appear in the append-only key log. This is "
|
||||
+ "exactly the substitution the log exists to catch; do not share anything with this "
|
||||
+ "account until it is explained.");
|
||||
}
|
||||
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.Verified,
|
||||
new VerifiedRecipient(entry, log.Head, fingerprint),
|
||||
"Verified against the key log. Compare the fingerprint with them out of band before "
|
||||
+ "sharing anything that matters.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Recomputed rather than compared: the point is that this client derives the hash from the
|
||||
/// entry's own contents, so a server that edited a field cannot hand over a hash that covers the
|
||||
/// original.
|
||||
/// </remarks>
|
||||
private static bool Links(KeyLogRecord entry, byte[] previous)
|
||||
{
|
||||
if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash, previous))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] computed;
|
||||
|
||||
try
|
||||
{
|
||||
computed = KeyLogChain.ComputeEntryHash(
|
||||
entry.PreviousHash,
|
||||
entry.UserId,
|
||||
entry.Generation,
|
||||
entry.EncryptionPublicKey,
|
||||
entry.SigningPublicKey,
|
||||
entry.StatementSignature,
|
||||
entry.CreatedAt);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// A key or signature of the wrong length. Malformed rather than merely mismatched, and a
|
||||
// refusal either way.
|
||||
return false;
|
||||
}
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(computed, entry.Hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A key log that has been read, with its chain checked.</summary>
|
||||
/// <param name="Entries">
|
||||
/// Every entry in order, or <see langword="null"/> when a link did not hold. Null is the only
|
||||
/// signal a caller needs: a partially verified log is not a weaker answer, it is no answer.
|
||||
/// </param>
|
||||
/// <param name="Head">The head the server reported, for recording in a grant.</param>
|
||||
public sealed record AuditedKeyLog(IReadOnlyList<KeyLogRecord>? Entries, byte[] Head);
|
||||
@@ -22,8 +22,18 @@ namespace DodoSSH.Client.App.ViewModels;
|
||||
/// of pinning one is to compare it with what they published.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class KnownHostRowViewModel(VaultItem<KnownHostSecret> pin, bool isDialledByAHost)
|
||||
internal sealed class KnownHostRowViewModel(
|
||||
VaultItem<KnownHostSecret> pin,
|
||||
bool isDialledByAHost,
|
||||
Guid vaultId,
|
||||
string vaultName)
|
||||
{
|
||||
/// <summary>Which vault this pin lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
internal Guid EntityId => pin.EntityId;
|
||||
|
||||
internal KnownHostSecret Pin => pin.Secret;
|
||||
|
||||
@@ -198,6 +198,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly ConnectionRecorder connectionLog;
|
||||
|
||||
private readonly TeamsViewModel teams;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -274,6 +276,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
|
||||
this.workspace.ConnectionLog = connectionLog;
|
||||
|
||||
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
|
||||
// session after unlock, and both go away again on lock. Capturing either would give this screen a
|
||||
// reference that outlives what it points at — which for a session means holding vault keys past the
|
||||
// moment locking is supposed to have zeroed them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
@@ -365,6 +373,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private LogsViewModel? logsScreen;
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen, which the window binds to whether or not a vault is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
|
||||
/// server rather than a vault, and both of its dependencies are fetched through a function at the
|
||||
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
|
||||
/// to rebuild it, and the list it is showing survives both.
|
||||
/// </remarks>
|
||||
internal TeamsViewModel Teams => teams;
|
||||
|
||||
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
|
||||
@@ -2009,6 +2028,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
_ = logs.RefreshCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// Teams are read from the server rather than from the vault, so there is nothing to show until
|
||||
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
|
||||
// screen most people never open. Fire-and-forget because a property change cannot await, and
|
||||
// because the view model turns every failure into its own status line rather than throwing.
|
||||
if (value is ShellScreen.Team)
|
||||
{
|
||||
_ = teams.LoadAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>One team, as a row in the list.</summary>
|
||||
internal sealed record TeamRowViewModel(TeamSummary Team)
|
||||
{
|
||||
internal Guid TeamId => Team.TeamId;
|
||||
|
||||
internal string Name => Team.Name;
|
||||
|
||||
internal string Slug => Team.Slug;
|
||||
|
||||
/// <summary>The caller's own role, as the chip the list shows.</summary>
|
||||
internal string Role => Team.Role.ToString().ToUpperInvariant();
|
||||
|
||||
internal string Detail => string.Create(
|
||||
CultureInfo.CurrentCulture,
|
||||
$"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");
|
||||
|
||||
/// <summary>Whether this account may add members and create vaults here.</summary>
|
||||
internal bool CanAdminister =>
|
||||
Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One member, as a row in the members table.</summary>
|
||||
internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
|
||||
{
|
||||
internal Guid UserId => Member.UserId;
|
||||
|
||||
/// <summary>What to call them. The address, or the id when the account has neither.</summary>
|
||||
/// <remarks>
|
||||
/// Falling through to the id rather than to "Unknown": an account with no display name and no email is
|
||||
/// rare and is exactly the row somebody needs to be able to identify in order to remove it.
|
||||
/// </remarks>
|
||||
internal string Name =>
|
||||
Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();
|
||||
|
||||
internal string Email => Member.Email ?? "—";
|
||||
|
||||
internal string Role => Member.Role.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// What the account can be given, in one phrase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a two-factor column, not a last-active column. The server records neither: there is no
|
||||
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
|
||||
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
|
||||
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
|
||||
/// </remarks>
|
||||
internal string KeyState => Member.IsEnrolled
|
||||
? "key published"
|
||||
: "no key yet — cannot be given a vault";
|
||||
|
||||
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
|
||||
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
|
||||
{
|
||||
/// <summary>What the row says about itself.</summary>
|
||||
/// <remarks>
|
||||
/// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
|
||||
/// somebody has been added to a team and nobody has wrapped the vault key to them yet.
|
||||
/// </remarks>
|
||||
internal string State => (IsReadable, RekeyRequired) switch
|
||||
{
|
||||
(false, _) => "waiting for a key — ask a member who has one to share it",
|
||||
(true, true) => "readable · a rekey is owed after a membership change",
|
||||
_ => "readable",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two separate acts, and the screen is built around saying so.</b> Adding somebody to a team is a
|
||||
/// server-side authorization change and takes effect immediately. Giving them a vault key is a
|
||||
/// cryptographic act only a machine with that key can perform, and until somebody does it their vault
|
||||
/// list shows an entry they cannot open. Every product that hides this ends up implying the server can
|
||||
/// hand out access on its own — which, here, it cannot. See <c>TeamService</c> and ADR 0001.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
|
||||
/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
|
||||
/// would be a second copy of something the server is authoritative for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TeamsViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
Func<VaultSession?> session) : ObservableObject
|
||||
{
|
||||
/// <summary>Teams this account belongs to.</summary>
|
||||
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
||||
|
||||
/// <summary>Members of the selected team.</summary>
|
||||
internal ObservableCollection<TeamMemberRowViewModel> Members { get; } = [];
|
||||
|
||||
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
|
||||
internal ObservableCollection<TeamVaultRowViewModel> Vaults { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamRowViewModel? selectedTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamMemberRowViewModel? selectedMember;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamVaultRowViewModel? selectedVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
// ---- Creating a team ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCreatingTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamSlug = string.Empty;
|
||||
|
||||
// ---- Adding a member ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string inviteEmail = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a server to talk to at all.</summary>
|
||||
internal bool IsOnline => connection() is not null;
|
||||
|
||||
/// <summary>Whether the selected team can be administered by this account.</summary>
|
||||
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
|
||||
|
||||
/// <summary>Whether there is anything to show below the team list.</summary>
|
||||
internal bool HasSelection => SelectedTeam is not null;
|
||||
|
||||
internal bool HasTeams => Teams.Count > 0;
|
||||
|
||||
/// <summary>Reads the teams this account belongs to, and the selected one's detail.</summary>
|
||||
internal Task LoadAsync(CancellationToken cancellationToken) =>
|
||||
RunAsync(() => ReloadAsync(cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// The reload itself, without the busy gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="LoadAsync"/> because every command ends by reloading, and a command that
|
||||
/// called the gated version would find the gate held by itself and skip the reload silently — leaving
|
||||
/// a team that was created moments ago missing from the list it was just added to.
|
||||
/// </remarks>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Teams.Clear();
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
RaiseState();
|
||||
|
||||
Status = "Offline. Teams are read from the server, so this screen needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedId = SelectedTeam?.TeamId;
|
||||
|
||||
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Teams.Clear();
|
||||
|
||||
foreach (var team in teams)
|
||||
{
|
||||
Teams.Add(new TeamRowViewModel(team));
|
||||
}
|
||||
|
||||
SelectedTeam =
|
||||
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
||||
|
||||
RaiseState();
|
||||
|
||||
await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = Teams.Count == 0
|
||||
? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void NewTeam()
|
||||
{
|
||||
NewTeamName = string.Empty;
|
||||
NewTeamSlug = string.Empty;
|
||||
IsCreatingTeam = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelNewTeam()
|
||||
{
|
||||
IsCreatingTeam = false;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates a team, with this account as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// The id is generated here, which is what makes a create whose response was lost safe to send again —
|
||||
/// the server treats an identical repeat as the same team rather than a second one.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CreateTeamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Status = "Offline. Creating a team needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewTeamName.Trim();
|
||||
var slug = NewTeamSlug.Trim().ToLowerInvariant();
|
||||
|
||||
if (name.Length == 0 || slug.Length == 0)
|
||||
{
|
||||
Status = "A team needs a name and a slug.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var created = await server.Teams
|
||||
.CreateTeamAsync(
|
||||
new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
IsCreatingTeam = false;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
|
||||
|
||||
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
||||
+ "whoever needs it.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a member, by looking their address up in the directory first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two calls rather than one, and the order is the point: the directory is what turns an address into
|
||||
/// an account and a public key, and the key that gets verified before any sharing is the one that
|
||||
/// lookup returned. Letting the server resolve an address to an account inside the add would put an
|
||||
/// unwitnessed step between the two.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AddMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var email = InviteEmail.Trim();
|
||||
|
||||
if (email.Length == 0)
|
||||
{
|
||||
Status = "Type the email address of somebody who has signed in to this server.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (found.Count == 0)
|
||||
{
|
||||
Status = $"No account here has the address '{email}'. They have to sign in to this "
|
||||
+ "server once before they can be added — that is what publishes the key a vault "
|
||||
+ "would be shared with.";
|
||||
return;
|
||||
}
|
||||
|
||||
var member = await server.Teams
|
||||
.AddTeamMemberAsync(
|
||||
team.TeamId,
|
||||
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Said out loud, every time. The single most common misunderstanding this design invites is
|
||||
// that adding somebody gave them the vault.
|
||||
Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
|
||||
+ "cannot read anything yet — select a vault below and share its key.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedTeam is not { } team
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await server.Teams
|
||||
.RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
|
||||
// and a message implying otherwise is the one thing this screen must not say.
|
||||
Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything "
|
||||
+ "they had already downloaded is still on their machine — rotate the credentials that "
|
||||
+ "matter.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by the selected team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
|
||||
+ "hosts and credentials can be filed into it from the Vault screen.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the selected vault's key to the selected member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything that makes this safe happens inside <see cref="VaultSession.ShareVaultAsync"/>: the key
|
||||
/// log is read and its chain verified, and the directory's answer has to appear in it unchanged before
|
||||
/// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because
|
||||
/// the reasons are not interchangeable — one of them means somebody is substituting keys.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ShareVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.IsSelf)
|
||||
{
|
||||
Status = "You already hold this vault's key.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var outcome = await open
|
||||
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = outcome.Shared
|
||||
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
|
||||
: $"Did not share '{vault.Name}': {outcome.Message}";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Withdraws the selected member's key to the selected vault.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RevokeVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var revoked = await server.Grants
|
||||
.RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = revoked
|
||||
? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they "
|
||||
+ "already have is unaffected."
|
||||
: $"{member.Name} held no key to '{vault.Name}'.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnSelectedTeamChanged(TeamRowViewModel? value)
|
||||
{
|
||||
RaiseState();
|
||||
|
||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||
// from a list box, which has no cancellation token and no way to await. Failures land in Status
|
||||
// through RunAsync exactly as a command's would.
|
||||
_ = LoadSelectedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Reads the selected team's members and vaults.</summary>
|
||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var open = session();
|
||||
var selfId = open?.Profile.UserId;
|
||||
|
||||
var members = await server.Teams
|
||||
.ListTeamMembersAsync(team.TeamId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
|
||||
}
|
||||
|
||||
if (open is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Read from the session rather than from a team-vaults endpoint, because the interesting fact
|
||||
// about a team vault here is whether *this* machine can open it — which is a property of the
|
||||
// keyring and not something the server can answer.
|
||||
var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
|
||||
|
||||
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
|
||||
{
|
||||
Vaults.Add(new TeamVaultRowViewModel(
|
||||
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
|
||||
}
|
||||
|
||||
SelectedVault = Vaults.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void RaiseState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTeams));
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanAdministerSelected));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One place that raises the busy flag and turns a failure into a sentence. An API exception's message
|
||||
/// is the server's problem detail, which is written for a person to read — see <c>Problems</c> — so it
|
||||
/// is shown rather than replaced with something vaguer.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,12 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
||||
|
||||
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
|
||||
internal string Permissions => entry.Permissions;
|
||||
|
||||
/// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
|
||||
internal bool IsExecutable => entry.IsExecutable;
|
||||
|
||||
/// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
|
||||
internal bool IsWorldWritable => entry.IsWorldWritable;
|
||||
}
|
||||
|
||||
/// <summary>One local file or directory, as a row.</summary>
|
||||
|
||||
@@ -124,10 +124,40 @@ internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
||||
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
|
||||
/// an item a newer client wrote that must not be re-encoded here.
|
||||
/// </remarks>
|
||||
internal sealed partial class HostRowViewModel(VaultItem<HostSecret> host) : ObservableObject, ISidebarRow
|
||||
internal sealed partial class HostRowViewModel(
|
||||
VaultItem<HostSecret> host,
|
||||
Guid vaultId,
|
||||
string vaultName) : ObservableObject, ISidebarRow
|
||||
{
|
||||
internal Guid EntityId => host.EntityId;
|
||||
|
||||
/// <summary>
|
||||
/// Which vault this host lives in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carried on the row rather than read from the session, because a session now holds several and an
|
||||
/// edit has to return to the vault the item came from. Writing it to the active vault instead would
|
||||
/// create a second copy in the personal vault and leave the team's original untouched — a silent fork
|
||||
/// that only shows up when somebody else wonders why their change never arrived.
|
||||
/// </remarks>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name, for the heading the sidebar groups under.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
/// <summary>
|
||||
/// The vault name to print on this row, or empty when there is only one vault to be in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Decided by the list rather than by the row, because "is there more than one vault" is not
|
||||
/// something a row can see — and the alternative, a binding that reaches out to the parent view
|
||||
/// model from inside an item template, is the kind of thing that silently resolves to nothing.
|
||||
/// </remarks>
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this row has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
internal HostSecret Host => host.Secret;
|
||||
|
||||
internal string Label => host.Secret.Label;
|
||||
@@ -270,10 +300,16 @@ internal sealed record AuthenticationChoice(
|
||||
/// property.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key)
|
||||
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vaultId, string vaultName)
|
||||
{
|
||||
internal Guid EntityId => key.EntityId;
|
||||
|
||||
/// <summary>Which vault this key lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
internal SshKeySecret Key => key.Secret;
|
||||
|
||||
internal string Label => key.Secret.Label;
|
||||
@@ -335,10 +371,19 @@ internal sealed class ObjectStoreRowViewModel(VaultItem<ObjectStoreSecret> store
|
||||
internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges);
|
||||
}
|
||||
|
||||
internal sealed class CredentialRowViewModel(VaultItem<CredentialSecret> credential)
|
||||
internal sealed class CredentialRowViewModel(
|
||||
VaultItem<CredentialSecret> credential,
|
||||
Guid vaultId,
|
||||
string vaultName)
|
||||
{
|
||||
internal Guid EntityId => credential.EntityId;
|
||||
|
||||
/// <summary>Which vault this credential lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
internal CredentialSecret Credential => credential.Secret;
|
||||
|
||||
internal string Label => credential.Secret.Label;
|
||||
@@ -504,6 +549,23 @@ internal enum VaultItemKind
|
||||
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
|
||||
/// by comparing it against the literal "not synced" would break the day that wording improves.
|
||||
/// </param>
|
||||
/// <summary>One vault, as an option in the "file this into" picker.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
||||
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
|
||||
internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal)
|
||||
{
|
||||
/// <summary>
|
||||
/// What the picker shows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A team vault is marked as one. The whole risk this picker introduces is putting a credential
|
||||
/// somewhere more people can read it, so the option that does that must not look like the option
|
||||
/// that does not.
|
||||
/// </remarks>
|
||||
internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
|
||||
}
|
||||
|
||||
internal sealed record VaultItemRowViewModel(
|
||||
VaultItemKind Kind,
|
||||
Guid EntityId,
|
||||
@@ -743,10 +805,16 @@ internal sealed partial class VaultViewModel(
|
||||
/// <remarks>
|
||||
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
|
||||
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
|
||||
/// presented as structure. One heading, because one vault is reachable: the server denies access to
|
||||
/// every vault that is not this user's own. See <c>docs/design-import-gaps.md</c>.
|
||||
/// presented as structure.
|
||||
/// <para>
|
||||
/// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold
|
||||
/// several, and then the heading stops naming one of them and each row names its own — a heading that
|
||||
/// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this
|
||||
/// interface is otherwise careful about.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string HostsHeading => VaultName.ToUpperInvariant();
|
||||
internal string HostsHeading =>
|
||||
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
|
||||
|
||||
/// <summary>Whether the host list under the heading is folded away.</summary>
|
||||
[ObservableProperty]
|
||||
@@ -770,6 +838,36 @@ internal sealed partial class VaultViewModel(
|
||||
internal string VaultName =>
|
||||
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";
|
||||
|
||||
/// <summary>
|
||||
/// The vaults a new item may be filed into: readable, and writable by this account.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one
|
||||
/// it can read but not write is a team vault this member is a viewer of — offering either would end
|
||||
/// in a Save that fails, one of them locally and one at the server.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultChoiceViewModel> TargetVaults { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Where the next new item goes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Falls back to the session's active vault, which is the personal one wherever there is one. Filing
|
||||
/// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is
|
||||
/// visible to people who should not have it, and moving it afterwards means deleting and retyping.
|
||||
/// </remarks>
|
||||
internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;
|
||||
|
||||
/// <summary>Whether there is more than one vault to choose between.</summary>
|
||||
/// <remarks>
|
||||
/// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is
|
||||
/// a question with no answer, and for most people this stays at one for ever.
|
||||
/// </remarks>
|
||||
internal bool HasVaultChoice => TargetVaults.Count > 1;
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedTargetVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
@@ -1036,6 +1134,24 @@ internal sealed partial class VaultViewModel(
|
||||
/// <summary>The item being edited, or null when creating.</summary>
|
||||
private Guid? editingEntityId;
|
||||
|
||||
/// <summary>
|
||||
/// Which vault the editor will write to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Captured when the editor opens rather than read at save time, and there are two different reasons
|
||||
/// for that depending on which way the editor was opened. Editing an existing item, it is the vault
|
||||
/// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the
|
||||
/// target picker said <em>at that moment</em>, so changing the picker afterwards cannot silently move
|
||||
/// a half-typed host into a team's vault.
|
||||
/// </remarks>
|
||||
private Guid editingHostVaultId;
|
||||
|
||||
/// <summary>Which vault the key editor will write to. See <see cref="editingHostVaultId"/>.</summary>
|
||||
private Guid editingKeyVaultId;
|
||||
|
||||
/// <summary>Which vault the credential editor will write to. See <see cref="editingHostVaultId"/>.</summary>
|
||||
private Guid editingCredentialVaultId;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the editor is showing a host that could have a pinned key to forget.
|
||||
/// </summary>
|
||||
@@ -1356,6 +1472,10 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// First, because the four lists below are read across the same set and a vault admitted by the
|
||||
// last refresh should appear in the picker on the same pass its items do.
|
||||
RebuildTargetVaults();
|
||||
|
||||
// Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what
|
||||
// gets counted under them — so the host reload is the pass that can put both together.
|
||||
var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);
|
||||
@@ -1379,20 +1499,74 @@ internal sealed partial class VaultViewModel(
|
||||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
|
||||
/// <remarks>
|
||||
/// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
|
||||
/// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
|
||||
/// vault rather than to nothing, so the next Save still has somewhere to go.
|
||||
/// </remarks>
|
||||
private void RebuildTargetVaults()
|
||||
{
|
||||
var selectedId = TargetVaultId;
|
||||
|
||||
TargetVaults.Clear();
|
||||
|
||||
foreach (var vault in session.ReadableVaults
|
||||
.Where(vault => vault.CanWrite)
|
||||
.OrderByDescending(vault => vault.IsPersonal)
|
||||
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
|
||||
{
|
||||
TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
|
||||
}
|
||||
|
||||
SelectedTargetVault =
|
||||
TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId)
|
||||
?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId)
|
||||
?? TargetVaults.FirstOrDefault();
|
||||
|
||||
OnPropertyChanged(nameof(HasVaultChoice));
|
||||
}
|
||||
|
||||
/// <returns>How many hosts would not decrypt.</returns>
|
||||
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.Hosts
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var selectedId = SelectedHost?.EntityId;
|
||||
var unreadable = 0;
|
||||
var rows = new List<HostRowViewModel>();
|
||||
|
||||
// Every vault this session holds a key for, not only the one new items are filed into. A team
|
||||
// vault whose hosts never reached this list would make sharing look as though it had not worked.
|
||||
var readable = session.ReadableVaults.ToList();
|
||||
var several = readable.Count > 1;
|
||||
|
||||
foreach (var vault in readable)
|
||||
{
|
||||
var listing = await session.Hosts
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new HostRowViewModel(item, vault.VaultId, vault.Name)
|
||||
{
|
||||
// Only when there is something to tell apart. A badge on every row of a
|
||||
// single-vault list is noise that says the same thing on all of them.
|
||||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||
}));
|
||||
}
|
||||
|
||||
Hosts.Clear();
|
||||
|
||||
foreach (var host in listing.Items.OrderBy(host => host.Secret.Label, StringComparer.CurrentCulture))
|
||||
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
|
||||
// hold a host with the same label and both are shown: which vault it is in is what tells them
|
||||
// apart, which is why the row carries the name rather than the list deduplicating.
|
||||
foreach (var host in rows
|
||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||||
{
|
||||
Hosts.Add(new HostRowViewModel(host));
|
||||
Hosts.Add(host);
|
||||
}
|
||||
|
||||
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
|
||||
@@ -1404,7 +1578,7 @@ internal sealed partial class VaultViewModel(
|
||||
RebuildGroups();
|
||||
RebuildVisibleHosts();
|
||||
|
||||
return listing.Unreadable;
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <returns>How many buckets would not decrypt.</returns>
|
||||
@@ -1533,9 +1707,21 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <returns>How many groups would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The listing is kept rather than projected straight into <see cref="Groups"/>, because a group row
|
||||
/// carries how many hosts name it and the hosts have not been read yet when this runs. See
|
||||
/// <see cref="RebuildGroups"/>, which is where the two meet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The active vault only, unlike every other list on this screen.</b> Hosts, keys, credentials and
|
||||
/// pins are read across every vault this session holds a key for; groups are not, so a host in a team's
|
||||
/// vault that a teammate filed appears under UNGROUPED. That is the same thing the sidebar already shows
|
||||
/// for a group that has been deleted, and it is deliberate here rather than an oversight: reading them
|
||||
/// across vaults means a group row has to carry the vault it lives in — rename and delete both need it —
|
||||
/// and two vaults may hold groups with the same name, which the one-heading-per-group layout cannot tell
|
||||
/// apart. Both are worth doing and neither is a merge's business. Recorded in
|
||||
/// <c>docs/design-import-gaps.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1735,22 +1921,35 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.SshKeys
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var selectedId = SelectedKey?.EntityId;
|
||||
var unreadable = 0;
|
||||
var rows = new List<SshKeyRowViewModel>();
|
||||
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
{
|
||||
var listing = await session.SshKeys
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
|
||||
}
|
||||
|
||||
Keys.Clear();
|
||||
|
||||
foreach (var key in listing.Items.OrderBy(key => key.Secret.Label, StringComparer.CurrentCulture))
|
||||
foreach (var key in rows
|
||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||||
{
|
||||
Keys.Add(new SshKeyRowViewModel(key));
|
||||
Keys.Add(key);
|
||||
}
|
||||
|
||||
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
return listing.Unreadable;
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <returns>How many credentials would not decrypt.</returns>
|
||||
@@ -1762,23 +1961,35 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.Credentials
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var selectedId = SelectedCredential?.EntityId;
|
||||
var unreadable = 0;
|
||||
var rows = new List<CredentialRowViewModel>();
|
||||
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
{
|
||||
var listing = await session.Credentials
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
|
||||
}
|
||||
|
||||
Credentials.Clear();
|
||||
|
||||
foreach (var credential in listing.Items
|
||||
.OrderBy(credential => credential.Secret.Label, StringComparer.CurrentCulture))
|
||||
foreach (var credential in rows
|
||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||||
{
|
||||
Credentials.Add(new CredentialRowViewModel(credential));
|
||||
Credentials.Add(credential);
|
||||
}
|
||||
|
||||
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
return listing.Unreadable;
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <returns>How many pins would not decrypt.</returns>
|
||||
@@ -1789,11 +2000,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.KnownHosts
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var selectedId = SelectedKnownHost?.EntityId;
|
||||
var unreadable = 0;
|
||||
var rows = new List<KnownHostRowViewModel>();
|
||||
|
||||
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
|
||||
// hundred scans of the host list on every background sync.
|
||||
@@ -1801,20 +2010,39 @@ internal sealed partial class VaultViewModel(
|
||||
.Select(host => Endpoint(host.Host.Hostname, host.Host.Port))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
|
||||
// the active vault alone. The difference is deliberate and is stated in the README: a pin in a
|
||||
// team vault is something a teammate can write, and letting it answer for a host in somebody's
|
||||
// personal vault would let one member suppress another's first-contact prompt. Showing them is
|
||||
// safe and is the only way somebody can see what their team has trusted.
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
{
|
||||
var listing = await session.KnownHosts
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel(
|
||||
item,
|
||||
dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)),
|
||||
vault.VaultId,
|
||||
vault.Name)));
|
||||
}
|
||||
|
||||
KnownHostPins.Clear();
|
||||
|
||||
foreach (var pin in listing.Items
|
||||
.OrderBy(pin => pin.Secret.Host, StringComparer.CurrentCulture)
|
||||
.ThenBy(pin => pin.Secret.Port)
|
||||
.ThenBy(pin => pin.Secret.Algorithm, StringComparer.Ordinal))
|
||||
foreach (var pin in rows
|
||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||||
{
|
||||
KnownHostPins.Add(new KnownHostRowViewModel(
|
||||
pin, dialled.Contains(Endpoint(pin.Secret.Host, pin.Secret.Port))));
|
||||
KnownHostPins.Add(pin);
|
||||
}
|
||||
|
||||
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
return listing.Unreadable;
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1938,16 +2166,17 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// A pass that had to start over says so even when it pulled nothing, which is the one place
|
||||
// this loop breaks its own rule about staying quiet. A machine that silently re-read the whole
|
||||
// vault has had something happen to it, and the alternative is that nobody ever finds out.
|
||||
// The item counts rather than the raw ones: a pass that carried nothing but log entries stays
|
||||
// quiet. Every user action queues one a moment after the action's own status message, and this
|
||||
// machine reads its own entries back on the next pull — so reporting on the raw numbers would
|
||||
// overwrite that message after every single save.
|
||||
if (report is not null
|
||||
&& (report.PulledItems > 0 || report.PushedItems > 0 || report.NeedsAttention
|
||||
|| report.ResyncedFromStart))
|
||||
if (report is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it
|
||||
// gets the treatment the catch below gives a total failure, the fact kept and the message
|
||||
// swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was
|
||||
// reading, once a minute, with the name of a vault it could not reach. Pressing Sync still
|
||||
// names the vault and the reason, because somebody who pressed it is waiting for an answer.
|
||||
if (IsWorthReporting(report))
|
||||
{
|
||||
Status = Describe(report);
|
||||
}
|
||||
@@ -1975,7 +2204,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
|
||||
/// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
|
||||
/// </remarks>
|
||||
private async Task<SyncReport?> SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken)
|
||||
private async Task<IReadOnlyList<VaultSyncReport>?> SyncOnceAsync(
|
||||
ISyncApi api,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
@@ -1984,9 +2215,16 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
try
|
||||
{
|
||||
var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true);
|
||||
// Every vault this session can read, not only the one new items are filed into. A team's
|
||||
// vault that never synced would show its hosts exactly once — at the unlock that first
|
||||
// pulled it — and then quietly stop, which reads as the feature not working.
|
||||
var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
LastSyncFailed = false;
|
||||
// Not unconditionally false, which it was while a pass was one vault and a failure was an
|
||||
// exception. A failure is now a report — one unreachable team vault must not stop the others
|
||||
// syncing — so clearing the flag here regardless would light the titlebar green over a vault
|
||||
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
||||
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -2076,6 +2314,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
editingEntityId = null;
|
||||
editingHostVaultId = TargetVaultId;
|
||||
EditorLabel = string.Empty;
|
||||
EditorHostname = string.Empty;
|
||||
EditorPort = HostSecret.DefaultPort;
|
||||
@@ -2109,6 +2348,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
editingEntityId = row.EntityId;
|
||||
editingHostVaultId = row.VaultId;
|
||||
EditorLabel = row.Host.Label;
|
||||
EditorHostname = row.Host.Hostname;
|
||||
EditorPort = row.Host.Port;
|
||||
@@ -2354,13 +2594,13 @@ internal sealed partial class VaultViewModel(
|
||||
if (editingEntityId is { } entityId)
|
||||
{
|
||||
await session.Hosts
|
||||
.UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken)
|
||||
.UpdateAsync(editingHostVaultId, entityId, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
editingEntityId = await session.Hosts
|
||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||||
.CreateAsync(editingHostVaultId, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -2483,7 +2723,7 @@ internal sealed partial class VaultViewModel(
|
||||
async () =>
|
||||
{
|
||||
await session.Hosts
|
||||
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
|
||||
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
@@ -2506,6 +2746,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
Section = VaultSection.Keys;
|
||||
editingKeyId = null;
|
||||
editingKeyVaultId = TargetVaultId;
|
||||
ClearKeyEditor();
|
||||
IsEditingKey = true;
|
||||
Status = "Adding an SSH key.";
|
||||
@@ -2532,6 +2773,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
Section = VaultSection.Keys;
|
||||
editingKeyId = row.EntityId;
|
||||
editingKeyVaultId = row.VaultId;
|
||||
KeyEditorLabel = row.Key.Label;
|
||||
KeyEditorPrivateKey = row.Key.PrivateKeyPem;
|
||||
KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
|
||||
@@ -2615,6 +2857,12 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
IsGeneratingKey = false;
|
||||
editingKeyId = null;
|
||||
|
||||
// Filed where a pasted key would be, and set here rather than left over from whatever was
|
||||
// edited last: this path opens the same editor without going through NewKey, so without
|
||||
// this a key generated after editing a team's key would be saved into that team's vault.
|
||||
editingKeyVaultId = TargetVaultId;
|
||||
|
||||
ClearKeyEditor();
|
||||
|
||||
KeyEditorLabel = comment;
|
||||
@@ -2694,13 +2942,13 @@ internal sealed partial class VaultViewModel(
|
||||
if (editingKeyId is { } entityId)
|
||||
{
|
||||
await session.SshKeys
|
||||
.UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken)
|
||||
.UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
editingKeyId = await session.SshKeys
|
||||
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
|
||||
.CreateAsync(editingKeyVaultId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -2758,7 +3006,7 @@ internal sealed partial class VaultViewModel(
|
||||
async () =>
|
||||
{
|
||||
await session.SshKeys
|
||||
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
|
||||
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
@@ -2779,6 +3027,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
Section = VaultSection.Credentials;
|
||||
editingCredentialId = null;
|
||||
editingCredentialVaultId = TargetVaultId;
|
||||
ClearCredentialEditor();
|
||||
IsEditingCredential = true;
|
||||
Status = "Adding a credential.";
|
||||
@@ -2805,6 +3054,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
Section = VaultSection.Credentials;
|
||||
editingCredentialId = row.EntityId;
|
||||
editingCredentialVaultId = row.VaultId;
|
||||
CredentialEditorLabel = row.Credential.Label;
|
||||
CredentialEditorUsername = row.Credential.Username ?? string.Empty;
|
||||
CredentialEditorPassword = row.Credential.Password;
|
||||
@@ -3028,13 +3278,13 @@ internal sealed partial class VaultViewModel(
|
||||
if (editingCredentialId is { } entityId)
|
||||
{
|
||||
await session.Credentials
|
||||
.UpdateAsync(session.ActiveVaultId, entityId, credential, cancellationToken)
|
||||
.UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
editingCredentialId = await session.Credentials
|
||||
.CreateAsync(session.ActiveVaultId, credential, cancellationToken)
|
||||
.CreateAsync(editingCredentialVaultId, credential, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -3086,7 +3336,7 @@ internal sealed partial class VaultViewModel(
|
||||
async () =>
|
||||
{
|
||||
await session.Credentials
|
||||
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
|
||||
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
@@ -3994,6 +4244,77 @@ internal sealed partial class VaultViewModel(
|
||||
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
|
||||
/// of recording those is that somebody sees them.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// Movement and attention only — deliberately not failure. A background pass that announced every
|
||||
/// unreachable vault would be a socket error on screen once a minute, which is the thing
|
||||
/// <see cref="AutoSyncAsync"/>'s catch block exists to avoid; the caller records
|
||||
/// <see cref="LastSyncFailed"/> instead, and the titlebar stops claiming to be up to date. Pressing
|
||||
/// Sync reports the failure in full, because somebody who pressed it is waiting for an answer.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// The item counts rather than the raw ones. Every user action queues a log entry a moment after the
|
||||
/// action's own status message, and this machine reads its own entries back on the next pull — so a
|
||||
/// rule written against the raw numbers would overwrite that message after every single save, which is
|
||||
/// exactly what it did until the report learned to tell the two apart.
|
||||
/// </remarks>
|
||||
private static bool IsWorthReporting(IReadOnlyList<VaultSyncReport> reports) =>
|
||||
reports.Any(vault => vault.Succeeded
|
||||
&& (vault.Report!.PulledItems > 0
|
||||
|| vault.Report.PushedItems > 0
|
||||
|| vault.Report.NeedsAttention
|
||||
|
||||
// A pass that had to start over says so even when it pulled nothing, which is the one
|
||||
// place this rule is broken deliberately. A machine that silently re-read a whole vault
|
||||
// has had something happen to it, and the alternative is that nobody ever finds out.
|
||||
|| vault.Report.ResyncedFromStart));
|
||||
|
||||
/// <remarks>
|
||||
/// Counts are summed across vaults, and a failure is named <em>with its reason</em>. Both halves
|
||||
/// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a
|
||||
/// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them
|
||||
/// costs nothing.
|
||||
/// </remarks>
|
||||
private static string Describe(IReadOnlyList<VaultSyncReport> reports)
|
||||
{
|
||||
var failed = reports
|
||||
.Where(vault => !vault.Succeeded)
|
||||
.Select(vault => $"{vault.Name} ({vault.Failure?.Message})")
|
||||
.ToList();
|
||||
|
||||
var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList();
|
||||
|
||||
var line = succeeded.Count switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => Describe(succeeded[0]),
|
||||
_ => DescribeMany(succeeded),
|
||||
};
|
||||
|
||||
if (failed.Count == 0)
|
||||
{
|
||||
return line.Length == 0 ? "Nothing to synchronise." : line;
|
||||
}
|
||||
|
||||
var names = string.Join("; ", failed);
|
||||
|
||||
return line.Length == 0
|
||||
? $"Could not synchronise {names}."
|
||||
: $"{line} Could not synchronise {names}.";
|
||||
}
|
||||
|
||||
private static string DescribeMany(List<SyncReport> reports)
|
||||
{
|
||||
var pulled = reports.Sum(report => report.Pulled);
|
||||
var pushed = reports.Sum(report => report.Pushed);
|
||||
var attention = reports.Count(report => report.NeedsAttention);
|
||||
|
||||
var line = pulled == 0 && pushed == 0
|
||||
? $"Already up to date across {reports.Count} vaults."
|
||||
: $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out.";
|
||||
|
||||
return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list.";
|
||||
}
|
||||
|
||||
private static string Describe(SyncReport report)
|
||||
{
|
||||
// Said first, and in both branches, because it is the explanation for the numbers after it. A pass
|
||||
|
||||
@@ -36,8 +36,10 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
One heading, for one vault. The chevron folds the list away; the count is the collection's own, so it
|
||||
follows the filter without a second number to keep in step.
|
||||
One heading, which names the vault while there is one and says ALL VAULTS once a team's is readable
|
||||
too — a heading that went on naming the personal vault over a list containing a team's hosts would be
|
||||
a quiet lie, so the rows carry the vault name instead. The chevron folds the list away; the count is
|
||||
the collection's own, so it follows the filter without a second number to keep in step.
|
||||
-->
|
||||
<Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
@@ -129,6 +131,14 @@
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
<!--
|
||||
Which vault this host is in, and only when there is more than one to be in. It decides
|
||||
who else can see the host and where an edit goes back to, so on a list that spans
|
||||
several vaults it is not decoration.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding HasVaultBadge}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -138,21 +138,14 @@
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
|
||||
Title="TEAM"
|
||||
Milestone="MILESTONE M3"
|
||||
Summary="The design shows members, roles, shared keychains and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every keychain that is not your own — so there is nobody to list and no shared keychain to open."
|
||||
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Keychain screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
|
||||
<x:String>Access to a keychain somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
|
||||
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
|
||||
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
|
||||
<x:String>Sharing an item, which is the point of the screen: today a keychain key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
<!--
|
||||
Wrapped, for the reason the vault and transfers screens are: the visibility is the shell's
|
||||
business and the data context is the teams view model, and both on one element would resolve
|
||||
IsTeamScreen against a type that does not have it.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTeamScreen}">
|
||||
<views:TeamsScreen DataContext="{Binding Teams}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TeamsScreen"
|
||||
x:DataType="vm:TeamsViewModel">
|
||||
|
||||
<!--
|
||||
Teams.
|
||||
|
||||
The screen is built around one fact that every other product in this category hides: adding somebody to
|
||||
a team and giving them a vault key are two different acts, and only the first is something a server can
|
||||
do. The second needs a machine that holds the key, because this server never does. So the members table
|
||||
and the vaults table are side by side, an addition says out loud that it granted nothing readable yet,
|
||||
and SHARE KEY is its own button rather than a checkbox on the member row.
|
||||
|
||||
What the design asked for and is still not here: pending invitations (there is no outbound mail path and
|
||||
no invitation token), two-factor state and last-active (the server records neither), and avatars (no
|
||||
picture is stored anywhere). None of them is drawn with invented data.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<!-- ============ The team list ============ -->
|
||||
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW"
|
||||
Command="{Binding NewTeamCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding Detail}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" Margin="14,12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasTeams}"
|
||||
Text="No teams yet. A team is what makes a vault shareable: its vaults can be opened by every member you wrap a key to." />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- The create form, in place rather than in a modal: this window has no idiom for one. -->
|
||||
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingTeam}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
||||
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Members and vaults ============ -->
|
||||
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
|
||||
<StackPanel Margin="14,14" Spacing="18">
|
||||
|
||||
<!-- Members -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="MEMBERS" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="240">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding Email}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="hint" FontSize="10" VerticalAlignment="Center"
|
||||
Text="{Binding KeyState}" TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
|
||||
Margin="0,0,6,0" />
|
||||
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER"
|
||||
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
|
||||
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Removes the selected member and withdraws every vault key they hold from this team. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding CanAdministerSelected}"
|
||||
Text="Adding somebody lets the server serve them this team's vaults. It does not let them read one: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<!-- Vaults -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
|
||||
Command="{Binding CreateVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
|
||||
</Grid>
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="200">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasSelection}"
|
||||
Text="Select a team to see its vaults." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Wraps the selected vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
|
||||
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" FontSize="11" Margin="20" TextWrapping="Wrap"
|
||||
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
|
||||
Text="Create a team on the left, or wait to be added to one. A team owns vaults; a vault's key is what makes its contents readable, and that key is handed out by people rather than by the server." />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Teams: who is in one, what they may do, and which vaults they hold a key to.</summary>
|
||||
internal sealed partial class TeamsScreen : UserControl
|
||||
{
|
||||
public TeamsScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -23,6 +23,16 @@
|
||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||||
described as deliberately rare. This is the one place it is spent.
|
||||
|
||||
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
|
||||
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
|
||||
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
|
||||
characters that actually say so. The two never compete for one TextBlock, which is what lets a
|
||||
world-writable executable show both facts instead of one winning an argument.
|
||||
|
||||
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
|
||||
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
|
||||
string is the compromise: the eye lands on the column, and the string itself is the detail.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
@@ -30,6 +40,25 @@
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.exec">
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
|
||||
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
|
||||
-->
|
||||
<Style Selector="TextBlock.perms">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<!--
|
||||
Warn rather than WarnText, which is the muted amber a warning card writes its sentences in. At 9.5px
|
||||
against TextFaint that one is a shade, not a signal, and a marker nobody notices is the same as no
|
||||
marker at all.
|
||||
-->
|
||||
<Style Selector="TextBlock.perms.loose">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
@@ -342,14 +371,15 @@
|
||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Classes.exec="{Binding IsExecutable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Permissions}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
Text="{Binding Permissions}" FontSize="9.5" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
behind them, so listing them would be two headings that could never have anything under them. Recorded
|
||||
in docs/design-import-gaps.md.
|
||||
|
||||
The SCOPES rail below the categories is the keychain list, which is real and today has one entry in it.
|
||||
The design shows three, two of them teams; team keychains exist as tables on the server and are refused
|
||||
by its access service, so a rail with three entries would be showing two nothing can open.
|
||||
The SCOPES rail below the categories is the keychain list. Since M3 it genuinely has more than one entry
|
||||
when somebody is in a team — but it is still not a selector, because every table on this screen already
|
||||
spans every keychain this session holds a key for and each row names its own. What it carries instead is
|
||||
the one keychain question with an answer: where a new item is filed.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="176,*,244">
|
||||
@@ -91,18 +92,35 @@
|
||||
<TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" />
|
||||
|
||||
<!--
|
||||
One entry per vault this session opened. Not a selector: every list on this screen reads the
|
||||
active vault, and a rail that let you click a vault you cannot switch to would be a control that
|
||||
does nothing. It is here because knowing which vault you are looking at is worth a line, and
|
||||
because this is where a second one appears when shared vaults arrive.
|
||||
Still not a selector. Every list on this screen now spans every vault this session holds a key
|
||||
for, and each row names its own vault — so there is nothing to switch to. What the picker below
|
||||
chooses is where a *new* item is filed, which is a different question and the only one that has
|
||||
an answer worth asking for.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
|
||||
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
|
||||
Text="One keychain, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
|
||||
|
||||
<!--
|
||||
Hidden at one vault, which is where most people stay. A control offering a single option is a
|
||||
question with no answer.
|
||||
-->
|
||||
<StackPanel Margin="14,10,14,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
|
||||
<TextBlock Classes="label" Text="NEW ITEMS GO TO" />
|
||||
<ComboBox ItemsSource="{Binding TargetVaults}"
|
||||
SelectedItem="{Binding SelectedTargetVault}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="11" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="An item filed into a team's vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Items that would not decrypt. Shown here rather than only in the status line because this is the
|
||||
|
||||
@@ -134,6 +134,22 @@ public interface IVaultServer : IDisposable
|
||||
/// <summary>Pull and push.</summary>
|
||||
ISyncApi Sync { get; }
|
||||
|
||||
/// <summary>Teams, their members, and the vaults they own.</summary>
|
||||
ITeamApi Teams { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The public-key directory, and the key log that makes an answer from it checkable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed as one member because the two are only ever used together: a directory answer is a claim
|
||||
/// the server makes about somebody else's key, and the log is what turns it into something a client
|
||||
/// can verify. See <c>KeyLogAudit</c>.
|
||||
/// </remarks>
|
||||
IDirectoryApi Directory { get; }
|
||||
|
||||
/// <summary>Vault key grants: who can open a vault, and who let them.</summary>
|
||||
IVaultGrantApi Grants { get; }
|
||||
|
||||
/// <summary>Obtains the identity provider's signature over a key statement.</summary>
|
||||
IKeyBindingAuthorizer KeyBinding { get; }
|
||||
|
||||
@@ -213,6 +229,15 @@ public sealed class ServerConnection : IVaultServer
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITeamApi Teams => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDirectoryApi Directory => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultGrantApi Grants => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => Oidc;
|
||||
|
||||
|
||||
@@ -26,6 +26,25 @@ public sealed record ConflictNotice(
|
||||
IReadOnlyList<ConflictDetailEntry> Fields,
|
||||
DateTimeOffset DetectedAt);
|
||||
|
||||
/// <summary>One vault's outcome from a pass over all of them.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name, so a message about it can name it.</param>
|
||||
/// <param name="Report">What the pass did, when it completed.</param>
|
||||
/// <param name="Failure">
|
||||
/// Why it did not, when it failed. Carried rather than thrown so one unreachable team vault cannot
|
||||
/// leave the others unsynced — and reported rather than swallowed, because a vault that silently
|
||||
/// stopped syncing is the worst of the three outcomes.
|
||||
/// </param>
|
||||
public sealed record VaultSyncReport(
|
||||
Guid VaultId,
|
||||
string Name,
|
||||
SyncReport? Report,
|
||||
Exception? Failure)
|
||||
{
|
||||
/// <summary>Whether this vault synced.</summary>
|
||||
public bool Succeeded => Report is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
|
||||
/// </summary>
|
||||
@@ -41,7 +60,7 @@ public sealed record ConflictNotice(
|
||||
/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultSession : IAsyncDisposable
|
||||
public sealed partial class VaultSession : IAsyncDisposable
|
||||
{
|
||||
private readonly UserSecretBundle bundle;
|
||||
private readonly LocalCacheProtector protector;
|
||||
@@ -111,11 +130,28 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
public StoredUnlockMaterial Profile { get; }
|
||||
|
||||
/// <summary>Every vault this user can reach, readable or not.</summary>
|
||||
public IReadOnlyList<StoredVault> Vaults { get; }
|
||||
/// <remarks>
|
||||
/// Re-read rather than fixed at unlock: a vault a teammate shares arrives mid-session, and one
|
||||
/// whose grant is withdrawn stops being readable mid-session too. <see cref="RefreshVaultsAsync"/>
|
||||
/// is what moves it, and it is the only thing that does.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<StoredVault> Vaults { get; private set; }
|
||||
|
||||
/// <summary>The vault the interface is showing. The personal one, for now.</summary>
|
||||
/// <summary>
|
||||
/// The vault new items are created in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One vault is the write target, not the read set — reading spans every vault the keyring opened.
|
||||
/// It stays the first readable one, which is the personal vault whenever there is one, because an
|
||||
/// application that silently filed a new host into a team's vault because that was the last thing
|
||||
/// selected would be the wrong default in the one direction that is hard to undo.
|
||||
/// </remarks>
|
||||
public Guid ActiveVaultId { get; }
|
||||
|
||||
/// <summary>Every vault this session actually holds a key for.</summary>
|
||||
public IEnumerable<StoredVault> ReadableVaults =>
|
||||
Vaults.Where(vault => keyring.CanRead(vault.VaultId));
|
||||
|
||||
/// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
|
||||
public HostRepository Hosts { get; }
|
||||
|
||||
@@ -232,10 +268,14 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
return SignIn.ForgetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Runs one synchronisation pass over the active vault.</summary>
|
||||
/// <summary>Runs one synchronisation pass over one vault.</summary>
|
||||
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
|
||||
/// <param name="vaultId">The vault to sync.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<SyncReport> SyncAsync(ISyncApi api, CancellationToken cancellationToken)
|
||||
public Task<SyncReport> SyncAsync(
|
||||
ISyncApi api,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
@@ -243,7 +283,51 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
var engine = new SyncEngine(
|
||||
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
|
||||
|
||||
return engine.SyncAsync(ActiveVaultId, cancellationToken);
|
||||
return engine.SyncAsync(vaultId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one synchronisation pass over every vault this session can read.
|
||||
/// </summary>
|
||||
/// <returns>One report per vault, in the order they were synced.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Sequential rather than concurrent. Each vault has its own cursor and its own outbox, so nothing
|
||||
/// forces the order — but a client that opened one connection per vault would multiply its request
|
||||
/// rate by the number of teams somebody is in, against a server the same person is also using
|
||||
/// interactively. Vaults are few and passes are cheap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A vault that throws does not stop the rest. One team's vault being unreachable — a revoked grant
|
||||
/// noticed mid-pass, a server-side fault — is not a reason to leave the personal vault unsynced,
|
||||
/// and the failure is reported per vault rather than as one exception naming none of them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<VaultSyncReport>> SyncAllAsync(
|
||||
ISyncApi api,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
|
||||
var reports = new List<VaultSyncReport>();
|
||||
|
||||
foreach (var vault in ReadableVaults.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
var report = await SyncAsync(api, vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, report, null));
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, null, exception));
|
||||
}
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>What a share attempt did.</summary>
|
||||
/// <param name="Shared">Whether a grant was recorded.</param>
|
||||
/// <param name="Verification">
|
||||
/// How the recipient's key was checked. Present whether or not the share went ahead, because a refusal
|
||||
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
|
||||
/// </param>
|
||||
/// <param name="Message">One line for a person. Never contains key material.</param>
|
||||
public sealed record ShareOutcome(
|
||||
bool Shared,
|
||||
RecipientVerification Verification,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Sharing, from the side that holds the keys.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These live on <see cref="VaultSession"/> rather than in a service above it for the reason
|
||||
/// registering a device does: wrapping a vault key is the one step only an unlocked session can
|
||||
/// perform, and this type is the keyring's custodian. Everything else — the calls, the directory —
|
||||
/// arrives as a parameter, so the session still knows nothing about how either is implemented.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here trusts the server's answer about somebody else's key.</b> Every share reads the
|
||||
/// whole key log, verifies its hash chain, and refuses unless the directory's answer appears in it
|
||||
/// unchanged. That check is the difference between end-to-end encryption and a server that can read
|
||||
/// everything by handing out a key of its own; see <see cref="KeyLogAudit"/> and ADR 0001.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class VaultSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a vault owned by a team, generating its key here.
|
||||
/// </summary>
|
||||
/// <param name="api">The team calls.</param>
|
||||
/// <param name="teamId">The owning team.</param>
|
||||
/// <param name="name">Display name. Plaintext, as all vault names are.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The new vault, already readable by this session.</returns>
|
||||
/// <remarks>
|
||||
/// The key never leaves this process in the clear: it is generated here, sealed to this user's own
|
||||
/// encryption key, and the seal is what the server stores. The creator's grant carries no key log
|
||||
/// head, exactly as a personal vault's does not — there is no third party whose key could have been
|
||||
/// substituted when you wrap something to yourself.
|
||||
/// </remarks>
|
||||
public async Task<StoredVault> CreateTeamVaultAsync(
|
||||
ITeamApi api,
|
||||
Guid teamId,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
var vaultKey = VaultKeys.Create();
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var request = BuildCreateRequest(vaultId, vaultKey, name, now);
|
||||
|
||||
var summary = await api.CreateTeamVaultAsync(teamId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var stored = ToStored(summary);
|
||||
|
||||
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Adopted rather than unwrapped from the response: this process generated the key, so
|
||||
// unwrapping the server's copy of our own seal would be a round trip to learn something we
|
||||
// already know. The keyring takes ownership from here.
|
||||
keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
|
||||
|
||||
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return stored;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never reached the keyring, so this is the only thing that can release it.
|
||||
CryptographicOperations.ZeroMemory(vaultKey);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a vault's key to another member, after verifying their published key.
|
||||
/// </summary>
|
||||
/// <param name="grants">The grant calls.</param>
|
||||
/// <param name="directory">The directory and the key log that makes it checkable.</param>
|
||||
/// <param name="vaultId">The vault to share.</param>
|
||||
/// <param name="recipientUserId">Who to share it with.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The verification is not optional and is not a parameter. A caller that could pass
|
||||
/// <c>skipChecks: true</c> is a caller that will, on the day the log is briefly unreachable, and the
|
||||
/// resulting grant is indistinguishable from a correct one afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this still cannot promise is that the key belongs to the person you meant. Compare
|
||||
/// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
|
||||
/// that is the only step that closes the gap, and the outcome message says so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<ShareOutcome> ShareVaultAsync(
|
||||
IVaultGrantApi grants,
|
||||
IDirectoryApi directory,
|
||||
Guid vaultId,
|
||||
Guid recipientUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(grants);
|
||||
ArgumentNullException.ThrowIfNull(directory);
|
||||
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var keyGeneration))
|
||||
{
|
||||
throw new VaultUnreadableException(vaultId);
|
||||
}
|
||||
|
||||
var entry = await directory.LookupByIdAsync(recipientUserId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var log = await KeyLogAudit.ReadAsync(directory, cancellationToken).ConfigureAwait(false);
|
||||
var verification = KeyLogAudit.Verify(log, entry);
|
||||
|
||||
if (!verification.IsVerified)
|
||||
{
|
||||
return new ShareOutcome(false, verification, verification.Message);
|
||||
}
|
||||
|
||||
var recipient = verification.Recipient!;
|
||||
|
||||
await IssueAsync(grants, vaultId, vaultKey, keyGeneration, recipient, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ShareOutcome(
|
||||
true,
|
||||
verification,
|
||||
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
|
||||
+ "its own only proves this server has been consistent with itself.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
|
||||
/// </summary>
|
||||
/// <returns>How many vaults this call made readable that were not before.</returns>
|
||||
/// <remarks>
|
||||
/// Called after a share and on a periodic pass. A vault somebody shared a minute ago arrives as a
|
||||
/// new entry with a wrapped key attached; one whose grant was revoked arrives without one, and is
|
||||
/// marked unreadable rather than quietly dropped so the interface can say what happened. Items
|
||||
/// already pulled are deliberately left alone — see <see cref="VaultStore.ReplaceAllAsync"/>.
|
||||
/// </remarks>
|
||||
public async Task<int> RefreshVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
|
||||
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await Vault.ReplaceAllAsync([.. me.Vaults.Select(ToStored)], cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var admitted = 0;
|
||||
|
||||
foreach (var vault in Vaults)
|
||||
{
|
||||
if (keyring.CanRead(vault.VaultId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (keyring.TryAdmit(bundle, vault))
|
||||
{
|
||||
admitted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
keyring.MarkUnreadable(vault.VaultId);
|
||||
}
|
||||
}
|
||||
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/// <summary>Signs and posts one grant.</summary>
|
||||
private async Task IssueAsync(
|
||||
IVaultGrantApi grants,
|
||||
Guid vaultId,
|
||||
ReadOnlyMemory<byte> vaultKey,
|
||||
uint keyGeneration,
|
||||
VerifiedRecipient recipient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
var entry = recipient.Entry;
|
||||
|
||||
var wrapped = VaultKeys.WrapTo(
|
||||
vaultKey.Span, entry.EncryptionPublicKey, vaultId, keyGeneration);
|
||||
|
||||
var ownFingerprint = DshCrypto.ComputeFingerprint(
|
||||
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
|
||||
|
||||
var canonical = GrantStatementCodec.Encode(
|
||||
vaultId,
|
||||
keyGeneration,
|
||||
GrantPurpose.Member,
|
||||
granteeUserId: entry.UserId,
|
||||
granteeKeyFingerprint: recipient.Fingerprint,
|
||||
wrappedKey: wrapped,
|
||||
granterUserId: Profile.UserId,
|
||||
granterKeyFingerprint: ownFingerprint,
|
||||
|
||||
// Present, unlike a self-grant's. This is the third-party case the head exists for: it
|
||||
// records which view of the key log this client held while wrapping, so a server showing
|
||||
// two clients different logs has to keep both stories straight for ever after.
|
||||
keyLogHead: recipient.KeyLogHead,
|
||||
grantedAt: now);
|
||||
|
||||
await grants.IssueVaultGrantAsync(
|
||||
vaultId,
|
||||
new IssueVaultGrantRequest(
|
||||
RecipientUserId: entry.UserId,
|
||||
RecipientKeyFingerprint: recipient.Fingerprint,
|
||||
KeyGeneration: keyGeneration,
|
||||
WrappedVaultKey: wrapped,
|
||||
KeyLogHead: recipient.KeyLogHead,
|
||||
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
|
||||
GrantedAt: now),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The signature covers the vault id, so the id has to be chosen before anything is wrapped — which
|
||||
/// is also what makes a create whose response was lost safe to send again.
|
||||
/// </remarks>
|
||||
private CreateTeamVaultRequest BuildCreateRequest(
|
||||
Guid vaultId,
|
||||
byte[] vaultKey,
|
||||
string name,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var wrapped = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
|
||||
|
||||
var fingerprint = DshCrypto.ComputeFingerprint(
|
||||
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
|
||||
|
||||
var canonical = GrantStatementCodec.Encode(
|
||||
vaultId,
|
||||
keyGeneration: 1,
|
||||
GrantPurpose.Member,
|
||||
granteeUserId: Profile.UserId,
|
||||
granteeKeyFingerprint: fingerprint,
|
||||
wrappedKey: wrapped,
|
||||
granterUserId: Profile.UserId,
|
||||
granterKeyFingerprint: fingerprint,
|
||||
keyLogHead: default,
|
||||
grantedAt: now);
|
||||
|
||||
return new CreateTeamVaultRequest(
|
||||
VaultId: vaultId,
|
||||
Name: name,
|
||||
WrappedVaultKey: wrapped,
|
||||
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
|
||||
GrantedAt: now);
|
||||
}
|
||||
|
||||
private static StoredVault ToStored(VaultSummary summary) =>
|
||||
new(
|
||||
summary.VaultId,
|
||||
summary.Name,
|
||||
summary.IsPersonal,
|
||||
summary.TeamId,
|
||||
summary.KeyGeneration,
|
||||
summary.Permissions,
|
||||
summary.WrappedVaultKey,
|
||||
summary.RekeyRequired);
|
||||
}
|
||||
@@ -51,6 +51,32 @@ public sealed record SftpEntry(
|
||||
/// file fails the listing instead, which is the caller's cue that it was not a directory after all.
|
||||
/// </remarks>
|
||||
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
|
||||
|
||||
/// <summary>Whether this is a file somebody can run.</summary>
|
||||
/// <remarks>
|
||||
/// Files only. On a directory the execute bit means "may be searched", which is true of very nearly every
|
||||
/// directory on a host — a listing that marked them all would be marking nothing.
|
||||
/// </remarks>
|
||||
public bool IsExecutable => Kind is SftpEntryKind.File && PosixMode.HasAnyExecuteBit(Permissions);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a file any account on the host may write to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Files only, and for two separate reasons. A symbolic link is <c>lrwxrwxrwx</c> by convention on every
|
||||
/// system that has one, and its mode governs nothing: what may be written is the target, whose own mode
|
||||
/// this listing did not fetch. And a directory that everyone may write to is the ordinary arrangement for
|
||||
/// <c>/tmp</c>, made safe by the sticky bit — which <see cref="PosixMode"/> does not render, so flagging
|
||||
/// the directory would be warning about the half of the mode that is on screen while the half that
|
||||
/// answers the warning is not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is not a claim that writing is dangerous, only that the mode says something a reader of that column
|
||||
/// would want to have noticed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool IsWorldWritable => Kind is SftpEntryKind.File && PosixMode.IsWorldWritable(Permissions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,6 +132,27 @@ public static class PosixMode
|
||||
triple[2] = execute ? 'x' : '-';
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether any of the three execute bits is set.</summary>
|
||||
public static bool HasAnyExecuteBit(string mode) =>
|
||||
At(mode, OwnerExecute) == 'x' || At(mode, GroupExecute) == 'x' || At(mode, OthersExecute) == 'x';
|
||||
|
||||
/// <summary>Whether the others triple carries the write bit.</summary>
|
||||
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
|
||||
|
||||
private const int OwnerExecute = 3;
|
||||
private const int GroupExecute = 6;
|
||||
private const int OthersWrite = 8;
|
||||
private const int OthersExecute = 9;
|
||||
|
||||
/// <remarks>
|
||||
/// Reading back what <see cref="Format"/> wrote, rather than carrying the nine booleans through
|
||||
/// <see cref="SftpEntry"/> as well. The alternative is a second representation of one fact, and the two
|
||||
/// disagreeing is the failure this avoids — a row coloured for a bit the column beside it does not show.
|
||||
/// Anything that is not a mode this type wrote answers false rather than throwing: these questions decide
|
||||
/// a colour, and a listing is not worth failing over one.
|
||||
/// </remarks>
|
||||
private static char At(string mode, int index) => mode.Length == 10 ? mode[index] : '-';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -79,7 +79,30 @@ public sealed record StoredVault(
|
||||
uint KeyGeneration,
|
||||
int Permissions,
|
||||
byte[]? WrappedVaultKey,
|
||||
bool RekeyRequired);
|
||||
bool RekeyRequired)
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>Write</c> bit of <see cref="Permissions"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A literal rather than a reference to <c>DodoSSH.Domain.PermissionFlags</c>, because that enum is
|
||||
/// the server's and no client project references the domain assembly. The value is part of the wire
|
||||
/// contract — <c>VaultSummary.Permissions</c> is an opaque int by design — and a test pins the two
|
||||
/// together so a renumbering cannot silently make a read-only vault look writable here.
|
||||
/// </remarks>
|
||||
private const int WriteFlag = 1 << 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the server would accept a change to this vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A user-interface answer, not a boundary: the server checks the same bit on every push, and this
|
||||
/// exists so somebody in a team as a viewer is not offered a Save button that ends in a 403. Its
|
||||
/// counterpart — whether the items can be <em>read</em> — is not a permission at all but a question
|
||||
/// of holding the vault key, and is answered by the keyring.
|
||||
/// </remarks>
|
||||
public bool CanWrite => (Permissions & WriteFlag) == WriteFlag;
|
||||
}
|
||||
|
||||
/// <summary>The last item state the server confirmed.</summary>
|
||||
/// <param name="VaultId">Owning vault.</param>
|
||||
|
||||
@@ -88,6 +88,37 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records one vault, leaving the rest alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For the vault this machine has just created, which exists here before the server's next
|
||||
/// <c>/me</c> confirms it. <see cref="ReplaceAllAsync"/> would be wrong for that: it treats absence
|
||||
/// as loss of access, and the one list that does not yet mention this vault is the one this client
|
||||
/// last fetched.
|
||||
/// </remarks>
|
||||
public async Task UpsertAsync(StoredVault vault, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<CachedVaultRow>()
|
||||
.SingleOrDefaultAsync(r => r.VaultId == vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new CachedVaultRow { VaultId = vault.VaultId };
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
Apply(row, vault, clock.GetUtcNow());
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
|
||||
{
|
||||
row.Name = vault.Name;
|
||||
|
||||
@@ -84,6 +84,93 @@ public sealed class VaultKeyring : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes a vault key this session has just generated.
|
||||
/// </summary>
|
||||
/// <param name="vaultId">The vault.</param>
|
||||
/// <param name="vaultKey">
|
||||
/// The plaintext key. <b>The keyring takes ownership</b> and zeroes it on disposal; the caller must
|
||||
/// not keep a reference or zero it itself.
|
||||
/// </param>
|
||||
/// <param name="keyGeneration">The generation this key is for.</param>
|
||||
/// <remarks>
|
||||
/// Creating a team vault is the only case: the client generates the key, wraps it to itself and
|
||||
/// sends the wrap, so the plaintext is already here and unwrapping the server's copy back would be
|
||||
/// a round trip to learn something this process just chose. Adopting it also means the new vault is
|
||||
/// usable immediately rather than at the next unlock, which is what somebody who just pressed
|
||||
/// "create" expects.
|
||||
/// </remarks>
|
||||
public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(vaultKey);
|
||||
|
||||
if (keys.TryGetValue(vaultId, out var previous))
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(previous);
|
||||
}
|
||||
|
||||
keys[vaultId] = vaultKey;
|
||||
generations[vaultId] = keyGeneration;
|
||||
|
||||
Unopened = [.. Unopened.Where(id => id != vaultId)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to open a vault that has become readable since the session was unlocked.
|
||||
/// </summary>
|
||||
/// <returns>Whether the grant opened.</returns>
|
||||
/// <remarks>
|
||||
/// What a share looks like from the receiving end: the vault was in the list all along, listed and
|
||||
/// unreadable, and a member holding Share has now wrapped its key. Re-opening it here rather than
|
||||
/// waiting for a relock is the difference between "someone shared a vault with you" arriving and
|
||||
/// arriving tomorrow.
|
||||
/// </remarks>
|
||||
public bool TryAdmit(UserSecretBundle bundle, StoredVault vault)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
|
||||
if (vault.WrappedVaultKey is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keys.ContainsKey(vault.VaultId) && generations[vault.VaultId] == vault.KeyGeneration)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var key = VaultKeys.TryUnwrap(
|
||||
bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, vault.KeyGeneration);
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Adopt(vault.VaultId, key, vault.KeyGeneration);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Records that a vault cannot be read, so the interface can say so.</summary>
|
||||
/// <remarks>
|
||||
/// The counterpart of <see cref="TryAdmit"/> for the case where the grant did not open. Kept
|
||||
/// explicit rather than inferred from the absence of a key, because "no key" is also what a vault
|
||||
/// this session has never heard of looks like.
|
||||
/// </remarks>
|
||||
public void MarkUnreadable(Guid vaultId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (!Unopened.Contains(vaultId))
|
||||
{
|
||||
Unopened = [.. Unopened, vaultId];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Borrows a vault's key.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,6 +40,17 @@ namespace DodoSSH.Contracts;
|
||||
[JsonSerializable(typeof(DirectoryEntry))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<DirectoryEntry>))]
|
||||
[JsonSerializable(typeof(VaultSummary))]
|
||||
[JsonSerializable(typeof(TeamSummary))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<TeamSummary>))]
|
||||
[JsonSerializable(typeof(CreateTeamRequest))]
|
||||
[JsonSerializable(typeof(TeamMemberSummary))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
|
||||
[JsonSerializable(typeof(AddTeamMemberRequest))]
|
||||
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
|
||||
[JsonSerializable(typeof(CreateTeamVaultRequest))]
|
||||
[JsonSerializable(typeof(IssueVaultGrantRequest))]
|
||||
[JsonSerializable(typeof(VaultGrantsResponse))]
|
||||
[JsonSerializable(typeof(KeyLogPage))]
|
||||
[JsonSerializable(typeof(SyncPullRequest))]
|
||||
[JsonSerializable(typeof(SyncPullResponse))]
|
||||
[JsonSerializable(typeof(SyncPushRequest))]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace DodoSSH.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the append-only log of every identity key statement ever published.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Served so a client can recompute the chain for itself rather than taking the directory's word for a
|
||||
/// public key. Every field the hash covers is here, in the order docs/crypto.md §7.2 hashes them, so
|
||||
/// verification is <c>ComputeEntryHash(previous, …) == hash</c> and nothing else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Sequence"/> is deliberately <em>not</em> an input to the hash. It is assigned by the
|
||||
/// database on insert, so a renumbered or gapped column cannot silently reorder history — order
|
||||
/// follows the hash links. It is here to page with, and to compare against what a directory entry
|
||||
/// claims.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Sequence">Monotonic position in the log.</param>
|
||||
/// <param name="UserId">Whose key this is.</param>
|
||||
/// <param name="Generation">Which generation of that user's key.</param>
|
||||
/// <param name="EncryptionPublicKey">X25519 public key, 32 bytes.</param>
|
||||
/// <param name="SigningPublicKey">Ed25519 public key, 32 bytes.</param>
|
||||
/// <param name="StatementSignature">The self-signature over the key statement.</param>
|
||||
/// <param name="PreviousHash">Hash of the preceding entry; all-zero for the first.</param>
|
||||
/// <param name="Hash">This entry's hash, over the previous hash and its own contents.</param>
|
||||
/// <param name="CreatedAt">When it was appended, truncated to milliseconds as the hash requires.</param>
|
||||
public sealed record KeyLogRecord(
|
||||
long Sequence,
|
||||
Guid UserId,
|
||||
int Generation,
|
||||
byte[] EncryptionPublicKey,
|
||||
byte[] SigningPublicKey,
|
||||
byte[] StatementSignature,
|
||||
byte[] PreviousHash,
|
||||
byte[] Hash,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>A page of the key log, with the head as of this response.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The head is what a client records in every grant it signs, which is what makes a forked view
|
||||
/// detectable: for a server to show two clients different key logs it must keep both forks consistent
|
||||
/// across every later shared operation, and any two clients touching one vault then disagree. It
|
||||
/// converts an otherwise invisible key substitution into a visible one. It does not prevent it — see
|
||||
/// ADR 0001.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Head"/> describes the whole log, not this page: a client that pages from an old cursor
|
||||
/// still learns where the end is, and can tell whether it has caught up without a second call.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Entries">Entries after the requested sequence, in ascending order.</param>
|
||||
/// <param name="HeadSequence">Sequence of the log's last entry; 0 when the log is empty.</param>
|
||||
/// <param name="Head">
|
||||
/// Hash of the log's last entry. All-zero when the log is empty, which is the same value the first
|
||||
/// entry records as its predecessor.
|
||||
/// </param>
|
||||
/// <param name="HasMore">Whether entries beyond this page are immediately available.</param>
|
||||
public sealed record KeyLogPage(
|
||||
IReadOnlyList<KeyLogRecord> Entries,
|
||||
long HeadSequence,
|
||||
byte[] Head,
|
||||
bool HasMore);
|
||||
@@ -80,4 +80,40 @@ public static class ProblemCodes
|
||||
|
||||
/// <summary>A push batch exceeded the operation count or payload size cap.</summary>
|
||||
public const string PushBatchTooLarge = "push-batch-too-large";
|
||||
|
||||
/// <summary>
|
||||
/// A team create or membership change was structurally invalid: a blank name, a slug that is
|
||||
/// not URL-safe, an unknown role, or an account that does not exist here.
|
||||
/// </summary>
|
||||
public const string InvalidTeam = "invalid-team";
|
||||
|
||||
/// <summary>
|
||||
/// The requested slug is already in use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own code rather than folded into <see cref="InvalidTeam"/>, because it is the one create
|
||||
/// failure the caller could not have predicted from their own input and the only one whose
|
||||
/// remedy is "pick a different one" rather than "fix what you typed".
|
||||
/// </remarks>
|
||||
public const string TeamSlugTaken = "team-slug-taken";
|
||||
|
||||
/// <summary>
|
||||
/// The change would leave a team with no owner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Refused rather than allowed, because a team with no owner has nobody who can appoint one —
|
||||
/// and the only route back would be an operator editing the database by hand.
|
||||
/// </remarks>
|
||||
public const string LastTeamOwner = "last-team-owner";
|
||||
|
||||
/// <summary>
|
||||
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
|
||||
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Never a statement about the wrapped key's <em>contents</em>. The server cannot open it, so a
|
||||
/// grant containing garbage is accepted here and surfaces at the recipient as a tag failure,
|
||||
/// with the signature naming who issued it. See docs/crypto.md §6.
|
||||
/// </remarks>
|
||||
public const string InvalidVaultGrant = "invalid-vault-grant";
|
||||
}
|
||||
|
||||
@@ -8,13 +8,61 @@ const DodoSSH.Contracts.ProblemCodes.IdentityBindingInvalid = "identity-binding-
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.TeamSlugTaken = "team-slug-taken" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
|
||||
DodoSSH.Contracts.AddTeamMemberRequest
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.<Clone>$() -> DodoSSH.Contracts.AddTeamMemberRequest!
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.AddTeamMemberRequest(System.Guid UserId, DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Deconstruct(out System.Guid UserId, out DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Equals(DodoSSH.Contracts.AddTeamMemberRequest? other) -> bool
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.Role.init -> void
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.UserId.get -> System.Guid
|
||||
DodoSSH.Contracts.AddTeamMemberRequest.UserId.init -> void
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.<Clone>$() -> DodoSSH.Contracts.ChangeTeamMemberRoleRequest!
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ChangeTeamMemberRoleRequest(DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
|
||||
DodoSSH.Contracts.CreateTeamRequest
|
||||
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
|
||||
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
|
||||
DodoSSH.Contracts.CreateTeamRequest.Deconstruct(out System.Guid TeamId, out string! Name, out string! Slug, out string? Description) -> void
|
||||
DodoSSH.Contracts.CreateTeamRequest.Description.get -> string?
|
||||
DodoSSH.Contracts.CreateTeamRequest.Description.init -> void
|
||||
DodoSSH.Contracts.CreateTeamRequest.Equals(DodoSSH.Contracts.CreateTeamRequest? other) -> bool
|
||||
DodoSSH.Contracts.CreateTeamRequest.Name.get -> string!
|
||||
DodoSSH.Contracts.CreateTeamRequest.Name.init -> void
|
||||
DodoSSH.Contracts.CreateTeamRequest.Slug.get -> string!
|
||||
DodoSSH.Contracts.CreateTeamRequest.Slug.init -> void
|
||||
DodoSSH.Contracts.CreateTeamRequest.TeamId.get -> System.Guid
|
||||
DodoSSH.Contracts.CreateTeamRequest.TeamId.init -> void
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamVaultRequest!
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.CreateTeamVaultRequest(System.Guid VaultId, string! Name, byte[]! WrappedVaultKey, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.Deconstruct(out System.Guid VaultId, out string! Name, out byte[]! WrappedVaultKey, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.Equals(DodoSSH.Contracts.CreateTeamVaultRequest? other) -> bool
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.GrantedAt.get -> System.DateTimeOffset
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.GrantedAt.init -> void
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.GrantSignature.get -> byte[]!
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.GrantSignature.init -> void
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.Name.get -> string!
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.Name.init -> void
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.VaultId.get -> System.Guid
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.VaultId.init -> void
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.WrappedVaultKey.get -> byte[]!
|
||||
DodoSSH.Contracts.CreateTeamVaultRequest.WrappedVaultKey.init -> void
|
||||
DodoSSH.Contracts.DirectoryEntry
|
||||
DodoSSH.Contracts.DirectoryEntry.<Clone>$() -> DodoSSH.Contracts.DirectoryEntry!
|
||||
DodoSSH.Contracts.DirectoryEntry.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! Fingerprint, out int KeyGeneration, out long KeyLogSequence) -> void
|
||||
@@ -105,6 +153,25 @@ DodoSSH.Contracts.EnrollmentResponse.PersonalVaultId.get -> System.Guid
|
||||
DodoSSH.Contracts.EnrollmentResponse.PersonalVaultId.init -> void
|
||||
DodoSSH.Contracts.EnrollmentResponse.UserId.get -> System.Guid
|
||||
DodoSSH.Contracts.EnrollmentResponse.UserId.init -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.<Clone>$() -> DodoSSH.Contracts.IssueVaultGrantRequest!
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.Deconstruct(out System.Guid RecipientUserId, out byte[]! RecipientKeyFingerprint, out uint KeyGeneration, out byte[]! WrappedVaultKey, out byte[]! KeyLogHead, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.Equals(DodoSSH.Contracts.IssueVaultGrantRequest? other) -> bool
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.GrantedAt.get -> System.DateTimeOffset
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.GrantedAt.init -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.GrantSignature.get -> byte[]!
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.GrantSignature.init -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.IssueVaultGrantRequest(System.Guid RecipientUserId, byte[]! RecipientKeyFingerprint, uint KeyGeneration, byte[]! WrappedVaultKey, byte[]! KeyLogHead, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.KeyGeneration.get -> uint
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.KeyGeneration.init -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.KeyLogHead.get -> byte[]!
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.KeyLogHead.init -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientKeyFingerprint.get -> byte[]!
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientKeyFingerprint.init -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientUserId.get -> System.Guid
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientUserId.init -> void
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.WrappedVaultKey.get -> byte[]!
|
||||
DodoSSH.Contracts.IssueVaultGrantRequest.WrappedVaultKey.init -> void
|
||||
DodoSSH.Contracts.KdfParameters
|
||||
DodoSSH.Contracts.KdfParameters.<Clone>$() -> DodoSSH.Contracts.KdfParameters!
|
||||
DodoSSH.Contracts.KdfParameters.Algorithm.get -> string!
|
||||
@@ -120,6 +187,42 @@ DodoSSH.Contracts.KdfParameters.Passes.get -> int
|
||||
DodoSSH.Contracts.KdfParameters.Passes.init -> void
|
||||
DodoSSH.Contracts.KdfParameters.Salt.get -> byte[]!
|
||||
DodoSSH.Contracts.KdfParameters.Salt.init -> void
|
||||
DodoSSH.Contracts.KeyLogPage
|
||||
DodoSSH.Contracts.KeyLogPage.<Clone>$() -> DodoSSH.Contracts.KeyLogPage!
|
||||
DodoSSH.Contracts.KeyLogPage.Deconstruct(out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>! Entries, out long HeadSequence, out byte[]! Head, out bool HasMore) -> void
|
||||
DodoSSH.Contracts.KeyLogPage.Entries.get -> System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>!
|
||||
DodoSSH.Contracts.KeyLogPage.Entries.init -> void
|
||||
DodoSSH.Contracts.KeyLogPage.Equals(DodoSSH.Contracts.KeyLogPage? other) -> bool
|
||||
DodoSSH.Contracts.KeyLogPage.HasMore.get -> bool
|
||||
DodoSSH.Contracts.KeyLogPage.HasMore.init -> void
|
||||
DodoSSH.Contracts.KeyLogPage.Head.get -> byte[]!
|
||||
DodoSSH.Contracts.KeyLogPage.Head.init -> void
|
||||
DodoSSH.Contracts.KeyLogPage.HeadSequence.get -> long
|
||||
DodoSSH.Contracts.KeyLogPage.HeadSequence.init -> void
|
||||
DodoSSH.Contracts.KeyLogPage.KeyLogPage(System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>! Entries, long HeadSequence, byte[]! Head, bool HasMore) -> void
|
||||
DodoSSH.Contracts.KeyLogRecord
|
||||
DodoSSH.Contracts.KeyLogRecord.<Clone>$() -> DodoSSH.Contracts.KeyLogRecord!
|
||||
DodoSSH.Contracts.KeyLogRecord.CreatedAt.get -> System.DateTimeOffset
|
||||
DodoSSH.Contracts.KeyLogRecord.CreatedAt.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.Deconstruct(out long Sequence, out System.Guid UserId, out int Generation, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! StatementSignature, out byte[]! PreviousHash, out byte[]! Hash, out System.DateTimeOffset CreatedAt) -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.EncryptionPublicKey.get -> byte[]!
|
||||
DodoSSH.Contracts.KeyLogRecord.EncryptionPublicKey.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.Equals(DodoSSH.Contracts.KeyLogRecord? other) -> bool
|
||||
DodoSSH.Contracts.KeyLogRecord.Generation.get -> int
|
||||
DodoSSH.Contracts.KeyLogRecord.Generation.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.Hash.get -> byte[]!
|
||||
DodoSSH.Contracts.KeyLogRecord.Hash.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.KeyLogRecord(long Sequence, System.Guid UserId, int Generation, byte[]! EncryptionPublicKey, byte[]! SigningPublicKey, byte[]! StatementSignature, byte[]! PreviousHash, byte[]! Hash, System.DateTimeOffset CreatedAt) -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.PreviousHash.get -> byte[]!
|
||||
DodoSSH.Contracts.KeyLogRecord.PreviousHash.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.Sequence.get -> long
|
||||
DodoSSH.Contracts.KeyLogRecord.Sequence.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.SigningPublicKey.get -> byte[]!
|
||||
DodoSSH.Contracts.KeyLogRecord.SigningPublicKey.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.StatementSignature.get -> byte[]!
|
||||
DodoSSH.Contracts.KeyLogRecord.StatementSignature.init -> void
|
||||
DodoSSH.Contracts.KeyLogRecord.UserId.get -> System.Guid
|
||||
DodoSSH.Contracts.KeyLogRecord.UserId.init -> void
|
||||
DodoSSH.Contracts.KeyStatement
|
||||
DodoSSH.Contracts.KeyStatement.<Clone>$() -> DodoSSH.Contracts.KeyStatement!
|
||||
DodoSSH.Contracts.KeyStatement.CreatedAt.get -> System.DateTimeOffset
|
||||
@@ -451,6 +554,96 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
|
||||
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
|
||||
DodoSSH.Contracts.SyncPushResult.Version.get -> int?
|
||||
DodoSSH.Contracts.SyncPushResult.Version.init -> void
|
||||
DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberRole.Owner = 40 -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberRole.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberRole.Viewer = 10 -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberStatus.Active = 2 -> DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberStatus.Invited = 1 -> DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberStatus.Revoked = 3 -> DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberStatus.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberSummary
|
||||
DodoSSH.Contracts.TeamMemberSummary.<Clone>$() -> DodoSSH.Contracts.TeamMemberSummary!
|
||||
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt) -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.DisplayName.get -> string?
|
||||
DodoSSH.Contracts.TeamMemberSummary.DisplayName.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Email.get -> string?
|
||||
DodoSSH.Contracts.TeamMemberSummary.Email.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Equals(DodoSSH.Contracts.TeamMemberSummary? other) -> bool
|
||||
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.get -> bool
|
||||
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.get -> System.DateTimeOffset?
|
||||
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberSummary.Role.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Status.get -> DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberSummary.Status.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt) -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.UserId.get -> System.Guid
|
||||
DodoSSH.Contracts.TeamMemberSummary.UserId.init -> void
|
||||
DodoSSH.Contracts.TeamSummary
|
||||
DodoSSH.Contracts.TeamSummary.<Clone>$() -> DodoSSH.Contracts.TeamSummary!
|
||||
DodoSSH.Contracts.TeamSummary.CreatedAt.get -> System.DateTimeOffset
|
||||
DodoSSH.Contracts.TeamSummary.CreatedAt.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.Deconstruct(out System.Guid TeamId, out string! Name, out string! Slug, out string? Description, out DodoSSH.Contracts.TeamMemberRole Role, out int MemberCount, out int VaultCount, out System.DateTimeOffset CreatedAt) -> void
|
||||
DodoSSH.Contracts.TeamSummary.Description.get -> string?
|
||||
DodoSSH.Contracts.TeamSummary.Description.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.Equals(DodoSSH.Contracts.TeamSummary? other) -> bool
|
||||
DodoSSH.Contracts.TeamSummary.MemberCount.get -> int
|
||||
DodoSSH.Contracts.TeamSummary.MemberCount.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.Name.get -> string!
|
||||
DodoSSH.Contracts.TeamSummary.Name.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamSummary.Role.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.Slug.get -> string!
|
||||
DodoSSH.Contracts.TeamSummary.Slug.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.TeamId.get -> System.Guid
|
||||
DodoSSH.Contracts.TeamSummary.TeamId.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.TeamSummary(System.Guid TeamId, string! Name, string! Slug, string? Description, DodoSSH.Contracts.TeamMemberRole Role, int MemberCount, int VaultCount, System.DateTimeOffset CreatedAt) -> void
|
||||
DodoSSH.Contracts.TeamSummary.VaultCount.get -> int
|
||||
DodoSSH.Contracts.TeamSummary.VaultCount.init -> void
|
||||
DodoSSH.Contracts.VaultGrantsResponse
|
||||
DodoSSH.Contracts.VaultGrantsResponse.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse!
|
||||
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
|
||||
DodoSSH.Contracts.VaultGrantsResponse.Equals(DodoSSH.Contracts.VaultGrantsResponse? other) -> bool
|
||||
DodoSSH.Contracts.VaultGrantsResponse.Grants.get -> System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>!
|
||||
DodoSSH.Contracts.VaultGrantsResponse.Grants.init -> void
|
||||
DodoSSH.Contracts.VaultGrantsResponse.KeyGeneration.get -> uint
|
||||
DodoSSH.Contracts.VaultGrantsResponse.KeyGeneration.init -> void
|
||||
DodoSSH.Contracts.VaultGrantsResponse.RekeyRequired.get -> bool
|
||||
DodoSSH.Contracts.VaultGrantsResponse.RekeyRequired.init -> void
|
||||
DodoSSH.Contracts.VaultGrantsResponse.VaultGrantsResponse(System.Guid VaultId, uint KeyGeneration, bool RekeyRequired, System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
|
||||
DodoSSH.Contracts.VaultGrantsResponse.VaultId.get -> System.Guid
|
||||
DodoSSH.Contracts.VaultGrantsResponse.VaultId.init -> void
|
||||
DodoSSH.Contracts.VaultGrantState
|
||||
DodoSSH.Contracts.VaultGrantState.Active = 1 -> DodoSSH.Contracts.VaultGrantState
|
||||
DodoSSH.Contracts.VaultGrantState.AwaitingRewrap = 2 -> DodoSSH.Contracts.VaultGrantState
|
||||
DodoSSH.Contracts.VaultGrantState.Revoked = 3 -> DodoSSH.Contracts.VaultGrantState
|
||||
DodoSSH.Contracts.VaultGrantState.Unspecified = 0 -> DodoSSH.Contracts.VaultGrantState
|
||||
DodoSSH.Contracts.VaultGrantSummary
|
||||
DodoSSH.Contracts.VaultGrantSummary.<Clone>$() -> DodoSSH.Contracts.VaultGrantSummary!
|
||||
DodoSSH.Contracts.VaultGrantSummary.CreatedAt.get -> System.DateTimeOffset
|
||||
DodoSSH.Contracts.VaultGrantSummary.CreatedAt.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.Deconstruct(out System.Guid RecipientUserId, out string? Email, out string? DisplayName, out uint KeyGeneration, out DodoSSH.Contracts.VaultGrantState State, out System.Guid GranterUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset? RevokedAt) -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.DisplayName.get -> string?
|
||||
DodoSSH.Contracts.VaultGrantSummary.DisplayName.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.Email.get -> string?
|
||||
DodoSSH.Contracts.VaultGrantSummary.Email.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.Equals(DodoSSH.Contracts.VaultGrantSummary? other) -> bool
|
||||
DodoSSH.Contracts.VaultGrantSummary.GranterUserId.get -> System.Guid
|
||||
DodoSSH.Contracts.VaultGrantSummary.GranterUserId.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.KeyGeneration.get -> uint
|
||||
DodoSSH.Contracts.VaultGrantSummary.KeyGeneration.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.RecipientUserId.get -> System.Guid
|
||||
DodoSSH.Contracts.VaultGrantSummary.RecipientUserId.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.RevokedAt.get -> System.DateTimeOffset?
|
||||
DodoSSH.Contracts.VaultGrantSummary.RevokedAt.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.State.get -> DodoSSH.Contracts.VaultGrantState
|
||||
DodoSSH.Contracts.VaultGrantSummary.State.init -> void
|
||||
DodoSSH.Contracts.VaultGrantSummary.VaultGrantSummary(System.Guid RecipientUserId, string? Email, string? DisplayName, uint KeyGeneration, DodoSSH.Contracts.VaultGrantState State, System.Guid GranterUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset? RevokedAt) -> void
|
||||
DodoSSH.Contracts.VaultSummary
|
||||
DodoSSH.Contracts.VaultSummary.<Clone>$() -> DodoSSH.Contracts.VaultSummary!
|
||||
DodoSSH.Contracts.VaultSummary.Deconstruct(out System.Guid VaultId, out string! Name, out bool IsPersonal, out System.Guid? TeamId, out uint KeyGeneration, out int Permissions, out byte[]? WrappedVaultKey, out bool RekeyRequired) -> void
|
||||
@@ -472,6 +665,18 @@ DodoSSH.Contracts.VaultSummary.VaultId.init -> void
|
||||
DodoSSH.Contracts.VaultSummary.VaultSummary(System.Guid VaultId, string! Name, bool IsPersonal, System.Guid? TeamId, uint KeyGeneration, int Permissions, byte[]? WrappedVaultKey, bool RekeyRequired) -> void
|
||||
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.get -> byte[]?
|
||||
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void
|
||||
override DodoSSH.Contracts.AddTeamMemberRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.AddTeamMemberRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.CreateTeamVaultRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.CreateTeamVaultRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.CreateTeamVaultRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.DirectoryEntry.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.DirectoryEntry.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.DirectoryEntry.ToString() -> string!
|
||||
@@ -487,9 +692,18 @@ override DodoSSH.Contracts.EnrollmentRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.EnrollmentResponse.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.EnrollmentResponse.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.EnrollmentResponse.ToString() -> string!
|
||||
override DodoSSH.Contracts.IssueVaultGrantRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.IssueVaultGrantRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.IssueVaultGrantRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.KdfParameters.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.KdfParameters.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.KdfParameters.ToString() -> string!
|
||||
override DodoSSH.Contracts.KeyLogPage.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.KeyLogPage.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.KeyLogPage.ToString() -> string!
|
||||
override DodoSSH.Contracts.KeyLogRecord.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.KeyLogRecord.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.KeyLogRecord.ToString() -> string!
|
||||
override DodoSSH.Contracts.KeyStatement.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.KeyStatement.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.KeyStatement.ToString() -> string!
|
||||
@@ -547,9 +761,29 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
|
||||
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
|
||||
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
|
||||
override DodoSSH.Contracts.TeamSummary.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.TeamSummary.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.TeamSummary.ToString() -> string!
|
||||
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
|
||||
override DodoSSH.Contracts.VaultGrantSummary.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.VaultGrantSummary.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.VaultGrantSummary.ToString() -> string!
|
||||
override DodoSSH.Contracts.VaultSummary.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.VaultSummary.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.VaultSummary.ToString() -> string!
|
||||
static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
|
||||
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
|
||||
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
|
||||
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamVaultRequest.operator ==(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
|
||||
static DodoSSH.Contracts.DirectoryEntry.operator !=(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool
|
||||
static DodoSSH.Contracts.DirectoryEntry.operator ==(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool
|
||||
static DodoSSH.Contracts.DodoSshConfiguration.operator !=(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool
|
||||
@@ -563,8 +797,14 @@ static DodoSSH.Contracts.EnrollmentRequest.operator !=(DodoSSH.Contracts.Enrollm
|
||||
static DodoSSH.Contracts.EnrollmentRequest.operator ==(DodoSSH.Contracts.EnrollmentRequest? left, DodoSSH.Contracts.EnrollmentRequest? right) -> bool
|
||||
static DodoSSH.Contracts.EnrollmentResponse.operator !=(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool
|
||||
static DodoSSH.Contracts.EnrollmentResponse.operator ==(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool
|
||||
static DodoSSH.Contracts.IssueVaultGrantRequest.operator !=(DodoSSH.Contracts.IssueVaultGrantRequest? left, DodoSSH.Contracts.IssueVaultGrantRequest? right) -> bool
|
||||
static DodoSSH.Contracts.IssueVaultGrantRequest.operator ==(DodoSSH.Contracts.IssueVaultGrantRequest? left, DodoSSH.Contracts.IssueVaultGrantRequest? right) -> bool
|
||||
static DodoSSH.Contracts.KdfParameters.operator !=(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool
|
||||
static DodoSSH.Contracts.KdfParameters.operator ==(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool
|
||||
static DodoSSH.Contracts.KeyLogPage.operator !=(DodoSSH.Contracts.KeyLogPage? left, DodoSSH.Contracts.KeyLogPage? right) -> bool
|
||||
static DodoSSH.Contracts.KeyLogPage.operator ==(DodoSSH.Contracts.KeyLogPage? left, DodoSSH.Contracts.KeyLogPage? right) -> bool
|
||||
static DodoSSH.Contracts.KeyLogRecord.operator !=(DodoSSH.Contracts.KeyLogRecord? left, DodoSSH.Contracts.KeyLogRecord? right) -> bool
|
||||
static DodoSSH.Contracts.KeyLogRecord.operator ==(DodoSSH.Contracts.KeyLogRecord? left, DodoSSH.Contracts.KeyLogRecord? right) -> bool
|
||||
static DodoSSH.Contracts.KeyStatement.operator !=(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool
|
||||
static DodoSSH.Contracts.KeyStatement.operator ==(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool
|
||||
static DodoSSH.Contracts.MeResponse.operator !=(DodoSSH.Contracts.MeResponse? left, DodoSSH.Contracts.MeResponse? right) -> bool
|
||||
@@ -603,5 +843,13 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
|
||||
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
|
||||
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
|
||||
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
|
||||
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamSummary.operator ==(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
|
||||
static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
|
||||
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
|
||||
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
|
||||
static DodoSSH.Contracts.VaultGrantSummary.operator ==(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
|
||||
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
||||
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
namespace DodoSSH.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A member's role within a team, as it travels on the wire.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A separate type from <c>DodoSSH.Domain.TeamRole</c> only because both are visible inside the
|
||||
/// server, exactly as <c>GrantPurpose</c> is separate from <c>GrantKind</c>. The <b>numeric values
|
||||
/// must match</b> that enum, and a test pins them: the two are converted by cast, so a renumbering
|
||||
/// here silently promotes or demotes every member on the next deployment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// There is no <c>ConnectOnly</c> role, and there will not be one built this way. Connect is a
|
||||
/// user-interface hint rather than a boundary — SSH terminates on the client, so opening a session
|
||||
/// needs the credential's plaintext on that machine, and "may connect but may not read the key" is
|
||||
/// unenforceable in this architecture. See <c>docs/adr/0001-e2ee-trust-model.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum TeamMemberRole
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>May read the team's vaults and nothing else.</summary>
|
||||
Viewer = 10,
|
||||
|
||||
/// <summary>May read and change the team's vaults.</summary>
|
||||
Member = 20,
|
||||
|
||||
/// <summary>May also manage members, create vaults, and share vault keys.</summary>
|
||||
Admin = 30,
|
||||
|
||||
/// <summary>Sole owner. Everything an admin may do, and cannot be removed while sole.</summary>
|
||||
Owner = 40,
|
||||
}
|
||||
|
||||
/// <summary>State of a team membership, as it travels on the wire.</summary>
|
||||
/// <remarks>
|
||||
/// Values match <c>DodoSSH.Domain.MembershipStatus</c>, for the reason
|
||||
/// <see cref="TeamMemberRole"/> gives.
|
||||
/// </remarks>
|
||||
public enum TeamMemberStatus
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Invited but not yet accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing writes this today. An invitation needs a token with a lifetime and an outbound mail
|
||||
/// path, and this server has neither — so a member is added by looking their account up in the
|
||||
/// directory, which requires that they have signed in here at least once. Retained because the
|
||||
/// column exists and a client must not fail on a value a later server may send.
|
||||
/// </remarks>
|
||||
Invited = 1,
|
||||
|
||||
/// <summary>Active member.</summary>
|
||||
Active = 2,
|
||||
|
||||
/// <summary>Removed. Retained so audit history stays resolvable to a person.</summary>
|
||||
Revoked = 3,
|
||||
}
|
||||
|
||||
/// <summary>State of a vault key grant, as it travels on the wire.</summary>
|
||||
/// <remarks>Values match <c>DodoSSH.Domain.GrantState</c>.</remarks>
|
||||
public enum VaultGrantState
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>Usable.</summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The recipient's identity key changed or the vault was rekeyed, so a member holding Share
|
||||
/// must wrap the key afresh before the recipient can read anything again.
|
||||
/// </summary>
|
||||
AwaitingRewrap = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Revoked. Blocks future reads only — anything already downloaded is already gone, and the
|
||||
/// remediation for a departed member is rotating the SSH credential itself. See ADR 0001.
|
||||
/// </summary>
|
||||
Revoked = 3,
|
||||
}
|
||||
|
||||
/// <summary>A team the caller belongs to.</summary>
|
||||
/// <param name="TeamId">The team.</param>
|
||||
/// <param name="Name">Display name.</param>
|
||||
/// <param name="Slug">URL-safe unique identifier.</param>
|
||||
/// <param name="Description">Optional description.</param>
|
||||
/// <param name="Role">The caller's own role.</param>
|
||||
/// <param name="MemberCount">Active members, including the caller.</param>
|
||||
/// <param name="VaultCount">Vaults the team owns.</param>
|
||||
/// <param name="CreatedAt">When the team was created.</param>
|
||||
public sealed record TeamSummary(
|
||||
Guid TeamId,
|
||||
string Name,
|
||||
string Slug,
|
||||
string? Description,
|
||||
TeamMemberRole Role,
|
||||
int MemberCount,
|
||||
int VaultCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>A request to create a team.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="TeamId"/> is chosen by the client for the same reason a vault id is: a request whose
|
||||
/// response was lost can be re-sent verbatim and returns the identical team rather than creating a
|
||||
/// second one under a name the user only meant to type once.
|
||||
/// </remarks>
|
||||
/// <param name="TeamId">Client-generated UUIDv7.</param>
|
||||
/// <param name="Name">Display name.</param>
|
||||
/// <param name="Slug">
|
||||
/// URL-safe unique identifier, lowercase. Unique across the deployment, so this is the one field a
|
||||
/// create can fail on for a reason the caller cannot see coming.
|
||||
/// </param>
|
||||
/// <param name="Description">Optional description.</param>
|
||||
public sealed record CreateTeamRequest(
|
||||
Guid TeamId,
|
||||
string Name,
|
||||
string Slug,
|
||||
string? Description);
|
||||
|
||||
/// <summary>One member of a team.</summary>
|
||||
/// <remarks>
|
||||
/// Carries no last-active time and no avatar. <c>UserAccount.LastSeenAtUtc</c> is written at
|
||||
/// provisioning and at enrollment and at no other point, so a column labelled "last active" would
|
||||
/// be reporting something else entirely; and no picture is stored anywhere.
|
||||
/// </remarks>
|
||||
/// <param name="UserId">The member.</param>
|
||||
/// <param name="Email">Email, for display.</param>
|
||||
/// <param name="DisplayName">Display name.</param>
|
||||
/// <param name="Role">Role within the team.</param>
|
||||
/// <param name="Status">Membership state.</param>
|
||||
/// <param name="IsEnrolled">
|
||||
/// Whether this member has published an identity key. A member who has not cannot be granted a
|
||||
/// vault key at all — there is nothing to wrap one to — so the interface has to be able to say so
|
||||
/// rather than offering a share that would fail.
|
||||
/// </param>
|
||||
/// <param name="JoinedAt">When the membership became active.</param>
|
||||
public sealed record TeamMemberSummary(
|
||||
Guid UserId,
|
||||
string? Email,
|
||||
string? DisplayName,
|
||||
TeamMemberRole Role,
|
||||
TeamMemberStatus Status,
|
||||
bool IsEnrolled,
|
||||
DateTimeOffset? JoinedAt);
|
||||
|
||||
/// <summary>Adds a member to a team.</summary>
|
||||
/// <remarks>
|
||||
/// By user id rather than by email, and the id comes from a directory lookup the caller has already
|
||||
/// made. That ordering is not incidental: whoever adds a member is usually about to wrap a vault key
|
||||
/// to their public key, and the key they must verify is the one the directory returned. Adding by
|
||||
/// email here would put an account resolution the client never saw between those two steps.
|
||||
/// </remarks>
|
||||
/// <param name="UserId">The account to add, as returned by the directory.</param>
|
||||
/// <param name="Role">Role to grant.</param>
|
||||
public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
/// <param name="Role">The new role.</param>
|
||||
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a vault owned by a team, with its key already wrapped to the creator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shaped like <see cref="PersonalVaultRequest"/> and for the same reasons: the vault key is
|
||||
/// generated on the client and sealed to the creator's own X25519 key, so the server cannot produce
|
||||
/// this and cannot check that <see cref="WrappedVaultKey"/> contains anything in particular. A vault
|
||||
/// created with no grant would be a container nobody could ever open, so the two arrive together.
|
||||
/// <para>
|
||||
/// The creator's grant carries no key log head, exactly as a personal vault's does not: there is no
|
||||
/// third party whose key could have been substituted. Every <em>other</em> member's grant does carry
|
||||
/// one — see <see cref="IssueVaultGrantRequest"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="VaultId">Client-generated UUIDv7.</param>
|
||||
/// <param name="Name">Display name. Plaintext, as all vault names are.</param>
|
||||
/// <param name="WrappedVaultKey">The vault key sealed to the creator's encryption key.</param>
|
||||
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
|
||||
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
|
||||
public sealed record CreateTeamVaultRequest(
|
||||
Guid VaultId,
|
||||
string Name,
|
||||
byte[] WrappedVaultKey,
|
||||
byte[] GrantSignature,
|
||||
DateTimeOffset GrantedAt);
|
||||
|
||||
/// <summary>Issues a vault key grant to another member.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The wrap is made by a client that holds the vault key, to a public key it has verified. The
|
||||
/// server stores both the ciphertext and the signature and can check neither — which is the property
|
||||
/// that makes it a zero-knowledge server rather than a key-holding one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="KeyLogHead"/> is required here and absent for a self-grant. A third party's key could
|
||||
/// have been substituted by the server; recording the log head the granter observed while wrapping
|
||||
/// is what converts that from an undetectable attack into a detectable one. See docs/crypto.md §7.2.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="RecipientUserId">Who the key was wrapped to.</param>
|
||||
/// <param name="RecipientKeyFingerprint">
|
||||
/// The exact identity key it was wrapped to. Stored so a later rotation invalidates this grant
|
||||
/// explicitly rather than leaving a row that no longer opens.
|
||||
/// </param>
|
||||
/// <param name="KeyGeneration">
|
||||
/// The generation wrapped. Rejected when it is not the vault's current one, because a grant for a
|
||||
/// superseded generation opens nothing and would read as corruption at the far end.
|
||||
/// </param>
|
||||
/// <param name="WrappedVaultKey">The vault key sealed to the recipient. Opaque to the server.</param>
|
||||
/// <param name="KeyLogHead">The key log head the granter observed while wrapping.</param>
|
||||
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
|
||||
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
|
||||
public sealed record IssueVaultGrantRequest(
|
||||
Guid RecipientUserId,
|
||||
byte[] RecipientKeyFingerprint,
|
||||
uint KeyGeneration,
|
||||
byte[] WrappedVaultKey,
|
||||
byte[] KeyLogHead,
|
||||
byte[] GrantSignature,
|
||||
DateTimeOffset GrantedAt);
|
||||
|
||||
/// <summary>One vault key grant, as the sharing interface sees it.</summary>
|
||||
/// <remarks>
|
||||
/// The wrapped key itself is deliberately not here. A member reads their own through
|
||||
/// <see cref="VaultSummary.WrappedVaultKey"/>; this listing exists so somebody holding Share can see
|
||||
/// <em>who has one</em>, and serving every member's sealed key to every member would be a pointless
|
||||
/// widening of what a stolen access token yields.
|
||||
/// </remarks>
|
||||
/// <param name="RecipientUserId">Who holds it.</param>
|
||||
/// <param name="Email">Their email, for display.</param>
|
||||
/// <param name="DisplayName">Their display name.</param>
|
||||
/// <param name="KeyGeneration">Generation this grant is for.</param>
|
||||
/// <param name="State">Grant state.</param>
|
||||
/// <param name="GranterUserId">Who issued it.</param>
|
||||
/// <param name="CreatedAt">When it was issued.</param>
|
||||
/// <param name="RevokedAt">When it was revoked, if it was.</param>
|
||||
public sealed record VaultGrantSummary(
|
||||
Guid RecipientUserId,
|
||||
string? Email,
|
||||
string? DisplayName,
|
||||
uint KeyGeneration,
|
||||
VaultGrantState State,
|
||||
Guid GranterUserId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? RevokedAt);
|
||||
|
||||
/// <summary>Who can open a vault, and at which generation.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="KeyGeneration">
|
||||
/// The vault's current generation. A grant listed at anything lower is stale, which is what a client
|
||||
/// compares against rather than inferring from <see cref="VaultGrantSummary.State"/> alone.
|
||||
/// </param>
|
||||
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
|
||||
/// <param name="Grants">Every grant, including revoked ones.</param>
|
||||
public sealed record VaultGrantsResponse(
|
||||
Guid VaultId,
|
||||
uint KeyGeneration,
|
||||
bool RekeyRequired,
|
||||
IReadOnlyList<VaultGrantSummary> Grants);
|
||||
Reference in New Issue
Block a user