Public Access
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.
Client, in Client.Session:
- SpelunkTunnel implements Spelunk's contract: POST
{api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
and {"port":N}, then a WebSocket to the API's own origin at the path
Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
could not reach the port, or is too old: "Unknown stream kind") and 4503
(agent disconnected). Only a path is taken from the ticket response, so it
cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
so the connect path has one shape for both.
Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.
The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.
Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.
UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.
dodossh://spelunk/connect?api=&server=&name=&host= links:
- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
running passes it down the doorbell as "dodossh.open-link <link>", which
the running instance parses again, bounded, and otherwise ignores whole.
The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
otherwise the editor opens on a new, unsaved Spelunk host, with the key
pre-chosen only when another host already sends it to that Spelunk. A link
that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
hooks and removes it on uninstall only if it still points at this
executable; the macOS plist template declares CFBundleURLTypes and the app
takes the URL from Avalonia's activation event; the Linux desktop entry
gets %u and MimeType=x-scheme-handler/dodossh.
579 lines
24 KiB
C#
579 lines
24 KiB
C#
using System.Text;
|
|
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
|
|
|
namespace DodoSSH.Client.Domain.Tests;
|
|
|
|
/// <summary>
|
|
/// The payload encoding.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Two properties carry weight here. Determinism, because the sync engine compares to decide whether
|
|
/// to push, and a codec that produced different bytes for the same host would make every pass look
|
|
/// like a change. And failing closed on anything malformed, because these bytes are decrypted inside
|
|
/// a sync pass where an exception would strand every item queued behind the bad one.
|
|
/// </remarks>
|
|
public sealed class HostSecretCodecTests
|
|
{
|
|
/// <remarks>
|
|
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
|
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
|
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
|
|
/// pair of pinned paths and a Spelunk route — which are orthogonal to the binding and are what make this
|
|
/// host reach the highest schema version a valid host can. The route and the relay are exclusive in the
|
|
/// same way the bindings are, so this carries the newer of those two as well; the relay's flag has its
|
|
/// own tests.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void AFullHost_RoundTrips()
|
|
{
|
|
var credentialId = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05");
|
|
|
|
var host = Host(
|
|
label: "prod-db",
|
|
hostname: "db.internal",
|
|
port: 2222,
|
|
username: "deploy",
|
|
notes: "primary replica",
|
|
jumps: [Bastion, Relay],
|
|
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
|
|
credentialId: credentialId,
|
|
groupId: Production,
|
|
tags: [Pci, EuWest],
|
|
pinnedPaths: ["/var/www/app", "/var/log"],
|
|
spelunk: ThroughSpelunk());
|
|
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.Host.ShouldBe(host);
|
|
document.Host.CredentialId.ShouldBe(credentialId);
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
|
document.IsReadOnly.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ARelayedHost_RoundTrips_AtTheVersionItsOtherFieldsNeed()
|
|
{
|
|
// The relay flag predates schema versions, so it costs none; it is here because the full host above
|
|
// now takes the Spelunk route, which the relay excludes.
|
|
var host = Host(relayEnabled: true);
|
|
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.Host.ShouldBe(host);
|
|
document.Host.RelayEnabled.ShouldBeTrue();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
|
}
|
|
|
|
// ---- The schema version is content-dependent ----
|
|
|
|
[Fact]
|
|
public void AHostWithNoKey_IsStillWrittenAtVersionOne()
|
|
{
|
|
// The compatibility rule, and the reason it is worth having. The version is what makes an older
|
|
// client refuse to edit an item, so stamping the newest one on every write would mean upgrading one
|
|
// machine and renaming one host made that host uneditable everywhere else. A host that uses nothing
|
|
// new stays readable and writable by the older build.
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host()), out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostThatBindsAKey_IsWrittenAtTheVersionThatIntroducedIt()
|
|
{
|
|
HostSecretCodec
|
|
.TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.SshKeyIdSchemaVersion);
|
|
document.Host.SshKeyId.ShouldBe(DeployKey);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostThatBindsACredential_IsWrittenAtTheVersionThatIntroducedIt()
|
|
{
|
|
// Each binding earns its own version, so a host using only the older one is not dragged forward onto
|
|
// a version older clients refuse to edit.
|
|
var credentialId = Guid.CreateVersion7();
|
|
|
|
HostSecretCodec
|
|
.TryDecode(HostSecretCodec.Encode(Host(credentialId: credentialId)), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.CredentialIdSchemaVersion);
|
|
document.Host.CredentialId.ShouldBe(credentialId);
|
|
}
|
|
|
|
[Fact]
|
|
public void AKeyBoundHost_IsNotDraggedOntoTheCredentialVersion()
|
|
{
|
|
// The point of the rule. Adding credentials must not make every key-bound host in every vault
|
|
// read-only on a client that understands keys perfectly well.
|
|
HostSecretCodec
|
|
.TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
var version = document.ShouldNotBeNull().SchemaVersion;
|
|
|
|
version.ShouldBe(HostSecretCodec.SshKeyIdSchemaVersion);
|
|
version.ShouldBeLessThan(HostSecretCodec.CredentialIdSchemaVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void AGroupedHost_IsWrittenAtTheVersionThatIntroducedGroups()
|
|
{
|
|
HostSecretCodec
|
|
.TryDecode(HostSecretCodec.Encode(Host(groupId: Production)), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion);
|
|
document.Host.GroupId.ShouldBe(Production);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The case that turned the version rule from a ladder into a maximum, and the one that would have shipped
|
|
/// a real defect. A group is orthogonal to the two authentication bindings — a host may carry a credential
|
|
/// and a group at once — so a <c>switch</c> returning the first match would have answered 3 for this host:
|
|
/// a version that has no concept of the group the same write just stored.
|
|
/// </para>
|
|
/// <para>
|
|
/// What that produces is worse than a wrong number. An older client reads schema 3, concludes the payload
|
|
/// holds nothing it does not understand, offers to edit the host, and drops the group on save — silently,
|
|
/// on every machine that has not been upgraded. Asserted for both bindings, because the ladder's order
|
|
/// meant only one of the two would have been caught by a single case.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void AHostThatIsBothBoundAndGrouped_IsWrittenAtTheHigherOfTheTwo(bool byCredential)
|
|
{
|
|
var host = byCredential
|
|
? Host(credentialId: Guid.CreateVersion7(), groupId: Production)
|
|
: Host(sshKeyId: DeployKey, groupId: Production);
|
|
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion);
|
|
document.Host.ShouldBe(host);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostThatInheritsItsPort_IsWrittenAtTheVersionThatIntroducedInheritance()
|
|
{
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(
|
|
HostSecretCodec.PortInheritSchemaVersion,
|
|
"a host stamped at 4 with its port omitted is not read-only on an older build — it is "
|
|
+ "undecodable there, because int Port reads 0 and TryValidate refuses it");
|
|
|
|
document.Host.Port.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostPinnedToATypedPassword_IsWrittenAtTheSameVersionAsAnInheritedPort()
|
|
{
|
|
// Both halves of inheritance share a version because they arrive together and answer the same
|
|
// question: what a host says when it declines to take its group's answer.
|
|
HostSecretCodec
|
|
.TryDecode(HostSecretCodec.Encode(Host(asksForPassword: true)), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.PortInheritSchemaVersion);
|
|
document.Host.AsksForPassword.ShouldBe(true);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostWearingTags_IsWrittenAtTheVersionThatIntroducedThem()
|
|
{
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.TagIdsSchemaVersion);
|
|
document.Host.TagIds.ShouldBe(TagSet.Create([Pci]));
|
|
}
|
|
|
|
[Fact]
|
|
public void ATaggedHostThatPinsItsPort_IsNotDraggedOntoTheInheritanceVersionOrBelowIt()
|
|
{
|
|
// The maximum, from both directions. Tags are independent of inheritance, so a host that only wears
|
|
// one must not be written at 5 — and a host that only inherits must not be written at 6, which would
|
|
// make it undecodable on a build that could have read it.
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var tagged)
|
|
.ShouldBeTrue();
|
|
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var inheriting)
|
|
.ShouldBeTrue();
|
|
|
|
tagged.ShouldNotBeNull().SchemaVersion
|
|
.ShouldBeGreaterThan(HostSecretCodec.PortInheritSchemaVersion);
|
|
|
|
inheriting.ShouldNotBeNull().SchemaVersion
|
|
.ShouldBeLessThan(HostSecretCodec.TagIdsSchemaVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostWithAnEmptyTagSet_IsWrittenAtTheVersionItWouldHaveHadWithout()
|
|
{
|
|
// Wearing no tags is not using the feature. If an empty set bumped the version, adding tags would
|
|
// have made every host in every vault read-only on every machine that had not upgraded.
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [])), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostPinningPaths_IsWrittenAtTheVersionThatIntroducedThem()
|
|
{
|
|
HostSecretCodec
|
|
.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: ["/var/www/app"])), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
|
|
document.Host.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app"]));
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostWithNoPinnedPaths_IsWrittenAtTheVersionItWouldHaveHadWithout()
|
|
{
|
|
// Pinning nothing is not using the feature, for the same reason an empty tag set is not: bumping
|
|
// the version for it would have made every host in every vault read-only on every machine that had
|
|
// not upgraded yet.
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: [])), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void ATaggedHostThatAlsoPinsPaths_IsWrittenAtTheHigherOfTheTwoVersions()
|
|
{
|
|
// Independent fields, so the version is a maximum over both rather than whichever this switch
|
|
// happened to check last — the same defect the group-and-binding case guards against above.
|
|
HostSecretCodec
|
|
.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci], pinnedPaths: ["/var/www/app"])), out var document)
|
|
.ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostPinningPaths_WritesThemLastAndInListOrder()
|
|
{
|
|
// Last, so every field that existed before them keeps its bytes; in list order rather than sorted,
|
|
// unlike tags — order is the meaning of a pinned-path list, so canonicalising it away would lose
|
|
// exactly what the feature is for.
|
|
var bytes = HostSecretCodec.Encode(
|
|
Host(username: null, notes: null, pinnedPaths: ["/var/www/app", "/var/log"]));
|
|
|
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
|
"""
|
|
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"pinnedPaths":["/var/www/app","/var/log"]}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void APayloadWithoutPinnedPaths_DecodesAsPinningNothing()
|
|
{
|
|
// The compatibility case this field exists to pass: a payload written before pinned paths existed
|
|
// must read cleanly as a host that pins nothing, not fail to decode.
|
|
var payload = Encoding.UTF8.GetBytes(
|
|
"""
|
|
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08"]}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull().Host.PinnedPaths.ShouldBe(PinnedPathList.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public void APayloadRepeatingAPinnedPath_DecodesKeepingTheFirstOccurrence()
|
|
{
|
|
// Written by some other client, and it must not compare unequal to the same list written once —
|
|
// or the engine would push this host as changed on every pass for ever.
|
|
var payload = Encoding.UTF8.GetBytes(
|
|
"""
|
|
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/www/app","/var/log","/var/www/app"]}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull().Host.PinnedPaths
|
|
.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
|
|
}
|
|
|
|
[Fact]
|
|
public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither()
|
|
{
|
|
// The companion to the pin below, and the one that matters most for these two fields: a set that
|
|
// serialised as [] and a flag that serialised as false would both land in every host in every vault,
|
|
// and the first sync after the upgrade would push all of them as changed.
|
|
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, tags: []));
|
|
|
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
|
"""
|
|
{"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostThatInheritsItsPort_OmitsTheKeyRatherThanWritingANull()
|
|
{
|
|
// The companion pin the old one could not be edited into. What an inheriting host must produce is
|
|
// the absence of "port", not "port":null and not "port":22 — the first would decode as 0 on any
|
|
// build, and the second is what inheritance exists to stop writing.
|
|
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, port: null));
|
|
|
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
|
"""
|
|
{"schemaVersion":5,"label":"prod-db","hostname":"db.internal","jumpHostIds":[],"options":{},"relayEnabled":false}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostWearingTags_WritesThemLastAndSorted()
|
|
{
|
|
// Last, so every field that existed before them keeps its bytes; sorted, so two users who tapped the
|
|
// same two chips in opposite orders produce one value and nothing to push.
|
|
var bytes = HostSecretCodec.Encode(
|
|
Host(username: null, notes: null, tags: [EuWest, Pci]));
|
|
|
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
|
"""
|
|
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08","0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09"]}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void APayloadSayingItAsksForNoPassword_DecodesAsHavingSaidNothing()
|
|
{
|
|
// False and null mean the same thing — take the group's binding — so only one may reach the record.
|
|
// Two spellings of one state is a difference the merge would report as a change nobody made.
|
|
var payload = Encoding.UTF8.GetBytes(
|
|
"""
|
|
{"schemaVersion":5,"label":"prod-db","hostname":"db.internal","port":22,"asksForPassword":false}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull().Host.AsksForPassword.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void APayloadRepeatingATag_DecodesAsTheSetItMeant()
|
|
{
|
|
// Written by some other client, and it must not compare unequal to the same set written once — or
|
|
// the engine would push this host as changed on every pass for ever.
|
|
var payload = Encoding.UTF8.GetBytes(
|
|
$$"""
|
|
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["{{Pci}}","{{Pci}}"]}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull().Host.TagIds.ShouldBe(TagSet.Create([Pci]));
|
|
}
|
|
|
|
[Fact]
|
|
public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne()
|
|
{
|
|
// Pinned against a literal rather than against the codec, because the claim is about history: every
|
|
// host already in every vault must re-encode to what it encoded before SshKeyId existed, or the
|
|
// first sync after an upgrade would push the entire vault as changed. Byte-for-byte, so a new field
|
|
// that serialised ahead of these — or a null that serialised as null — would fail here.
|
|
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null));
|
|
|
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
|
"""
|
|
{"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostBoundToAKeyByANewerClient_IsReadableButNotWritableHere()
|
|
{
|
|
// What an older build sees. Simulated by a version past this one rather than by an older codec,
|
|
// since the mechanism is the comparison and not the field: read the item, refuse to re-encode it.
|
|
var payload = Encoding.UTF8.GetBytes(
|
|
"""
|
|
{"schemaVersion":99,"label":"prod-db","hostname":"db.internal","port":22,"certificateId":"something this build has never heard of"}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.IsReadOnly.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void AMinimalHost_RoundTrips()
|
|
{
|
|
var host = Host(username: null, notes: null);
|
|
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
|
|
|
document!.Host.ShouldBe(host);
|
|
document.Host.Username.ShouldBeNull();
|
|
document.Host.Notes.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Encoding_IsDeterministic()
|
|
{
|
|
var host = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
|
|
|
|
HostSecretCodec.Encode(host).ShouldBe(HostSecretCodec.Encode(host));
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectiveOrder_DoesNotAffectTheEncoding()
|
|
{
|
|
// Two clients that agree on the content must produce the same bytes regardless of the order
|
|
// the user happened to type the directives in.
|
|
var one = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
|
|
var other = Host(options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);
|
|
|
|
HostSecretCodec.Encode(one).ShouldBe(HostSecretCodec.Encode(other));
|
|
}
|
|
|
|
[Fact]
|
|
public void APayloadFromANewerSchema_IsReadableButReadOnly()
|
|
{
|
|
// The forward-compatibility rule. An old client can show the host but must not re-encode it,
|
|
// because it has no representation for the newer client's extra fields and would drop them.
|
|
var payload = Json("""
|
|
{
|
|
"schemaVersion": 99,
|
|
"label": "prod-db",
|
|
"hostname": "db.internal",
|
|
"port": 22,
|
|
"unknownFutureField": { "nested": true }
|
|
}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
|
|
|
document!.Host.Label.ShouldBe("prod-db");
|
|
document.Host.Hostname.ShouldBe("db.internal");
|
|
document.IsReadOnly.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void AnUnknownFieldAtTheCurrentSchema_IsSkippedRatherThanFatal()
|
|
{
|
|
var payload = Json("""
|
|
{
|
|
"schemaVersion": 1,
|
|
"label": "prod-db",
|
|
"hostname": "db.internal",
|
|
"port": 22,
|
|
"somethingElse": 5
|
|
}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
|
document!.IsReadOnly.ShouldBeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("not json at all")]
|
|
[InlineData("{")]
|
|
[InlineData("[]")]
|
|
[InlineData("null")]
|
|
public void MalformedBytes_ReturnFalseRatherThanThrow(string text)
|
|
{
|
|
HostSecretCodec.TryDecode(Json(text), out var document).ShouldBeFalse();
|
|
document.ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{ "schemaVersion": 0, "label": "a", "hostname": "b", "port": 22 }""")]
|
|
[InlineData("""{ "schemaVersion": -1, "label": "a", "hostname": "b", "port": 22 }""")]
|
|
[InlineData("""{ "schemaVersion": 1, "label": "", "hostname": "b", "port": 22 }""")]
|
|
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "", "port": 22 }""")]
|
|
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 0 }""")]
|
|
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 70000 }""")]
|
|
public void AStructurallyInvalidPayload_IsRejected(string json)
|
|
{
|
|
HostSecretCodec.TryDecode(Json(json), out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void DuplicateDirectiveNamesDifferingOnlyInCase_AreRejected()
|
|
{
|
|
// Fails closed. SSH treats keywords case-insensitively, so this payload has no single
|
|
// meaning; guessing which one wins would make two clients disagree about the same bytes.
|
|
var payload = Json("""
|
|
{
|
|
"schemaVersion": 1,
|
|
"label": "prod-db",
|
|
"hostname": "db.internal",
|
|
"port": 22,
|
|
"options": { "Compression": "yes", "compression": "no" }
|
|
}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void AnEmptyJumpHostId_IsRejected()
|
|
{
|
|
var payload = Json($$"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"label": "prod-db",
|
|
"hostname": "db.internal",
|
|
"port": 22,
|
|
"jumpHostIds": ["{{Guid.Empty}}"]
|
|
}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Encode_RefusesAnInvalidHost()
|
|
{
|
|
// Throwing rather than returning false, because unlike decoding, this is a caller bug: the
|
|
// host came from this process and should have been validated before it got here.
|
|
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(label: " ")));
|
|
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(port: 0)));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheEncoding_CarriesNoPlaintextOutsideTheEnvelope()
|
|
{
|
|
// A reminder of what this codec is for: every one of these values is inside the ciphertext.
|
|
// There is no plaintext host label anywhere in the system.
|
|
var host = Host(label: "prod-db", notes: "root password in 1Password");
|
|
|
|
var text = Encoding.UTF8.GetString(HostSecretCodec.Encode(host));
|
|
|
|
text.Contains("prod-db", StringComparison.Ordinal).ShouldBeTrue();
|
|
text.Contains("1Password", StringComparison.Ordinal).ShouldBeTrue();
|
|
}
|
|
|
|
private static byte[] Json(string text) => Encoding.UTF8.GetBytes(text);
|
|
}
|