Public Access
Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
This commit is contained in:
1 parent
ee423b25ce
commit
b08f2c58e3
37 files changed
+3907
-64
No files matched your search
@@ -0,0 +1,92 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A Spelunk API key and the one Spelunk it may be sent to, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address is what makes this an item of its own.</b> A key kept as a credential's password can be
|
||||
/// sent anywhere a host names, and a link from Spelunk can be sent by anything on the machine — so a link
|
||||
/// that is not already backed by a saved host has to stop and ask. A key stored with its address says, once
|
||||
/// and in the keychain, where the user agreed it may go: a link from that Spelunk connects straight away, and
|
||||
/// a host naming this key with any other address is refused at connect rather than sending it there. See
|
||||
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="ApiKey"/> is a password, and everything this codebase does about passwords applies to
|
||||
/// it</b>, as it does to <see cref="ObjectStoreSecret.SecretAccessKey"/>: it is inside the encrypted payload,
|
||||
/// the activity log records that it changed and never what it was, and the merge reports that it differed
|
||||
/// rather than either value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkKeySecret : IVaultSecret
|
||||
{
|
||||
/// <summary>The longest API key stored.</summary>
|
||||
/// <remarks>
|
||||
/// Far beyond any real key, for the reason <see cref="SpelunkRoute.MaxApiBaseLength"/> exists: an
|
||||
/// unbounded string is the shape a corrupted payload takes, and this one goes into a request header.
|
||||
/// </remarks>
|
||||
public const int MaxApiKeyLength = 4096;
|
||||
|
||||
/// <summary>What the user calls this key.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk API this key is for, as <see cref="SpelunkRoute.TryNormalizeApiBase"/> writes it, and the
|
||||
/// only address it is ever sent to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Normalised for the reason <see cref="SpelunkRoute.ApiBase"/> is: it is compared, byte for byte, with
|
||||
/// the address a host or a link names.
|
||||
/// </remarks>
|
||||
public required string ApiBase { get; init; }
|
||||
|
||||
/// <summary>The API key itself, as Spelunk issued it.</summary>
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A Spelunk key needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApiBase))
|
||||
{
|
||||
reason = "A Spelunk key needs the address of the Spelunk it is for.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(ApiBase, out var normalized, out reason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
|
||||
{
|
||||
reason = "The Spelunk API address is not in its normal form.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||
{
|
||||
reason = "A Spelunk key needs the key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// A header value cannot hold a line break, and a key with one would fail at the first connect with an
|
||||
// HTTP error that names neither the key nor this item.
|
||||
if (ApiKey.Length > MaxApiKeyLength || ApiKey.Any(char.IsControl))
|
||||
{
|
||||
reason = "That does not look like a Spelunk API key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded Spelunk key payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Key">The key.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record SpelunkKeySecretDocument(SpelunkKeySecret Key, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > SpelunkKeySecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a Spelunk key item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="ObjectStoreSecretCodec"/>, for the same reasons and with the same guarantees.
|
||||
/// </remarks>
|
||||
public static class SpelunkKeySecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a key to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The key is not valid for storage.</exception>
|
||||
public static byte[] Encode(SpelunkKeySecret key)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
if (!key.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(key));
|
||||
}
|
||||
|
||||
var document = new SpelunkKeyPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = key.Label,
|
||||
ApiBase = key.ApiBase,
|
||||
ApiKey = key.ApiKey,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out SpelunkKeySecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
SpelunkKeyPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new SpelunkKeySecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
ApiBase = parsed.ApiBase ?? string.Empty,
|
||||
ApiKey = parsed.ApiKey ?? string.Empty,
|
||||
};
|
||||
|
||||
// A key whose address is not in its normal form is refused rather than normalised here: the address
|
||||
// is what decides where the key may go, and a payload that disagrees with how every client writes it
|
||||
// is not one to guess about.
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new SpelunkKeySecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class SpelunkKeyPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
public string? ApiBase { get; set; }
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SpelunkKeyPayloadDocument))]
|
||||
internal sealed partial class SpelunkKeyPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged Spelunk key, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The key to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record SpelunkKeyMergeResult(
|
||||
SpelunkKeySecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a Spelunk key against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address and the key merge as one value, not two.</b> The pair is the whole meaning of this item —
|
||||
/// "this key may go to this Spelunk" — and merging them apart could keep one device's new address beside the
|
||||
/// other device's new key, sending a key to a Spelunk nobody ever paired it with. So when both sides changed
|
||||
/// the pair, one side's pair is kept whole.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The key never reaches the conflict log</b>, for the reason a secret access key does not: a discarded
|
||||
/// one is very often still live. The addresses are shown, because which Spelunk the kept key goes to is the
|
||||
/// whole content of the notice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SpelunkKeySecretMerge
|
||||
{
|
||||
/// <summary>The name a conflict over the address and key is recorded under.</summary>
|
||||
public const string PairField = "ApiBaseAndKey";
|
||||
|
||||
/// <summary>Produces the merged key.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static SpelunkKeyMergeResult Merge(
|
||||
SpelunkKeySecret ancestor,
|
||||
SpelunkKeySecret local,
|
||||
SpelunkKeySecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var label = ThreeWayMerge.Scalar(ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
|
||||
|
||||
if (label.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
nameof(SpelunkKeySecret.Label),
|
||||
MergeSide.Local,
|
||||
label.Value,
|
||||
label.Discarded ?? "(none)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
// A tuple of two strings compares both ordinally, which is the comparison each field needs.
|
||||
var pair = ThreeWayMerge.Scalar(
|
||||
(ancestor.ApiBase, ancestor.ApiKey),
|
||||
(local.ApiBase, local.ApiKey),
|
||||
(remote.ApiBase, remote.ApiKey));
|
||||
|
||||
if (pair.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
PairField,
|
||||
MergeSide.Local,
|
||||
$"{pair.Value.ApiBase} (with its key)",
|
||||
string.Equals(pair.Discarded.ApiBase, pair.Value.ApiBase, StringComparison.Ordinal)
|
||||
? "(a different key for the same address was discarded)"
|
||||
: $"{pair.Discarded.ApiBase} (with its key)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
var merged = new SpelunkKeySecret
|
||||
{
|
||||
Label = label.Value,
|
||||
ApiBase = pair.Value.ApiBase,
|
||||
ApiKey = pair.Value.ApiKey,
|
||||
};
|
||||
|
||||
return new SpelunkKeyMergeResult(merged, conflicts);
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@ namespace DodoSSH.Client.Domain;
|
||||
/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names an ordinary
|
||||
/// keychain credential whose password is the key, for the reason <see cref="HostSecret.CredentialId"/>
|
||||
/// gives: one Spelunk key is the key for every server in its organisation, and a copy per host is twenty
|
||||
/// places to rotate it and one to forget. It also means the key is encrypted, synced, merged and shown in
|
||||
/// the keychain exactly as a password is, with no item type of its own to build and keep in step.
|
||||
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names the keychain item
|
||||
/// holding it, for the reason <see cref="HostSecret.CredentialId"/> gives: one Spelunk key is the key for
|
||||
/// every server in its organisation, and a copy per host is twenty places to rotate it and one to forget.
|
||||
/// That item is a <see cref="SpelunkKeySecret"/>, which also says the one address the key may go to, or —
|
||||
/// for a host set up before those existed — an ordinary credential whose password is the key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkRoute
|
||||
@@ -50,12 +50,22 @@ public sealed record SpelunkRoute
|
||||
/// <summary>The server's id in Spelunk.</summary>
|
||||
public required Guid ServerId { get; init; }
|
||||
|
||||
/// <summary>The keychain credential whose password is the Spelunk API key.</summary>
|
||||
/// <summary>
|
||||
/// The keychain item holding the Spelunk API key: a <see cref="SpelunkKeySecret"/>, or a credential whose
|
||||
/// password is the key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named for the credential it always was, and kept so on the wire: item ids are unique across kinds, so
|
||||
/// the one id says which, and a build that predates Spelunk keys reads a host naming one as a host whose
|
||||
/// key credential has gone — refused at connect, and kept as it is by an edit — rather than as damage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Required rather than nullable. A route with no key can never be opened, and a host saved that way
|
||||
/// would refuse every connection with a sentence about something the editor let through. The reference
|
||||
/// can still dangle — the credential may be deleted on another machine — and that is refused where it
|
||||
/// is noticed, at connect, exactly as a missing SSH credential is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public required Guid ApiKeyCredentialId { get; init; }
|
||||
|
||||
@@ -113,7 +123,7 @@ public sealed record SpelunkRoute
|
||||
|
||||
if (ApiKeyCredentialId == Guid.Empty)
|
||||
{
|
||||
reason = "A host reached through Spelunk needs a credential holding its Spelunk API key.";
|
||||
reason = "A host reached through Spelunk needs its Spelunk API key chosen.";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user