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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user