Share a vault with a team, without the server holding a key

M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the
append-only key log served for clients to check it against, team-owned vaults,
and vault key grants wrapped by a client and stored opaquely by the server.
VaultAccessService resolves team membership to PermissionFlags, so a viewer may
pull and may not push; the desktop client reads and syncs every vault it holds
a key for, and a real TEAMS screen replaces the one that said it did not exist.
No migration: team, team_membership, vault.team_id and vault_key_grant have all
been there since the first one, which is what carrying two unused tables bought.

Membership is authorisation. A grant is access. The obvious model is one
concept — "access", with a role attached, handed out by the server — and this
architecture cannot implement it: a vault key is sealed to each member's X25519
key, and only a client holding the plaintext can seal it for somebody else. So
"give Bob access" decomposes into a database write and a wrap, which happen on
different machines. Adding a member makes the server serve them the vault; it
cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime
and the vault appears in their list saying it is waiting for a key, because
hiding it until a grant existed would have been tidier and would have implied
the server was the thing granting access. The screen says the same thing after
every add, in the status line. ADR 0009 records the whole decision.

Sharing verifies or refuses. A directory lookup is a claim by the server about
a third party's public key, and wrapping to an unverified claim hands the vault
to whoever made it — no amount of transport security helps, because the server
is inside the threat model. KeyLogAudit reads the whole log, recomputes every
entry's hash from its own contents, checks the chain from genesis, and refuses
unless the offered key appears in it unchanged. There is no override flag: one
that exists gets used on the day the log is briefly unreachable, and the
resulting grant is indistinguishable from a correct one afterwards. What it
still cannot promise is that the key is the right person's, so the fingerprint
comes back for an out-of-band comparison and the success message says so every
time. A test corrupts the fake server's log by one byte and watches the client
refuse rather than warn.

The roles are only the ones that are enforceable. There is no ConnectOnly,
despite the design asking for one and TeamRole having room: SSH terminates on
the client, so a session needs the credential's plaintext on that machine, and
"may connect but may not read the key" cannot be enforced here. Shipping it as
an option in a dropdown would have been a lie. Connect rides along with Read
and is documented as an interface hint. Removal is named for what it does — it
revokes grants and flags the vault for rekey, and claims nothing about what is
already on somebody's laptop.

Three things are deliberately absent, and each is a refusal rather than an
omission. The rekey itself, because re-wrapping every item's data key under a
new vault key needs a client holding the current one; the server records that a
rotation is owed and the interface reports it, which is more honest than a
button that only appears to do it. Ownership transfer, because allowing an
owner to be removed without one leaves a team nobody can administer. And
cross-vault host key trust: a pin in a team vault is listed but not consulted
at connect time, because any member with Write could otherwise pre-approve a
fingerprint another member's client then trusts silently for a host in their
own vault. Scoping trust properly needs a scope on the SSH connect path, which
IKnownHostStore has not got; until then the narrow direction is the safe one
and the cost is in the README rather than hidden.

Reading now spans vaults and writing still does not. Every list on the vault
and hosts screens covers each vault the keyring opened, rows carry the vault
they came from, and an edit goes back to that vault rather than to the active
one — writing it to the active vault would fork the item and only show up when
a colleague wondered why their change never arrived. A new item goes wherever a
picker says, defaulting to the personal vault and never moving on its own,
because an item filed into a team's vault is visible to that team and moving it
back means deleting and retyping. The sidebar heading stops naming one vault
once there are two, and each row names its own.

The server checks what it can and nothing it cannot. It will not record a grant
for a key its recipient no longer holds, for a superseded generation, or for
somebody who is not in the team — each of those would otherwise surface days
later at the far end as a tag failure indistinguishable from corruption. It
does not verify the wrap or the signature, and the grant service says so: that
would be a convenience and never the boundary, and would put an asymmetric
implementation on a machine that is supposed to hold no keys.

Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so
a team created a moment earlier was missing from the list it had just been
added to. And syncing every vault turned a failure from an exception into a
report, which made a background pass announce an unreachable vault once a
minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone
exists to prevent. The fact is recorded and the message swallowed, as it was
before; pressing Sync still names the vault and the reason.

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
This commit is contained in:
2026-07-31 12:18:28 +02:00
parent d1700f5a34
commit 95816de0c5
45 changed files with 6699 additions and 133 deletions
+306 -1
View File
@@ -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>&amp;</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);
+313
View File
@@ -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);