Public Access
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.
A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
581 lines
24 KiB
C#
581 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, a pair of forwards and a Spelunk route that signs in — which are orthogonal to the
|
|
/// binding (a sign-in leaves a stored binding alone rather than refusing it) 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"],
|
|
portForwards: [Postgres, Socks],
|
|
spelunk: ThroughSpelunk(signIn: true));
|
|
|
|
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);
|
|
}
|