Public Access
Freeze DSH1 crypto specification and implement the core (M1)
docs/crypto.md is now the normative, frozen specification. This had to land before anything else in M1: the server holds ciphertext and no keys, so it can never re-encrypt, and a format change after users hold data is a coordinated client rewrite with no rollback. Specification: - DSH1 envelope layout, canonical 64-byte AAD encoding, SealTo construction, key hierarchy, Argon2id profiles, fingerprints, and the change rules for each version field. - AAD encoding is fixed-width binary rather than delimited string concatenation, so no field value can forge a field boundary. This supersedes the illustrative form sketched in ADR 0001, which now points here. - UUIDs are RFC 4122 big-endian. Guid.ToByteArray() emits the first three groups little-endian and would have made our ciphertext unreadable by any other implementation of this spec, failing only at a cross-implementation boundary. Verified rather than assumed: - PrimitiveAvailabilityTests proves X25519, Ed25519, XChaCha20-Poly1305, Argon2id and HKDF-SHA512 all function on net10.0. NSec 26.4.0 targets net9.0 and is consumed by forward compatibility; this closes one of the two package questions the plan flagged. - Argon2Profile exists because NSec's MemorySize is in KIBIBYTES, not bytes. Passing bytes gives either a 256 GiB allocation or a 256 KiB KDF that cracks instantly. The type takes mebibytes so the unit cannot be got wrong at a call site. Found by benchmarking: the first measurements were ~1000x too slow, which turned out to be 19 GiB of work. - Parameters measured, not guessed: 256 MiB/t=4 is 323 ms on this machine; the table of candidates is in the spec. Implementation and tests (83 total, up from 17): - AadDescriptor, DshEnvelope, DshCrypto (Seal/Open/SealTo/OpenSealed/fingerprints). - Decryption returns null rather than throwing: ciphertext comes from a server that is explicitly not trusted, so a failed tag is an expected outcome. - Envelope readers reject unknown algorithms and any non-zero flag bit, so an envelope that is not fully understood fails closed. - Executable form of the spec's substitution claims: a server cannot move ciphertext between resources, roll back a key generation or item version, repurpose a payload as metadata, or confuse the two constructions. - Golden vectors in tests/fixtures/crypto/vectors.json guard the format. Mutation-checked: a one-byte schema version change trips four tests including the guard. Two build-infrastructure bugs found and fixed along the way: - .editorconfig forced camelCase on const and static readonly fields. PascalCase is the .NET convention for both; the config was wrong, not the code. - The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild rewrites to /_/... under deterministic source paths. It passed locally and would have failed only in CI. Now copied to the output directory and read from there.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the row a ciphertext belongs to, and computes the additional authenticated data
|
||||
/// that binds the ciphertext to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// See docs/crypto.md §4, which is normative. The AAD is never stored: it is recomputed from
|
||||
/// the row's plaintext columns on both encrypt and decrypt. That is what stops a server that
|
||||
/// holds every ciphertext from moving one between rows, rolling a row back to an earlier key
|
||||
/// generation, replaying a revoked grant, or substituting a metadata blob for a payload blob.
|
||||
/// None of those properties follow from access control.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The encoding is fixed-width rather than delimited, so no field value can forge a field
|
||||
/// boundary.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Purpose">What this ciphertext is.</param>
|
||||
/// <param name="ResourceType">The kind of entity it belongs to.</param>
|
||||
/// <param name="ResourceId">The entity's identifier.</param>
|
||||
/// <param name="KeyId">The key it is encrypted under, where one is identified.</param>
|
||||
/// <param name="KeyGeneration">The vault key generation in force.</param>
|
||||
/// <param name="ItemVersion">The item version, where applicable.</param>
|
||||
/// <param name="AadVersion">The AAD rule version, so a change can be applied lazily.</param>
|
||||
/// <param name="SchemaVersion">The relational schema version.</param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct AadDescriptor(
|
||||
CryptoSpec.AadPurpose Purpose,
|
||||
CryptoSpec.AadResourceType ResourceType,
|
||||
Guid ResourceId,
|
||||
Guid KeyId,
|
||||
uint KeyGeneration,
|
||||
uint ItemVersion,
|
||||
byte AadVersion,
|
||||
ushort SchemaVersion)
|
||||
{
|
||||
private const int OffsetAadVersion = 5;
|
||||
private const int OffsetPurpose = 6;
|
||||
private const int OffsetResourceType = 7;
|
||||
private const int OffsetResourceId = 8;
|
||||
private const int OffsetKeyId = 24;
|
||||
private const int OffsetKeyGeneration = 40;
|
||||
private const int OffsetItemVersion = 44;
|
||||
private const int OffsetSchemaVersion = 48;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a descriptor at the current AAD and schema versions.
|
||||
/// </summary>
|
||||
public static AadDescriptor Create(
|
||||
CryptoSpec.AadPurpose purpose,
|
||||
CryptoSpec.AadResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid keyId = default,
|
||||
uint keyGeneration = 1,
|
||||
uint itemVersion = 0) =>
|
||||
new(
|
||||
purpose,
|
||||
resourceType,
|
||||
resourceId,
|
||||
keyId,
|
||||
keyGeneration,
|
||||
itemVersion,
|
||||
CryptoSpec.CurrentAadVersion,
|
||||
CryptoSpec.CurrentSchemaVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the canonical 64-byte encoding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// UUIDs are written in RFC 4122 big-endian order, <b>not</b> the mixed-endian order that
|
||||
/// <see cref="Guid.ToByteArray()"/> produces by default. Getting that wrong would make
|
||||
/// ciphertext written by one implementation undecryptable by another.
|
||||
/// </remarks>
|
||||
public void WriteCanonicalEncoding(Span<byte> destination)
|
||||
{
|
||||
if (destination.Length < CryptoSpec.AadEncodedLength)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Destination must be at least {CryptoSpec.AadEncodedLength} bytes.",
|
||||
nameof(destination));
|
||||
}
|
||||
|
||||
if (Purpose == CryptoSpec.AadPurpose.Unspecified)
|
||||
{
|
||||
throw new InvalidOperationException("AAD purpose must be specified.");
|
||||
}
|
||||
|
||||
var buffer = destination[..CryptoSpec.AadEncodedLength];
|
||||
buffer.Clear();
|
||||
|
||||
CryptoSpec.AadMagic.CopyTo(buffer);
|
||||
buffer[OffsetAadVersion] = AadVersion;
|
||||
buffer[OffsetPurpose] = (byte)Purpose;
|
||||
buffer[OffsetResourceType] = (byte)ResourceType;
|
||||
|
||||
if (!ResourceId.TryWriteBytes(buffer[OffsetResourceId..], bigEndian: true, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to write resource id.");
|
||||
}
|
||||
|
||||
if (!KeyId.TryWriteBytes(buffer[OffsetKeyId..], bigEndian: true, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to write key id.");
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buffer[OffsetKeyGeneration..], KeyGeneration);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buffer[OffsetItemVersion..], ItemVersion);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buffer[OffsetSchemaVersion..], SchemaVersion);
|
||||
|
||||
// Trailing 14 reserved bytes stay zero from the Clear above.
|
||||
}
|
||||
|
||||
/// <summary>Returns the canonical encoding as a new array. Prefer the span overload.</summary>
|
||||
public byte[] ToCanonicalEncoding()
|
||||
{
|
||||
var buffer = new byte[CryptoSpec.AadEncodedLength];
|
||||
WriteCanonicalEncoding(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>Computes the AAD: SHA-256 over the canonical encoding.</summary>
|
||||
public void ComputeAad(Span<byte> destination)
|
||||
{
|
||||
Span<byte> encoded = stackalloc byte[CryptoSpec.AadEncodedLength];
|
||||
WriteCanonicalEncoding(encoded);
|
||||
|
||||
if (!SHA256.TryHashData(encoded, destination, out _))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Destination must be at least {CryptoSpec.DigestSize} bytes.",
|
||||
nameof(destination));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Computes the AAD as a new array.</summary>
|
||||
public byte[] ComputeAad()
|
||||
{
|
||||
var aad = new byte[CryptoSpec.DigestSize];
|
||||
ComputeAad(aad);
|
||||
return aad;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// An Argon2id parameter set, in units that cannot be misread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This type exists for one reason: <c>NSec.Cryptography.Argon2Parameters.MemorySize</c> is in
|
||||
/// <b>kibibytes</b>, not bytes. Passing bytes silently produces either a catastrophically weak
|
||||
/// KDF or an impossible allocation — for a 256 MiB intent, <c>268435456</c> asks for 256 GiB,
|
||||
/// while <c>262144</c> interpreted as bytes is 256 KiB and cracks in milliseconds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Callers therefore never supply a raw memory figure. See docs/crypto.md §2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Parallelism is pinned to 1 because libsodium's Argon2id supports only <c>p=1</c>. Memory
|
||||
/// cost compensates: the default of 256 MiB at four passes is far above OWASP's 19 MiB/2-pass
|
||||
/// floor, and measured about 323 ms on a fast desktop.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record Argon2Profile
|
||||
{
|
||||
private const int KibibytesPerMebibyte = 1024;
|
||||
|
||||
private Argon2Profile(int memoryMebibytes, int passes)
|
||||
{
|
||||
if (memoryMebibytes is < 8 or > 4096)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(memoryMebibytes),
|
||||
memoryMebibytes,
|
||||
"Memory must be between 8 and 4096 MiB.");
|
||||
}
|
||||
|
||||
if (passes is < 1 or > 16)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(passes), passes, "Passes must be 1 to 16.");
|
||||
}
|
||||
|
||||
MemoryMebibytes = memoryMebibytes;
|
||||
Passes = passes;
|
||||
}
|
||||
|
||||
/// <summary>Memory cost, in mebibytes.</summary>
|
||||
public int MemoryMebibytes { get; }
|
||||
|
||||
/// <summary>Number of passes over memory.</summary>
|
||||
public int Passes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Degree of parallelism. Always 1; libsodium's Argon2id supports no other value, so this
|
||||
/// is a property of the algorithm rather than of a profile.
|
||||
/// </summary>
|
||||
public static int Parallelism => 1;
|
||||
|
||||
/// <summary>Default for deriving the master key from a vault passphrase.</summary>
|
||||
public static Argon2Profile PassphraseDefault { get; } = new(256, 4);
|
||||
|
||||
/// <summary>Reduced profile for low-powered devices. Still well above the OWASP floor.</summary>
|
||||
public static Argon2Profile PassphraseReduced { get; } = new(128, 3);
|
||||
|
||||
/// <summary>Raised profile for users who accept a slower unlock.</summary>
|
||||
public static Argon2Profile PassphraseHigh { get; } = new(512, 4);
|
||||
|
||||
/// <summary>
|
||||
/// Profile for 128-bit random secrets — recovery codes and invite secrets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// KDF hardening is nearly irrelevant for a full-entropy random secret; this is
|
||||
/// anti-nuisance only, not a defence against a serious offline attack.
|
||||
/// </remarks>
|
||||
public static Argon2Profile RandomSecret { get; } = new(64, 3);
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs a profile from stored parameters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// KDF parameters are stored in plaintext per wrap row so that raising them later is a
|
||||
/// per-user, unlock-time migration rather than a breaking change, and so an older client
|
||||
/// can still open its own wrap.
|
||||
/// </remarks>
|
||||
/// <param name="memoryKibibytes">Stored memory cost, in kibibytes.</param>
|
||||
/// <param name="passes">Stored pass count.</param>
|
||||
/// <param name="parallelism">Stored parallelism. Must be 1.</param>
|
||||
public static Argon2Profile FromStoredParameters(int memoryKibibytes, int passes, int parallelism)
|
||||
{
|
||||
if (parallelism != 1)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Argon2id parallelism {parallelism} is not supported; libsodium implements only p=1.");
|
||||
}
|
||||
|
||||
if (memoryKibibytes % KibibytesPerMebibyte != 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(memoryKibibytes),
|
||||
memoryKibibytes,
|
||||
"Stored memory cost must be a whole number of mebibytes.");
|
||||
}
|
||||
|
||||
return new Argon2Profile(memoryKibibytes / KibibytesPerMebibyte, passes);
|
||||
}
|
||||
|
||||
/// <summary>Memory cost in kibibytes, as persisted and as NSec expects it.</summary>
|
||||
public int MemoryKibibytes => MemoryMebibytes * KibibytesPerMebibyte;
|
||||
|
||||
/// <summary>Builds the NSec algorithm instance for this profile.</summary>
|
||||
public PasswordBasedKeyDerivationAlgorithm CreateAlgorithm() =>
|
||||
PasswordBasedKeyDerivationAlgorithm.Argon2id(new Argon2Parameters
|
||||
{
|
||||
// MemorySize is KiB. This single line is the reason this type exists.
|
||||
MemorySize = MemoryKibibytes,
|
||||
NumberOfPasses = Passes,
|
||||
DegreeOfParallelism = Parallelism,
|
||||
});
|
||||
}
|
||||
@@ -1,47 +1,171 @@
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Constants of the DodoSSH cryptographic specification.
|
||||
/// Constants of the DodoSSH cryptographic specification, version 1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// docs/crypto.md is the normative specification; this type must agree with it exactly.
|
||||
/// The implementation of the envelope, AAD derivation and key wrapping lands in M1, once
|
||||
/// the specification and its test vectors are frozen. Nothing else may be built on top of
|
||||
/// an unfrozen AAD: only clients can re-encrypt, so a change after users hold data cannot
|
||||
/// be migrated server-side.
|
||||
/// docs/crypto.md is normative; this type must agree with it exactly. The values here are
|
||||
/// written into stored data, so changing one reinterprets or orphans existing ciphertext.
|
||||
/// Only clients can re-encrypt, so the server cannot migrate a change here.
|
||||
/// </remarks>
|
||||
public static class CryptoSpec
|
||||
{
|
||||
/// <summary>Magic prefix identifying a DSH1 envelope.</summary>
|
||||
public const string EnvelopeMagic = "DSH1";
|
||||
public static ReadOnlySpan<byte> EnvelopeMagic => "DSH1"u8;
|
||||
|
||||
/// <summary>Magic prefix of the canonical AAD encoding.</summary>
|
||||
public static ReadOnlySpan<byte> AadMagic => "dsh1\n"u8;
|
||||
|
||||
/// <summary>Length of the canonical AAD encoding, before hashing.</summary>
|
||||
public const int AadEncodedLength = 64;
|
||||
|
||||
/// <summary>Version of the AAD derivation rule that payloads are bound to.</summary>
|
||||
/// <remarks>
|
||||
/// Stored per row as <c>payload_aad_version</c> so a future change can be applied
|
||||
/// lazily, re-encrypting on next write rather than in a migration.
|
||||
/// Stored per row as <c>payload_aad_version</c> so a future change can be applied lazily,
|
||||
/// re-encrypting on next write rather than in a migration.
|
||||
/// </remarks>
|
||||
public const short CurrentAadVersion = 1;
|
||||
public const byte CurrentAadVersion = 1;
|
||||
|
||||
/// <summary>Domain-separation prefix for every AAD computation.</summary>
|
||||
public const string AadDomainPrefix = "dsh1\n";
|
||||
/// <summary>Version of the relational schema that AAD is bound to.</summary>
|
||||
public const ushort CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Identifiers for the algorithms an envelope may declare.</summary>
|
||||
/// <summary>Size of a symmetric content or wrapping key.</summary>
|
||||
public const int SymmetricKeySize = 32;
|
||||
|
||||
/// <summary>Size of an X25519 or Ed25519 public key.</summary>
|
||||
public const int PublicKeySize = 32;
|
||||
|
||||
/// <summary>Size of an Ed25519 signature.</summary>
|
||||
public const int SignatureSize = 64;
|
||||
|
||||
/// <summary>Size of a SHA-256 output, used for AAD, fingerprints and digests.</summary>
|
||||
public const int DigestSize = 32;
|
||||
|
||||
/// <summary>Size of an AEAD authentication tag.</summary>
|
||||
public const int TagSize = 16;
|
||||
|
||||
/// <summary>Recommended salt length for password-based derivation.</summary>
|
||||
public const int SaltSize = 16;
|
||||
|
||||
/// <summary>Identifies the construction used by a DSH1 envelope.</summary>
|
||||
public enum AlgorithmId : byte
|
||||
{
|
||||
/// <summary>Reserved; never written.</summary>
|
||||
/// <summary>Reserved; never written, and rejected on read.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>Symmetric content encryption under a known key.</summary>
|
||||
XChaCha20Poly1305 = 1,
|
||||
|
||||
/// <summary>Symmetric fallback where XChaCha20 is unavailable.</summary>
|
||||
/// <summary>
|
||||
/// Symmetric fallback for environments without XChaCha20-Poly1305. Accepted on read,
|
||||
/// not currently emitted.
|
||||
/// </summary>
|
||||
Aes256Gcm = 2,
|
||||
|
||||
/// <summary>Anonymous-sender seal to an X25519 public key.</summary>
|
||||
/// <summary>Anonymous-sender seal to an X25519 public key. See docs/crypto.md §6.</summary>
|
||||
SealToX25519 = 3,
|
||||
|
||||
// 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Store-now-decrypt-later is
|
||||
// a genuine threat for long-lived SSH keys, so the identifier is claimed now even
|
||||
// though the construction ships later.
|
||||
// 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Claimed now so it cannot be
|
||||
// reused: store-now-decrypt-later is a real threat for long-lived SSH keys.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a given ciphertext is, so that one kind of blob can never be substituted for
|
||||
/// another. Part of the AAD; see docs/crypto.md §4.2.
|
||||
/// </summary>
|
||||
public enum AadPurpose : byte
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>A wrap of the user's secret bundle.</summary>
|
||||
UserSecretBundle = 1,
|
||||
|
||||
/// <summary>A vault key sealed to a member's public key.</summary>
|
||||
VaultKeyGrant = 2,
|
||||
|
||||
/// <summary>An item data key wrapped under a vault key.</summary>
|
||||
ItemDataKey = 3,
|
||||
|
||||
/// <summary>Item plaintext under its data key.</summary>
|
||||
ItemPayload = 4,
|
||||
|
||||
/// <summary>Encrypted item metadata under its data key.</summary>
|
||||
ItemMetadata = 5,
|
||||
|
||||
/// <summary>A record in a client's on-disk cache.</summary>
|
||||
LocalCache = 6,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The kind of entity a ciphertext belongs to. Part of the AAD; see docs/crypto.md §4.3.
|
||||
/// Append only.
|
||||
/// </summary>
|
||||
public enum AadResourceType : byte
|
||||
{
|
||||
/// <summary>No resource. Legal only where the purpose implies none.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>A user account.</summary>
|
||||
User = 1,
|
||||
|
||||
/// <summary>An enrolled device.</summary>
|
||||
Device = 2,
|
||||
|
||||
/// <summary>A vault.</summary>
|
||||
Vault = 3,
|
||||
|
||||
/// <summary>An SSH host.</summary>
|
||||
Host = 4,
|
||||
|
||||
/// <summary>A credential.</summary>
|
||||
Credential = 5,
|
||||
|
||||
/// <summary>An SSH key pair.</summary>
|
||||
SshKey = 6,
|
||||
|
||||
/// <summary>A host group.</summary>
|
||||
HostGroup = 7,
|
||||
|
||||
/// <summary>A tag.</summary>
|
||||
Tag = 8,
|
||||
|
||||
/// <summary>A snippet.</summary>
|
||||
Snippet = 9,
|
||||
|
||||
/// <summary>A port forward.</summary>
|
||||
PortForward = 10,
|
||||
|
||||
/// <summary>A known SSH host key.</summary>
|
||||
KnownHostKey = 11,
|
||||
}
|
||||
|
||||
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
||||
public static class DerivationLabels
|
||||
{
|
||||
/// <summary>Derives the key-encryption key that wraps the secret bundle.</summary>
|
||||
public static ReadOnlySpan<byte> PassphraseKek => "dsh1/kek/passphrase/v1"u8;
|
||||
|
||||
/// <summary>Derives the key that encrypts the client's on-disk cache.</summary>
|
||||
public static ReadOnlySpan<byte> LocalCache => "dsh1/localcache/v1"u8;
|
||||
|
||||
/// <summary>Prefix of the SealTo key-derivation info, concatenated with the AAD.</summary>
|
||||
public static ReadOnlySpan<byte> SealTo => "dsh1/sealto/v1|"u8;
|
||||
|
||||
/// <summary>Prefix of the identity key fingerprint input.</summary>
|
||||
public static ReadOnlySpan<byte> Fingerprint => "dsh1/fp/v1"u8;
|
||||
}
|
||||
|
||||
/// <summary>Ed25519 signing contexts. Prevents a signature being replayed in another role.</summary>
|
||||
public static class SigningContexts
|
||||
{
|
||||
/// <summary>Signs an enrollment key statement.</summary>
|
||||
public static ReadOnlySpan<byte> KeyStatement => "dsh1/sig/keystatement/v1"u8;
|
||||
|
||||
/// <summary>Signs a vault key grant tuple.</summary>
|
||||
public static ReadOnlySpan<byte> Grant => "dsh1/sig/grant/v1"u8;
|
||||
|
||||
/// <summary>Signs an admin attestation of another user's key statement.</summary>
|
||||
public static ReadOnlySpan<byte> Attestation => "dsh1/sig/attestation/v1"u8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSec.Cryptography" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Crypto.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Security.Cryptography;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// The DSH1 operations: symmetric content encryption and anonymous-sender key wrapping.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// docs/crypto.md is normative. Every operation takes an <see cref="AadDescriptor"/> rather
|
||||
/// than a raw AAD, so a caller cannot omit the binding that stops a server relocating
|
||||
/// ciphertext between rows or key generations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Decryption returns <see langword="null"/> on authentication failure rather than throwing.
|
||||
/// Ciphertext arrives from a server that is explicitly not trusted, so a failed tag is an
|
||||
/// expected outcome to be handled, not an exceptional one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DshCrypto
|
||||
{
|
||||
private static AeadAlgorithm Aead => AeadAlgorithm.XChaCha20Poly1305;
|
||||
|
||||
private static KeyAgreementAlgorithm Agreement => KeyAgreementAlgorithm.X25519;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts under a known symmetric key, producing a complete DSH1 envelope
|
||||
/// (<see cref="CryptoSpec.AlgorithmId.XChaCha20Poly1305"/>).
|
||||
/// </summary>
|
||||
/// <param name="key">32-byte content key.</param>
|
||||
/// <param name="plaintext">Data to protect.</param>
|
||||
/// <param name="descriptor">Identifies the row this ciphertext belongs to.</param>
|
||||
public static byte[] Seal(ReadOnlySpan<byte> key, ReadOnlySpan<byte> plaintext, in AadDescriptor descriptor)
|
||||
{
|
||||
RequireSymmetricKey(key);
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
// A 192-bit nonce is why a random nonce per message is safe with no counter to track.
|
||||
Span<byte> nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey);
|
||||
var ciphertext = Aead.Encrypt(aeadKey, nonce, aad, plaintext);
|
||||
|
||||
return DshEnvelope.Write(CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an envelope produced by <see cref="Seal"/>.
|
||||
/// </summary>
|
||||
/// <returns>The plaintext, or <see langword="null"/> if the envelope is malformed, uses
|
||||
/// another construction, or fails authentication under this descriptor.</returns>
|
||||
public static byte[]? Open(ReadOnlySpan<byte> key, ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
|
||||
{
|
||||
RequireSymmetricKey(key);
|
||||
|
||||
if (!DshEnvelope.TryRead(envelope, out var view))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (view.Algorithm != CryptoSpec.AlgorithmId.XChaCha20Poly1305)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey);
|
||||
return Aead.Decrypt(aeadKey, view.Nonce, aad, view.Ciphertext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seals to a recipient's X25519 public key, producing a
|
||||
/// <see cref="CryptoSpec.AlgorithmId.SealToX25519"/> envelope. See docs/crypto.md §6.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Anonymous-sender by construction: this proves nothing about who created the envelope.
|
||||
/// Callers that need attribution — grants, in particular — must additionally attach a
|
||||
/// detached Ed25519 signature, or a server could fabricate a grant undetectably.
|
||||
/// </remarks>
|
||||
public static byte[] SealTo(
|
||||
ReadOnlySpan<byte> recipientPublicKey,
|
||||
ReadOnlySpan<byte> plaintext,
|
||||
in AadDescriptor descriptor)
|
||||
{
|
||||
RequirePublicKey(recipientPublicKey);
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
var recipient = PublicKey.Import(Agreement, recipientPublicKey, KeyBlobFormat.RawPublicKey);
|
||||
|
||||
// The ephemeral key must be exportable: its public half goes into the envelope.
|
||||
using var ephemeral = Key.Create(
|
||||
Agreement,
|
||||
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
||||
|
||||
var ephemeralPublic = ephemeral.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
using var shared = Agreement.Agree(ephemeral, recipient)
|
||||
?? throw new CryptographicException("X25519 agreement failed; the recipient key is invalid.");
|
||||
|
||||
Span<byte> derived = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
DeriveSealKey(shared, ephemeralPublic, recipientPublicKey, aad, derived);
|
||||
|
||||
Span<byte> nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey);
|
||||
var ciphertext = Aead.Encrypt(contentKey, nonce, aad, plaintext);
|
||||
|
||||
CryptographicOperations.ZeroMemory(derived);
|
||||
|
||||
return DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.SealToX25519,
|
||||
nonce,
|
||||
ciphertext,
|
||||
ephemeralPublic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an envelope produced by <see cref="SealTo"/> using the recipient's private key.
|
||||
/// </summary>
|
||||
/// <returns>The plaintext, or <see langword="null"/> on any failure.</returns>
|
||||
public static byte[]? OpenSealed(Key recipientKey, ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(recipientKey);
|
||||
|
||||
if (!DshEnvelope.TryRead(envelope, out var view))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (view.Algorithm != CryptoSpec.AlgorithmId.SealToX25519)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
PublicKey ephemeralPublic;
|
||||
try
|
||||
{
|
||||
ephemeralPublic = PublicKey.Import(Agreement, view.EphemeralPublicKey, KeyBlobFormat.RawPublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var shared = Agreement.Agree(recipientKey, ephemeralPublic);
|
||||
if (shared is null)
|
||||
{
|
||||
// All-zero agreement: a low-order or otherwise invalid ephemeral key.
|
||||
return null;
|
||||
}
|
||||
|
||||
var recipientPublic = recipientKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
Span<byte> derived = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
DeriveSealKey(shared, view.EphemeralPublicKey, recipientPublic, aad, derived);
|
||||
|
||||
using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey);
|
||||
var plaintext = Aead.Decrypt(contentKey, view.Nonce, aad, view.Ciphertext);
|
||||
|
||||
CryptographicOperations.ZeroMemory(derived);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes an identity fingerprint over a user's two public keys. See docs/crypto.md §8.
|
||||
/// </summary>
|
||||
public static byte[] ComputeFingerprint(
|
||||
ReadOnlySpan<byte> x25519PublicKey,
|
||||
ReadOnlySpan<byte> ed25519PublicKey)
|
||||
{
|
||||
RequirePublicKey(x25519PublicKey);
|
||||
RequirePublicKey(ed25519PublicKey);
|
||||
|
||||
var label = CryptoSpec.DerivationLabels.Fingerprint;
|
||||
|
||||
Span<byte> input = stackalloc byte[label.Length + (CryptoSpec.PublicKeySize * 2)];
|
||||
label.CopyTo(input);
|
||||
x25519PublicKey.CopyTo(input[label.Length..]);
|
||||
ed25519PublicKey.CopyTo(input[(label.Length + CryptoSpec.PublicKeySize)..]);
|
||||
|
||||
var fingerprint = new byte[CryptoSpec.DigestSize];
|
||||
SHA256.HashData(input, fingerprint);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HKDF-SHA256 over the X25519 shared secret, salted with both public keys and bound to
|
||||
/// the AAD. Specified rather than reusing libsodium's sealed box, whose KDF covers only the
|
||||
/// key pair and would not carry the AAD binding.
|
||||
/// </summary>
|
||||
private static void DeriveSealKey(
|
||||
SharedSecret shared,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey,
|
||||
ReadOnlySpan<byte> recipientPublicKey,
|
||||
ReadOnlySpan<byte> aad,
|
||||
Span<byte> destination)
|
||||
{
|
||||
Span<byte> salt = stackalloc byte[CryptoSpec.PublicKeySize * 2];
|
||||
ephemeralPublicKey.CopyTo(salt);
|
||||
recipientPublicKey.CopyTo(salt[CryptoSpec.PublicKeySize..]);
|
||||
|
||||
var prefix = CryptoSpec.DerivationLabels.SealTo;
|
||||
Span<byte> info = stackalloc byte[prefix.Length + aad.Length];
|
||||
prefix.CopyTo(info);
|
||||
aad.CopyTo(info[prefix.Length..]);
|
||||
|
||||
KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(shared, salt, info, destination);
|
||||
}
|
||||
|
||||
private static void RequireSymmetricKey(ReadOnlySpan<byte> key)
|
||||
{
|
||||
if (key.Length != CryptoSpec.SymmetricKeySize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Key must be {CryptoSpec.SymmetricKeySize} bytes, got {key.Length}.",
|
||||
nameof(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequirePublicKey(ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (publicKey.Length != CryptoSpec.PublicKeySize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Public key must be {CryptoSpec.PublicKeySize} bytes, got {publicKey.Length}.",
|
||||
nameof(publicKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Framing of the DSH1 envelope. See docs/crypto.md §5, which is normative.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Layout, all integers big-endian:
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// offset size field
|
||||
/// 0 4 magic "DSH1"
|
||||
/// 4 1 alg_id
|
||||
/// 5 1 flags reserved, must be zero
|
||||
/// [alg_id = 3 only]
|
||||
/// 6 32 ephemeral_pk
|
||||
/// — — nonce 24 bytes for alg 1 and 3, 12 for alg 2
|
||||
/// — n ciphertext including the trailing 16-byte tag
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// This type does framing only; it performs no cryptography. Readers reject unknown
|
||||
/// algorithms and any non-zero flag bit, so an envelope that is not fully understood fails
|
||||
/// closed rather than being partially interpreted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DshEnvelope
|
||||
{
|
||||
/// <summary>Bytes before the algorithm-specific portion.</summary>
|
||||
public const int HeaderSize = 6;
|
||||
|
||||
private const int OffsetAlgorithm = 4;
|
||||
private const int OffsetFlags = 5;
|
||||
private const int OffsetEphemeralPublicKey = 6;
|
||||
|
||||
/// <summary>Nonce length for XChaCha20-Poly1305 and SealTo.</summary>
|
||||
public const int XChaChaNonceSize = 24;
|
||||
|
||||
/// <summary>Nonce length for AES-256-GCM.</summary>
|
||||
public const int AesGcmNonceSize = 12;
|
||||
|
||||
/// <summary>Returns the nonce length used by an algorithm.</summary>
|
||||
public static int NonceSizeFor(CryptoSpec.AlgorithmId algorithm) => algorithm switch
|
||||
{
|
||||
CryptoSpec.AlgorithmId.XChaCha20Poly1305 => XChaChaNonceSize,
|
||||
CryptoSpec.AlgorithmId.SealToX25519 => XChaChaNonceSize,
|
||||
CryptoSpec.AlgorithmId.Aes256Gcm => AesGcmNonceSize,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Unknown algorithm."),
|
||||
};
|
||||
|
||||
/// <summary>True when the algorithm carries an ephemeral public key in its header.</summary>
|
||||
public static bool HasEphemeralPublicKey(CryptoSpec.AlgorithmId algorithm) =>
|
||||
algorithm == CryptoSpec.AlgorithmId.SealToX25519;
|
||||
|
||||
/// <summary>Total prefix length before the ciphertext for a given algorithm.</summary>
|
||||
public static int PrefixSizeFor(CryptoSpec.AlgorithmId algorithm) =>
|
||||
HeaderSize
|
||||
+ (HasEphemeralPublicKey(algorithm) ? CryptoSpec.PublicKeySize : 0)
|
||||
+ NonceSizeFor(algorithm);
|
||||
|
||||
/// <summary>Smallest legal envelope for an algorithm: prefix plus a bare tag.</summary>
|
||||
public static int MinimumSizeFor(CryptoSpec.AlgorithmId algorithm) =>
|
||||
PrefixSizeFor(algorithm) + CryptoSpec.TagSize;
|
||||
|
||||
/// <summary>Exact size of an envelope for an algorithm and ciphertext length.</summary>
|
||||
public static int SizeFor(CryptoSpec.AlgorithmId algorithm, int ciphertextLength) =>
|
||||
PrefixSizeFor(algorithm) + ciphertextLength;
|
||||
|
||||
/// <summary>
|
||||
/// Writes an envelope.
|
||||
/// </summary>
|
||||
/// <param name="destination">Buffer receiving the envelope.</param>
|
||||
/// <param name="algorithm">Construction used.</param>
|
||||
/// <param name="nonce">Nonce, whose length must match the algorithm.</param>
|
||||
/// <param name="ciphertext">Ciphertext including its trailing tag.</param>
|
||||
/// <param name="ephemeralPublicKey">
|
||||
/// Ephemeral X25519 public key, required for <see cref="CryptoSpec.AlgorithmId.SealToX25519"/>
|
||||
/// and forbidden otherwise.
|
||||
/// </param>
|
||||
/// <returns>Number of bytes written.</returns>
|
||||
public static int Write(
|
||||
Span<byte> destination,
|
||||
CryptoSpec.AlgorithmId algorithm,
|
||||
ReadOnlySpan<byte> nonce,
|
||||
ReadOnlySpan<byte> ciphertext,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey = default)
|
||||
{
|
||||
var expectedNonce = NonceSizeFor(algorithm);
|
||||
if (nonce.Length != expectedNonce)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{algorithm} requires a {expectedNonce}-byte nonce, got {nonce.Length}.",
|
||||
nameof(nonce));
|
||||
}
|
||||
|
||||
var wantsEphemeral = HasEphemeralPublicKey(algorithm);
|
||||
if (wantsEphemeral && ephemeralPublicKey.Length != CryptoSpec.PublicKeySize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{algorithm} requires a {CryptoSpec.PublicKeySize}-byte ephemeral public key.",
|
||||
nameof(ephemeralPublicKey));
|
||||
}
|
||||
|
||||
if (!wantsEphemeral && !ephemeralPublicKey.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{algorithm} does not carry an ephemeral public key.",
|
||||
nameof(ephemeralPublicKey));
|
||||
}
|
||||
|
||||
if (ciphertext.Length < CryptoSpec.TagSize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Ciphertext must include a {CryptoSpec.TagSize}-byte tag.",
|
||||
nameof(ciphertext));
|
||||
}
|
||||
|
||||
var total = SizeFor(algorithm, ciphertext.Length);
|
||||
if (destination.Length < total)
|
||||
{
|
||||
throw new ArgumentException($"Destination must be at least {total} bytes.", nameof(destination));
|
||||
}
|
||||
|
||||
CryptoSpec.EnvelopeMagic.CopyTo(destination);
|
||||
destination[OffsetAlgorithm] = (byte)algorithm;
|
||||
destination[OffsetFlags] = 0;
|
||||
|
||||
var cursor = HeaderSize;
|
||||
if (wantsEphemeral)
|
||||
{
|
||||
ephemeralPublicKey.CopyTo(destination[cursor..]);
|
||||
cursor += CryptoSpec.PublicKeySize;
|
||||
}
|
||||
|
||||
nonce.CopyTo(destination[cursor..]);
|
||||
cursor += nonce.Length;
|
||||
|
||||
ciphertext.CopyTo(destination[cursor..]);
|
||||
return cursor + ciphertext.Length;
|
||||
}
|
||||
|
||||
/// <summary>Writes an envelope into a new array.</summary>
|
||||
public static byte[] Write(
|
||||
CryptoSpec.AlgorithmId algorithm,
|
||||
ReadOnlySpan<byte> nonce,
|
||||
ReadOnlySpan<byte> ciphertext,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey = default)
|
||||
{
|
||||
var buffer = new byte[SizeFor(algorithm, ciphertext.Length)];
|
||||
Write(buffer, algorithm, nonce, ciphertext, ephemeralPublicKey);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an envelope without copying. Returns false for anything malformed or not fully
|
||||
/// understood, rather than throwing, because envelopes arrive from an untrusted server.
|
||||
/// </summary>
|
||||
public static bool TryRead(ReadOnlySpan<byte> envelope, out DshEnvelopeView view)
|
||||
{
|
||||
view = default;
|
||||
|
||||
if (envelope.Length < HeaderSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!envelope[..CryptoSpec.EnvelopeMagic.Length].SequenceEqual(CryptoSpec.EnvelopeMagic))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject unknown flag bits: an envelope we do not fully understand must fail closed.
|
||||
if (envelope[OffsetFlags] != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var algorithmByte = envelope[OffsetAlgorithm];
|
||||
if (!Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), algorithmByte))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var algorithm = (CryptoSpec.AlgorithmId)algorithmByte;
|
||||
if (algorithm == CryptoSpec.AlgorithmId.Unspecified)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (envelope.Length < MinimumSizeFor(algorithm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cursor = HeaderSize;
|
||||
var ephemeral = ReadOnlySpan<byte>.Empty;
|
||||
if (HasEphemeralPublicKey(algorithm))
|
||||
{
|
||||
ephemeral = envelope.Slice(cursor, CryptoSpec.PublicKeySize);
|
||||
cursor += CryptoSpec.PublicKeySize;
|
||||
}
|
||||
|
||||
var nonceSize = NonceSizeFor(algorithm);
|
||||
var nonce = envelope.Slice(cursor, nonceSize);
|
||||
cursor += nonceSize;
|
||||
|
||||
view = new DshEnvelopeView(algorithm, ephemeral, nonce, envelope[cursor..]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// A parsed view over an envelope's bytes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Holds no copies, so it must not outlive the buffer it was parsed from. Produced by
|
||||
/// <see cref="DshEnvelope.TryRead"/>.
|
||||
/// </remarks>
|
||||
[SuppressMessage(
|
||||
"Performance",
|
||||
"CA1815:Override equals and object equals operator",
|
||||
Justification = "A ref struct over borrowed spans; equality is meaningless and cannot be expressed.")]
|
||||
public readonly ref struct DshEnvelopeView
|
||||
{
|
||||
internal DshEnvelopeView(
|
||||
CryptoSpec.AlgorithmId algorithm,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey,
|
||||
ReadOnlySpan<byte> nonce,
|
||||
ReadOnlySpan<byte> ciphertext)
|
||||
{
|
||||
Algorithm = algorithm;
|
||||
EphemeralPublicKey = ephemeralPublicKey;
|
||||
Nonce = nonce;
|
||||
Ciphertext = ciphertext;
|
||||
}
|
||||
|
||||
/// <summary>Construction the envelope declares.</summary>
|
||||
public CryptoSpec.AlgorithmId Algorithm { get; }
|
||||
|
||||
/// <summary>Ephemeral X25519 public key, empty unless the algorithm carries one.</summary>
|
||||
public ReadOnlySpan<byte> EphemeralPublicKey { get; }
|
||||
|
||||
/// <summary>Nonce.</summary>
|
||||
public ReadOnlySpan<byte> Nonce { get; }
|
||||
|
||||
/// <summary>Ciphertext, including its trailing authentication tag.</summary>
|
||||
public ReadOnlySpan<byte> Ciphertext { get; }
|
||||
}
|
||||
@@ -19,6 +19,21 @@
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "Direct",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user