Public Access
Keep host key trust in the vault, and make it withdrawable
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.
The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.
One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.
The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.
The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.
That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.
Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.
ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.
Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.
The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.
Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.
It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.
Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.
Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.
The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.
845 tests green. Zero warnings, dotnet format clean.
Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
This commit is contained in:
@@ -14,6 +14,14 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||
<!--
|
||||
The SSH layer, for one type: VaultKnownHostStore, which is the IKnownHostStore the application
|
||||
actually composes. It has to live on this side of the seam, because it is the only thing that needs
|
||||
both the interface the handshake asks and the vault the answer comes from — and it must not live in
|
||||
Client.Ssh, which has no project references at all so that connections, authentication and PTY
|
||||
handling stay testable with no cache, no keyring and no server.
|
||||
-->
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Host key trust kept in the vault, answered from memory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a snapshot and not a lookup.</b> <see cref="FindAsync"/> is called from inside SSH.NET's
|
||||
/// synchronous host key event, which the connection factory has no choice but to block on — see
|
||||
/// <c>SshNetConnectionFactory</c>, where the comment explains why that cannot be avoided. A store that read
|
||||
/// SQLite and ran an AEAD open per lookup would put a disk round trip and a decryption on the thread
|
||||
/// completing the key exchange, once per host key offered, on every connection. So the vault is read when it
|
||||
/// unlocks and after each synchronisation pass, and the handshake gets a dictionary lookup.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What that costs, stated rather than hidden.</b> Trust recorded on another machine is not visible until
|
||||
/// the next pass brings it down, which is within the minute the shell already syncs on. The failure that
|
||||
/// causes is a first-contact prompt for a host somebody else approved seconds ago — a prompt the user can
|
||||
/// answer correctly, since the fingerprint is on screen — and it resolves itself. The reverse mistake would
|
||||
/// be the serious one, and it cannot happen here: a pin recorded on this machine goes into the snapshot as
|
||||
/// part of recording it, and invalidates any read that was already in flight.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Process-lifetime object, session-scoped contents.</b> The connection factory is composed once, at
|
||||
/// startup, and outlives every unlock; the vault behind this store does not. So the lifecycle is explicit:
|
||||
/// <see cref="OpenAsync"/> when a vault unlocks, <see cref="RefreshAsync"/> after a pass,
|
||||
/// <see cref="Close"/> when it locks. While closed every lookup answers "not pinned", which refuses
|
||||
/// connections rather than allowing them — the safe direction for the one case that can reach it, a vault
|
||||
/// locked while a handshake was in flight.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultKnownHostStore : IKnownHostStore
|
||||
{
|
||||
private readonly Lock gate = new();
|
||||
|
||||
private Dictionary<string, PinnedHostKey> pins = new(KnownHostIdentity.Comparer);
|
||||
private Binding? binding;
|
||||
|
||||
/// <summary>
|
||||
/// Bumped whenever the snapshot changes underneath a read, so a read in flight cannot install a stale
|
||||
/// answer over a newer one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The races are ordinary rather than theoretical. A background pass ends with a refresh, and between
|
||||
/// that refresh's listing and its assignment the user may lock the vault, approve a new host key, or
|
||||
/// withdraw trust from one — and every one of those would otherwise be undone a moment later by the
|
||||
/// arriving snapshot.
|
||||
/// </remarks>
|
||||
private int generation;
|
||||
|
||||
/// <summary>Whether a vault is open behind this store.</summary>
|
||||
public bool IsOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return binding is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads an unlocked vault's pins, and starts writing new ones to it.</summary>
|
||||
/// <param name="session">The unlocked session. Its active vault is the one used.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <remarks>
|
||||
/// The previous session's pins are dropped before the new vault is read, not after. Between the two
|
||||
/// every host looks unvisited, which is one listing long and errs towards asking; keeping them would
|
||||
/// mean one account's trust decisions briefly answering for another's.
|
||||
/// </remarks>
|
||||
public async Task OpenAsync(VaultSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
var opened = new Binding(session.KnownHosts, session.ActiveVaultId);
|
||||
int stamp;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
binding = opened;
|
||||
pins = new Dictionary<string, PinnedHostKey>(KnownHostIdentity.Comparer);
|
||||
stamp = ++generation;
|
||||
}
|
||||
|
||||
var snapshot = await ReadAsync(opened, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Install(stamp, snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the vault, picking up trust recorded on another machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Does nothing while closed, so a synchronisation pass that finishes after the vault was locked cannot
|
||||
/// bring its contents back.
|
||||
/// </remarks>
|
||||
public async Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Binding? current;
|
||||
int stamp;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
current = binding;
|
||||
stamp = generation;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = await ReadAsync(current, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Install(stamp, snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the vault and everything read from it. What locking means here.</summary>
|
||||
public void Close()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
binding = null;
|
||||
pins = new Dictionary<string, PinnedHostKey>(KnownHostIdentity.Comparer);
|
||||
generation++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<string?> FindAsync(
|
||||
string host,
|
||||
int port,
|
||||
string algorithm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var identity = KnownHostIdentity.For(host, port, algorithm);
|
||||
|
||||
return ValueTask.FromResult(pins.GetValueOrDefault(identity)?.Secret.Fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The vault is not open, so there is nowhere to record trust.
|
||||
/// </exception>
|
||||
public async ValueTask TrustAsync(
|
||||
HostKeyPresentation presentation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(presentation);
|
||||
|
||||
var bound = Require();
|
||||
|
||||
var identity = KnownHostIdentity.For(
|
||||
presentation.Host, presentation.Port, presentation.Algorithm);
|
||||
|
||||
var existing = Pinned(identity);
|
||||
|
||||
if (existing is not null
|
||||
&& SshHostKeyFingerprint.Equal(existing.Secret.Fingerprint, presentation.Fingerprint))
|
||||
{
|
||||
// Already trusted, to the byte. Writing it again would queue an outbox operation that changes
|
||||
// nothing and push it to every other machine as a modification.
|
||||
return;
|
||||
}
|
||||
|
||||
var pin = new KnownHostSecret
|
||||
{
|
||||
Host = presentation.Host,
|
||||
Port = presentation.Port,
|
||||
Algorithm = presentation.Algorithm,
|
||||
Fingerprint = presentation.Fingerprint,
|
||||
};
|
||||
|
||||
var entityId = await StoreAsync(bound, existing, pin, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (!ReferenceEquals(binding, bound))
|
||||
{
|
||||
// The vault was locked, or another one was opened, while this was being written. The item is
|
||||
// in that vault's outbox and will be there when it is next opened; it must not answer for
|
||||
// whatever is open now.
|
||||
return;
|
||||
}
|
||||
|
||||
pins[identity] = new PinnedHostKey(entityId, pin);
|
||||
generation++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The vault is not open, so there is nothing to forget.
|
||||
/// </exception>
|
||||
public async ValueTask<int> ForgetAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bound = Require();
|
||||
|
||||
// Read from the vault rather than from the snapshot, and this is the one operation that must:
|
||||
// withdrawing trust has to reach every pin for the endpoint, including a duplicate the snapshot
|
||||
// shadowed. A pin left behind is a host that keeps refusing to connect for a reason the user
|
||||
// believes they have already dealt with.
|
||||
var listing = await bound.KnownHosts
|
||||
.ListAsync(bound.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var doomed = listing.Items
|
||||
.Where(item =>
|
||||
KnownHostIdentity.Comparer.Equals(item.Secret.Host, host) && item.Secret.Port == port)
|
||||
.ToArray();
|
||||
|
||||
foreach (var item in doomed)
|
||||
{
|
||||
await bound.KnownHosts
|
||||
.DeleteAsync(bound.VaultId, item.EntityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
|
||||
// Re-read rather than patched. The next listing is what these deletions mean, and reproducing that
|
||||
// arithmetic against the snapshot is how the two would come to disagree.
|
||||
await RefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return doomed.Length;
|
||||
}
|
||||
|
||||
/// <summary>The vault this store writes to, and which vault inside it.</summary>
|
||||
private sealed record Binding(KnownHostRepository KnownHosts, Guid VaultId);
|
||||
|
||||
/// <summary>A pin, and the vault item it came from, so re-trusting updates rather than duplicates.</summary>
|
||||
/// <param name="EntityId">The item holding this pin.</param>
|
||||
/// <param name="Secret">The pin.</param>
|
||||
private sealed record PinnedHostKey(Guid EntityId, KnownHostSecret Secret)
|
||||
{
|
||||
/// <summary>Whether this build may re-encode the item, or a newer one wrote it.</summary>
|
||||
internal bool IsWritable { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a pin, updating the item that already held one for this endpoint where there is one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An item a newer client wrote is left alone and a fresh one is created beside it. Re-encoding it would
|
||||
/// drop fields this build has no concept of, which is the rule the whole client follows for read-only
|
||||
/// items — and refusing outright, as the editors do, would leave the user unable to connect to a rebuilt
|
||||
/// server at all. The new item wins the lookup by the tie-break in <see cref="ReadAsync"/>, and a client
|
||||
/// that understands both can reconcile them.
|
||||
/// </remarks>
|
||||
private static async Task<Guid> StoreAsync(
|
||||
Binding bound,
|
||||
PinnedHostKey? existing,
|
||||
KnownHostSecret pin,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (existing is { IsWritable: true } writable)
|
||||
{
|
||||
await bound.KnownHosts
|
||||
.UpdateAsync(bound.VaultId, writable.EntityId, pin, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return writable.EntityId;
|
||||
}
|
||||
|
||||
return await bound.KnownHosts
|
||||
.CreateAsync(bound.VaultId, pin, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every readable pin in the vault into a lookup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two items can name the same endpoint and algorithm: two machines that first met a host while unable
|
||||
/// to reach each other each minted one. Where they agree — the ordinary case, since they saw the same
|
||||
/// server — the duplicate is invisible. Where they do not, the later item wins, ordered by an id that is
|
||||
/// a UUIDv7 and therefore by when the trust was recorded. Any total order would do for correctness; what
|
||||
/// matters is that every machine picks the same one, and that the wrong choice is recoverable rather than
|
||||
/// permanent, which <see cref="ForgetAsync"/> makes it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A pin that will not decrypt is skipped, and its endpoint then looks unvisited. That is the safe
|
||||
/// reading: an unreadable pin cannot be compared against anything, so the only honest answers are "ask
|
||||
/// the user" and "refuse", and asking is the one that leaves them a way forward. The count is not lost —
|
||||
/// the vault view reports undecryptable items of every kind.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<Dictionary<string, PinnedHostKey>> ReadAsync(
|
||||
Binding bound,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await bound.KnownHosts
|
||||
.ListAsync(bound.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var snapshot = new Dictionary<string, PinnedHostKey>(KnownHostIdentity.Comparer);
|
||||
|
||||
foreach (var item in listing.Items.OrderBy(item => item.EntityId))
|
||||
{
|
||||
var identity = KnownHostIdentity.For(
|
||||
item.Secret.Host, item.Secret.Port, item.Secret.Algorithm);
|
||||
|
||||
snapshot[identity] = new PinnedHostKey(item.EntityId, item.Secret)
|
||||
{
|
||||
IsWritable = !item.IsReadOnly,
|
||||
};
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Replaces the snapshot wholesale rather than merging into it, which is what makes a pin withdrawn on
|
||||
/// another machine actually disappear here. Anything recorded on this machine while the read was in
|
||||
/// flight has already bumped the generation, so it is this snapshot that gets dropped and not that pin.
|
||||
/// </remarks>
|
||||
private void Install(int stamp, Dictionary<string, PinnedHostKey> snapshot)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (stamp == generation)
|
||||
{
|
||||
pins = snapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Invalidate()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
generation++;
|
||||
}
|
||||
}
|
||||
|
||||
private PinnedHostKey? Pinned(string identity)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return pins.GetValueOrDefault(identity);
|
||||
}
|
||||
}
|
||||
|
||||
private Binding Require()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return binding
|
||||
?? throw new InvalidOperationException(
|
||||
"The vault is locked, so host key trust cannot be changed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
Hosts = new HostRepository(Items, Outbox, keyring);
|
||||
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
|
||||
Credentials = new CredentialRepository(Items, Outbox, keyring);
|
||||
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
|
||||
}
|
||||
|
||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||
@@ -101,6 +102,14 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
/// <summary>Usernames and passwords, decrypted, with unpushed local changes laid over them.</summary>
|
||||
public CredentialRepository Credentials { get; }
|
||||
|
||||
/// <summary>The host keys this vault trusts, decrypted, with unpushed local changes laid over them.</summary>
|
||||
/// <remarks>
|
||||
/// Read through <see cref="VaultKnownHostStore"/> rather than directly by anything that connects. The
|
||||
/// handshake asks about host key trust from inside a synchronous SSH.NET event, and listing decrypts every
|
||||
/// pin in the vault — see that type for why the two must not meet.
|
||||
/// </remarks>
|
||||
public KnownHostRepository KnownHosts { get; }
|
||||
|
||||
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
|
||||
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -166,6 +172,12 @@
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.1, )",
|
||||
@@ -261,6 +273,16 @@
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.12"
|
||||
}
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user