Public Access
Add the client key hierarchy: bundle, master key, vault and item keys
Everything crypto.md section 3 describes below the identity key, which is what the desktop client needs before it can enroll or store anything. DshAad gives every descriptor in the specification a named constructor. The AAD binding is the most valuable structural property in the design -- it is what stops a server holding every ciphertext from pasting one row's bytes onto another, rolling a row back to a superseded generation, or replaying a revoked grant -- and all of it depends on callers getting purpose, resource type and ids right at every single call site. Hand-constructing descriptors makes that a matter of care; picking a method name makes it a matter of spelling. UserSecretBundle holds private keys in libsodium's guarded, mlocked allocations rather than a byte[], so they are not paged out and do not land in a core dump. They are created exportable, deliberately: re-wrapping the same bundle for a passphrase change or a new device needs to re-encode it, and the alternative -- a long-lived managed array so the keys need not be exportable -- keeps the identical secret in strictly worse memory. Every export is into a buffer zeroed before the method returns. Two spec changes, both found by implementing it, which is the argument for writing code before calling a spec frozen: - MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK cannot be expanded with SHA-512 at all. Widening it keeps the specified primitive; the alternatives were dropping to SHA-256 or adding an Extract step that conditions nothing. - The bundle encoding is a fixed 92-byte layout rather than canonical CBOR. Canonicality is not load-bearing here -- unlike a key statement the bundle is never hashed or signed, only encrypted -- so CBOR's one advantage does not apply, while its canonicalisation rules are a real source of cross-implementation disagreement. It also costs a dependency System.Formats.Cbor is not in the shared framework. Safe to change now and not later: no bundle has ever been stored. 53 new tests. The encoding is checked against an independent codec written in the test rather than by round-tripping production code against itself -- a round trip passes just as happily when both directions are wrong the same way, and this format cannot change after one bundle is stored. The pinned 92-byte hex constant is the golden vector for the layout. Most of the rest are negative, because a binding is only demonstrated by the substitutions that fail: a wrap for another user, a grant from a superseded generation, a payload pasted onto another item, a metadata blob offered as a payload, a version rolled back.
This commit is contained in:
1 parent
885fb17bdc
commit
e65d738912
9 files changed
+1763
-3
No files matched your search
@@ -0,0 +1,322 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// A user's identity key pair, in the form that gets wrapped. See docs/crypto.md §3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the load-bearing structural choice of the whole key hierarchy. The bundle is stored
|
||||
/// server-side as N independent wraps of the <em>same</em> bytes — one per passphrase, device,
|
||||
/// recovery code and escrow key — so a passphrase change re-wraps about a hundred bytes and updates
|
||||
/// one row. No vault data is re-encrypted, and no other member is involved. Adding a device or a
|
||||
/// recovery code is likewise one more wrap row. Encrypting vault keys under the passphrase key
|
||||
/// directly would have made every one of those a fan-out over the entire vault.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Private keys live in libsodium's guarded, <c>mlock</c>ed allocations rather than in a
|
||||
/// <see cref="byte"/> array, so they are not paged to disk and do not appear in a core dump. They
|
||||
/// are created exportable, which is a deliberate trade: re-wrapping the same bundle for a passphrase
|
||||
/// change or a new device requires re-encoding it, and the alternative — holding the plaintext in a
|
||||
/// long-lived managed array so the keys need not be exportable — keeps the identical secret in
|
||||
/// strictly worse memory. Every export here is into a buffer that is zeroed before the method
|
||||
/// returns.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class UserSecretBundle : IDisposable
|
||||
{
|
||||
/// <summary>Domain-separating prefix of the encoding.</summary>
|
||||
public static ReadOnlySpan<byte> Label => "dsh1/bundle/v1"u8;
|
||||
|
||||
/// <summary>Encoding version this implementation writes.</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Total encoded length. Fixed, because every field is fixed-width.
|
||||
/// </summary>
|
||||
public const int EncodedLength =
|
||||
14 // Label
|
||||
+ sizeof(ushort) // version
|
||||
+ sizeof(uint) // key generation
|
||||
+ sizeof(long) // created at, Unix milliseconds
|
||||
+ (CryptoSpec.SymmetricKeySize * 2); // X25519 then Ed25519 private keys
|
||||
|
||||
private const int OffsetVersion = 14;
|
||||
private const int OffsetKeyGeneration = 16;
|
||||
private const int OffsetCreatedAt = 20;
|
||||
private const int OffsetEncryptionKey = 28;
|
||||
private const int OffsetSigningKey = 60;
|
||||
|
||||
private readonly Key encryptionKey;
|
||||
private readonly Key signingKey;
|
||||
private bool disposed;
|
||||
|
||||
private UserSecretBundle(Key encryptionKey, Key signingKey, uint keyGeneration, DateTimeOffset createdAt)
|
||||
{
|
||||
this.encryptionKey = encryptionKey;
|
||||
this.signingKey = signingKey;
|
||||
KeyGeneration = keyGeneration;
|
||||
CreatedAt = createdAt;
|
||||
|
||||
EncryptionPublicKey = encryptionKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
SigningPublicKey = signingKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
}
|
||||
|
||||
/// <summary>Generation of this identity key pair, starting at 1.</summary>
|
||||
public uint KeyGeneration { get; }
|
||||
|
||||
/// <summary>When the keys were generated, truncated to milliseconds.</summary>
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>X25519 public key, 32 bytes. Safe to publish.</summary>
|
||||
public byte[] EncryptionPublicKey { get; }
|
||||
|
||||
/// <summary>Ed25519 public key, 32 bytes. Safe to publish.</summary>
|
||||
public byte[] SigningPublicKey { get; }
|
||||
|
||||
/// <summary>The X25519 key, for unwrapping vault keys sealed to this user.</summary>
|
||||
public Key EncryptionKey => Alive().encryptionKey;
|
||||
|
||||
/// <summary>The Ed25519 key, for signing key statements and grants.</summary>
|
||||
public Key SigningKey => Alive().signingKey;
|
||||
|
||||
/// <summary>Generates a fresh identity key pair.</summary>
|
||||
/// <param name="createdAt">Creation timestamp; truncated to milliseconds.</param>
|
||||
/// <param name="keyGeneration">Generation number. 1 at enrollment.</param>
|
||||
public static UserSecretBundle Create(DateTimeOffset createdAt, uint keyGeneration = 1)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(keyGeneration, 1u);
|
||||
|
||||
var parameters = new KeyCreationParameters
|
||||
{
|
||||
ExportPolicy = KeyExportPolicies.AllowPlaintextExport,
|
||||
};
|
||||
|
||||
var encryption = Key.Create(KeyAgreementAlgorithm.X25519, parameters);
|
||||
|
||||
Key signing;
|
||||
try
|
||||
{
|
||||
signing = Key.Create(SignatureAlgorithm.Ed25519, parameters);
|
||||
}
|
||||
catch
|
||||
{
|
||||
encryption.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new UserSecretBundle(
|
||||
encryption,
|
||||
signing,
|
||||
keyGeneration,
|
||||
KeyLogChain.TruncateTimestamp(createdAt));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the bundle under a symmetric key-encryption key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used for the passphrase and recovery-code wraps, whose keys are derived rather than sealed
|
||||
/// to. The plaintext exists only inside this method and is zeroed before it returns.
|
||||
/// </remarks>
|
||||
public byte[] WrapUnder(ReadOnlySpan<byte> keyEncryptionKey, in AadDescriptor descriptor)
|
||||
{
|
||||
Alive();
|
||||
|
||||
Span<byte> plaintext = stackalloc byte[EncodedLength];
|
||||
try
|
||||
{
|
||||
Encode(plaintext);
|
||||
return DshCrypto.Seal(keyEncryptionKey, plaintext, descriptor);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seals the bundle to a public key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used for device and escrow wraps. Anonymous-sender by construction, which is fine here: the
|
||||
/// recipient is the same user, so there is nothing to attribute.
|
||||
/// </remarks>
|
||||
public byte[] SealTo(ReadOnlySpan<byte> recipientPublicKey, in AadDescriptor descriptor)
|
||||
{
|
||||
Alive();
|
||||
|
||||
Span<byte> plaintext = stackalloc byte[EncodedLength];
|
||||
try
|
||||
{
|
||||
Encode(plaintext);
|
||||
return DshCrypto.SealTo(recipientPublicKey, plaintext, descriptor);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a wrap made by <see cref="WrapUnder"/>.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The bundle, or <see langword="null"/> if the key is wrong, the descriptor does not match, or
|
||||
/// the encoding is unrecognised. Never throws on bad input: the wrap arrives from a server that
|
||||
/// is explicitly not trusted, so a failed tag is an expected outcome — most often simply a
|
||||
/// mistyped passphrase.
|
||||
/// </returns>
|
||||
public static UserSecretBundle? TryOpenUnder(
|
||||
ReadOnlySpan<byte> keyEncryptionKey,
|
||||
ReadOnlySpan<byte> envelope,
|
||||
in AadDescriptor descriptor)
|
||||
{
|
||||
var plaintext = DshCrypto.Open(keyEncryptionKey, envelope, descriptor);
|
||||
return FromPlaintext(plaintext);
|
||||
}
|
||||
|
||||
/// <summary>Opens a wrap made by <see cref="SealTo"/>, using the recipient's private key.</summary>
|
||||
public static UserSecretBundle? TryOpenSealed(
|
||||
Key recipientKey,
|
||||
ReadOnlySpan<byte> envelope,
|
||||
in AadDescriptor descriptor)
|
||||
{
|
||||
var plaintext = DshCrypto.OpenSealed(recipientKey, envelope, descriptor);
|
||||
return FromPlaintext(plaintext);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
encryptionKey.Dispose();
|
||||
signingKey.Dispose();
|
||||
}
|
||||
|
||||
private void Encode(Span<byte> destination)
|
||||
{
|
||||
Label.CopyTo(destination);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(destination[OffsetVersion..], CurrentVersion);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[OffsetKeyGeneration..], KeyGeneration);
|
||||
BinaryPrimitives.WriteInt64BigEndian(destination[OffsetCreatedAt..], CreatedAt.ToUnixTimeMilliseconds());
|
||||
|
||||
// Ed25519's raw private key is the 32-byte seed, and X25519's is the 32-byte scalar, so both
|
||||
// fit the fixed layout. Exported straight into the caller's buffer, which is zeroed there.
|
||||
ExportInto(encryptionKey, destination.Slice(OffsetEncryptionKey, CryptoSpec.SymmetricKeySize));
|
||||
ExportInto(signingKey, destination.Slice(OffsetSigningKey, CryptoSpec.SymmetricKeySize));
|
||||
}
|
||||
|
||||
private static void ExportInto(Key key, Span<byte> destination)
|
||||
{
|
||||
var exported = key.Export(KeyBlobFormat.RawPrivateKey);
|
||||
try
|
||||
{
|
||||
if (exported.Length != destination.Length)
|
||||
{
|
||||
throw new CryptographicException(
|
||||
$"Expected a {destination.Length}-byte raw private key, got {exported.Length}.");
|
||||
}
|
||||
|
||||
exported.CopyTo(destination);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(exported);
|
||||
}
|
||||
}
|
||||
|
||||
private static UserSecretBundle? FromPlaintext(byte[]? plaintext)
|
||||
{
|
||||
if (plaintext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return TryDecode(plaintext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Fails closed on anything unexpected. A bundle is the root of everything a user can read, so
|
||||
/// guessing at a malformed one is never the right move.
|
||||
/// </remarks>
|
||||
private static bool IsWellFormed(ReadOnlySpan<byte> encoded) =>
|
||||
encoded.Length == EncodedLength
|
||||
&& encoded[..Label.Length].SequenceEqual(Label)
|
||||
&& BinaryPrimitives.ReadUInt16BigEndian(encoded[OffsetVersion..]) == CurrentVersion
|
||||
&& BinaryPrimitives.ReadUInt32BigEndian(encoded[OffsetKeyGeneration..]) >= 1;
|
||||
|
||||
private static UserSecretBundle? TryDecode(ReadOnlySpan<byte> encoded)
|
||||
{
|
||||
if (!IsWellFormed(encoded))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keyGeneration = BinaryPrimitives.ReadUInt32BigEndian(encoded[OffsetKeyGeneration..]);
|
||||
|
||||
var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(
|
||||
BinaryPrimitives.ReadInt64BigEndian(encoded[OffsetCreatedAt..]));
|
||||
|
||||
var parameters = new KeyCreationParameters
|
||||
{
|
||||
ExportPolicy = KeyExportPolicies.AllowPlaintextExport,
|
||||
};
|
||||
|
||||
Key? encryption = null;
|
||||
Key? signing = null;
|
||||
|
||||
try
|
||||
{
|
||||
encryption = Key.Import(
|
||||
KeyAgreementAlgorithm.X25519,
|
||||
encoded.Slice(OffsetEncryptionKey, CryptoSpec.SymmetricKeySize),
|
||||
KeyBlobFormat.RawPrivateKey,
|
||||
parameters);
|
||||
|
||||
signing = Key.Import(
|
||||
SignatureAlgorithm.Ed25519,
|
||||
encoded.Slice(OffsetSigningKey, CryptoSpec.SymmetricKeySize),
|
||||
KeyBlobFormat.RawPrivateKey,
|
||||
parameters);
|
||||
|
||||
var bundle = new UserSecretBundle(encryption, signing, keyGeneration, createdAt);
|
||||
|
||||
// Ownership transferred; do not dispose in the catch below.
|
||||
encryption = null;
|
||||
signing = null;
|
||||
|
||||
return bundle;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
encryption?.Dispose();
|
||||
signing?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private UserSecretBundle Alive()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user