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:
+44
-3
@@ -94,15 +94,14 @@ OIDC access-token gate plus client-side backoff.
|
|||||||
|
|
||||||
```
|
```
|
||||||
vault passphrase
|
vault passphrase
|
||||||
│ Argon2id(salt, m=256 MiB, t=4, p=1) → 32 B
|
│ Argon2id(salt, m=256 MiB, t=4, p=1) → 64 B
|
||||||
▼
|
▼
|
||||||
MK — master key, RAM only, never persisted, never transmitted
|
MK — master key, RAM only, never persisted, never transmitted
|
||||||
│ HKDF-SHA512-Expand with domain-separated info labels
|
│ HKDF-SHA512-Expand with domain-separated info labels
|
||||||
├── KEK_pp info = "dsh1/kek/passphrase/v1" 32 B
|
├── KEK_pp info = "dsh1/kek/passphrase/v1" 32 B
|
||||||
└── LocalCacheKey info = "dsh1/localcache/v1" 32 B
|
└── LocalCacheKey info = "dsh1/localcache/v1" 32 B
|
||||||
▼
|
▼
|
||||||
UserSecretBundle — canonical CBOR, ~200 B
|
UserSecretBundle — fixed binary, 92 B (see 3.1)
|
||||||
{ v: 1, x25519_sk: 32 B, ed25519_sk: 32 B, created: <unix s>, keyGeneration: <u32> }
|
|
||||||
stored server-side as N independent wraps of the SAME bundle:
|
stored server-side as N independent wraps of the SAME bundle:
|
||||||
kind=passphrase → symmetric AEAD under KEK_pp
|
kind=passphrase → symmetric AEAD under KEK_pp
|
||||||
kind=device → SealTo(device_x25519_pk) one row per enrolled device
|
kind=device → SealTo(device_x25519_pk) one row per enrolled device
|
||||||
@@ -119,6 +118,48 @@ item plaintext — password, private key, key passphrase, TOTP seed, encrypted m
|
|||||||
XChaCha20-Poly1305(DK, plaintext, aad)
|
XChaCha20-Poly1305(DK, plaintext, aad)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Changed 2026-07-28: MK is 64 bytes, not 32.** Skipping HKDF-Extract — correct, because an
|
||||||
|
> Argon2id output is already uniformly random, per RFC 5869 §3.3 — means MK *is* the PRK of the
|
||||||
|
> expansion. .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 MK keeps the specified primitive; the alternatives were
|
||||||
|
> dropping to SHA-256 or adding an Extract step that conditions nothing. Extra Argon2id output is
|
||||||
|
> free. Discovered by implementing it, which is the argument for writing the code before declaring a
|
||||||
|
> spec frozen.
|
||||||
|
|
||||||
|
### 3.1 UserSecretBundle encoding
|
||||||
|
|
||||||
|
> **Changed 2026-07-28**, from "canonical CBOR" to the fixed layout below. This reverses a stated
|
||||||
|
> choice rather than clarifying an unstated one, so the reasoning is recorded here. It is safe to
|
||||||
|
> make now and would not be later: nothing has been implemented against CBOR and no bundle has ever
|
||||||
|
> been stored, so there is nothing to migrate.
|
||||||
|
|
||||||
|
```
|
||||||
|
bundle = "dsh1/bundle/v1" 14 bytes, literal
|
||||||
|
|| u16 version big-endian
|
||||||
|
|| u32 keyGeneration big-endian
|
||||||
|
|| i64 createdAt big-endian, Unix milliseconds, UTC
|
||||||
|
|| x25519_sk 32 bytes, raw scalar
|
||||||
|
|| ed25519_sk 32 bytes, raw seed
|
||||||
|
= 92 bytes, fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
Three reasons for the change:
|
||||||
|
|
||||||
|
- **Canonicality is not load-bearing here.** Unlike a key statement (§7.1), the bundle is never
|
||||||
|
hashed or signed — only encrypted. Any deterministic encoding is sufficient, so the one property
|
||||||
|
CBOR was chosen for does not apply. Canonical CBOR's rules (definite-length maps, sorted keys,
|
||||||
|
shortest-form integers) are a source of cross-implementation disagreement bought for nothing.
|
||||||
|
- **It costs a dependency.** `System.Formats.Cbor` is not in the .NET 10 shared framework. Keeping
|
||||||
|
`DodoSSH.Crypto` down to NSec alone matters for a client that wants trimming.
|
||||||
|
- **Consistency.** §7.1 and §7.2 already establish a fixed big-endian layout with an explicit
|
||||||
|
domain label. One convention to learn and to review beats two.
|
||||||
|
|
||||||
|
Forward compatibility is unaffected: the bundle is versioned, and only our own clients ever read it,
|
||||||
|
so a new field means bumping `version` — which a fixed layout handles as well as CBOR would.
|
||||||
|
|
||||||
|
Readers **must** reject a bundle whose length, label or version does not match exactly. This is the
|
||||||
|
root of everything a user can read; there is no safe way to guess at a malformed one.
|
||||||
|
|
||||||
### Why the bundle is wrapped many ways
|
### Why the bundle is wrapped many ways
|
||||||
|
|
||||||
This is the load-bearing structural choice. Because every wrap protects the *same* bundle:
|
This is the load-bearing structural choice. Because every wrap protects the *same* bundle:
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ public static class CryptoSpec
|
|||||||
/// <summary>Size of a symmetric content or wrapping key.</summary>
|
/// <summary>Size of a symmetric content or wrapping key.</summary>
|
||||||
public const int SymmetricKeySize = 32;
|
public const int SymmetricKeySize = 32;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Size of the passphrase-derived master key, which subkeys are expanded from.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 64 bytes, not 32, because it is the PRK of an HKDF-SHA512 expansion and .NET's
|
||||||
|
/// <c>HKDF.Expand</c> rejects a PRK shorter than the hash output. See docs/crypto.md §3.
|
||||||
|
/// </remarks>
|
||||||
|
public const int MasterKeySize = 64;
|
||||||
|
|
||||||
/// <summary>Size of an X25519 or Ed25519 public key.</summary>
|
/// <summary>Size of an X25519 or Ed25519 public key.</summary>
|
||||||
public const int PublicKeySize = 32;
|
public const int PublicKeySize = 32;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Named constructors for every AAD descriptor the specification defines. See docs/crypto.md §4.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The AAD binding is the most valuable structural property in the design: it is what stops a
|
||||||
|
/// server that holds every ciphertext from pasting one row's bytes onto another, rolling a row back
|
||||||
|
/// to a superseded key generation, replaying a revoked grant, or substituting a metadata blob for a
|
||||||
|
/// payload. None of that follows from access control.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// All of it also depends on callers getting the purpose, resource type and ids right at every call
|
||||||
|
/// site. Hand-constructing <see cref="AadDescriptor"/> makes that a matter of care; going through
|
||||||
|
/// these factories makes it a matter of picking the right method name, which is the difference
|
||||||
|
/// between a property that holds and one that mostly holds.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class DshAad
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a wrap of the user's secret bundle to that user and key generation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every wrap kind — passphrase, device, recovery, escrow — protects the same bundle and uses
|
||||||
|
/// this same descriptor. In particular a device wrap is <em>not</em> bound to its device row:
|
||||||
|
/// the client cannot be, since the server assigns the device id after the wrap is built, and it
|
||||||
|
/// need not be, because the wrap is sealed to that device's public key. Relocating the row to
|
||||||
|
/// another device gains an attacker nothing they could open.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Including the generation means a server cannot serve back a superseded bundle after a key
|
||||||
|
/// rotation. The client learns the current generation from <c>/me</c>, so a lie there produces
|
||||||
|
/// a tag failure rather than a silent downgrade.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="userId">
|
||||||
|
/// The user's identifier, as assigned by the server. A client must therefore read
|
||||||
|
/// <c>/api/v1/me</c> — which provisions the account and returns its id even before enrollment —
|
||||||
|
/// before it can build a wrap.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="keyGeneration">Generation of the identity key pair inside the bundle.</param>
|
||||||
|
public static AadDescriptor UserSecretBundle(Guid userId, uint keyGeneration = 1) =>
|
||||||
|
AadDescriptor.Create(
|
||||||
|
CryptoSpec.AadPurpose.UserSecretBundle,
|
||||||
|
CryptoSpec.AadResourceType.User,
|
||||||
|
userId,
|
||||||
|
keyGeneration: keyGeneration);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a vault key grant to its vault and generation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The generation is what makes revocation stick against a malicious server: after a rekey it
|
||||||
|
/// cannot re-serve a previous generation's grant to a removed member, because the AAD no longer
|
||||||
|
/// matches. That is a bound on future reads only — anything already downloaded is already gone,
|
||||||
|
/// which is why offboarding means rotating the SSH credentials themselves. See ADR 0001.
|
||||||
|
/// </remarks>
|
||||||
|
public static AadDescriptor VaultKeyGrant(Guid vaultId, uint keyGeneration) =>
|
||||||
|
AadDescriptor.Create(
|
||||||
|
CryptoSpec.AadPurpose.VaultKeyGrant,
|
||||||
|
CryptoSpec.AadResourceType.Vault,
|
||||||
|
vaultId,
|
||||||
|
keyGeneration: keyGeneration);
|
||||||
|
|
||||||
|
/// <summary>Binds an item's data key, wrapped under the vault key, to that item and version.</summary>
|
||||||
|
public static AadDescriptor ItemDataKey(
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid itemId,
|
||||||
|
uint keyGeneration,
|
||||||
|
uint itemVersion) =>
|
||||||
|
AadDescriptor.Create(
|
||||||
|
CryptoSpec.AadPurpose.ItemDataKey,
|
||||||
|
resourceType,
|
||||||
|
itemId,
|
||||||
|
keyGeneration: keyGeneration,
|
||||||
|
itemVersion: itemVersion);
|
||||||
|
|
||||||
|
/// <summary>Binds an item's payload to the item, its data key, generation and version.</summary>
|
||||||
|
/// <param name="resourceType">What kind of item this is.</param>
|
||||||
|
/// <param name="itemId">The item.</param>
|
||||||
|
/// <param name="dataKeyId">
|
||||||
|
/// The data key the payload is under — the <c>content_key_id</c> column. Reserved so that
|
||||||
|
/// per-item grants can make item-level access cryptographic in M5 without a migration.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="keyGeneration">Vault key generation in force.</param>
|
||||||
|
/// <param name="itemVersion">Item version, so an earlier version cannot be replayed.</param>
|
||||||
|
public static AadDescriptor ItemPayload(
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid itemId,
|
||||||
|
Guid dataKeyId,
|
||||||
|
uint keyGeneration,
|
||||||
|
uint itemVersion) =>
|
||||||
|
AadDescriptor.Create(
|
||||||
|
CryptoSpec.AadPurpose.ItemPayload,
|
||||||
|
resourceType,
|
||||||
|
itemId,
|
||||||
|
dataKeyId,
|
||||||
|
keyGeneration,
|
||||||
|
itemVersion);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds an item's encrypted metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A distinct purpose from <see cref="ItemPayload"/> on the same item, which is what stops a
|
||||||
|
/// server swapping one blob for the other — a substitution that would otherwise leave a client
|
||||||
|
/// decrypting a password where it expected a display name.
|
||||||
|
/// </remarks>
|
||||||
|
public static AadDescriptor ItemMetadata(
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid itemId,
|
||||||
|
Guid dataKeyId,
|
||||||
|
uint keyGeneration,
|
||||||
|
uint itemVersion) =>
|
||||||
|
AadDescriptor.Create(
|
||||||
|
CryptoSpec.AadPurpose.ItemMetadata,
|
||||||
|
resourceType,
|
||||||
|
itemId,
|
||||||
|
dataKeyId,
|
||||||
|
keyGeneration,
|
||||||
|
itemVersion);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds a record in the client's own on-disk cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Separate from every server-side purpose so a cache record can never be accepted as vault
|
||||||
|
/// content, nor the reverse. The cache is local, so the adversary here is another process on
|
||||||
|
/// the same machine rather than the server.
|
||||||
|
/// </remarks>
|
||||||
|
public static AadDescriptor LocalCache(Guid userId) =>
|
||||||
|
AadDescriptor.Create(
|
||||||
|
CryptoSpec.AadPurpose.LocalCache,
|
||||||
|
CryptoSpec.AadResourceType.User,
|
||||||
|
userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-item data keys and the payloads they protect. See docs/crypto.md §3.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every item version gets its own 32-byte data key, wrapped under the vault key. Four reasons, all
|
||||||
|
/// of which matter: rotating a vault key re-wraps N × 32 bytes and never touches a content blob, so a
|
||||||
|
/// 10,000-item vault rotates in a few hundred kilobytes; a single item can be re-wrapped to another
|
||||||
|
/// key without disturbing the rest; each key encrypts about one message, which makes nonce-collision
|
||||||
|
/// analysis moot; and a new version gets a new key, so earlier ciphertext stays independently
|
||||||
|
/// decryptable for history and undo.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Be plain about what this does <em>not</em> yet buy. Data keys are wrapped under the vault key, not
|
||||||
|
/// to individual users, so anyone holding the vault key can decrypt any ciphertext they obtain. Until
|
||||||
|
/// per-item grants land in M5, an item-level ACL is access control, not cryptographic isolation. The
|
||||||
|
/// <c>content_key_id</c> column exists from the first migration so that change needs no migration.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ItemKeys
|
||||||
|
{
|
||||||
|
/// <summary>Generates a fresh data key. The caller owns and must zero it.</summary>
|
||||||
|
public static byte[] CreateDataKey() =>
|
||||||
|
RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize);
|
||||||
|
|
||||||
|
/// <summary>Wraps an item's data key under the vault key.</summary>
|
||||||
|
/// <param name="dataKey">The 32-byte data key.</param>
|
||||||
|
/// <param name="vaultKey">The vault key it is wrapped under.</param>
|
||||||
|
/// <param name="resourceType">What kind of item this is.</param>
|
||||||
|
/// <param name="itemId">The item.</param>
|
||||||
|
/// <param name="keyGeneration">Vault key generation in force.</param>
|
||||||
|
/// <param name="itemVersion">Item version.</param>
|
||||||
|
public static byte[] WrapDataKey(
|
||||||
|
ReadOnlySpan<byte> dataKey,
|
||||||
|
ReadOnlySpan<byte> vaultKey,
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid itemId,
|
||||||
|
uint keyGeneration,
|
||||||
|
uint itemVersion)
|
||||||
|
{
|
||||||
|
RequireDataKey(dataKey);
|
||||||
|
VaultKeys.RequireVaultKey(vaultKey);
|
||||||
|
|
||||||
|
return DshCrypto.Seal(
|
||||||
|
vaultKey,
|
||||||
|
dataKey,
|
||||||
|
DshAad.ItemDataKey(resourceType, itemId, keyGeneration, itemVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens a wrapped data key.</summary>
|
||||||
|
/// <returns>The data key, or <see langword="null"/> if it does not belong to this item, version
|
||||||
|
/// or generation.</returns>
|
||||||
|
public static byte[]? TryUnwrapDataKey(
|
||||||
|
ReadOnlySpan<byte> vaultKey,
|
||||||
|
ReadOnlySpan<byte> envelope,
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid itemId,
|
||||||
|
uint keyGeneration,
|
||||||
|
uint itemVersion)
|
||||||
|
{
|
||||||
|
VaultKeys.RequireVaultKey(vaultKey);
|
||||||
|
|
||||||
|
var plaintext = DshCrypto.Open(
|
||||||
|
vaultKey,
|
||||||
|
envelope,
|
||||||
|
DshAad.ItemDataKey(resourceType, itemId, keyGeneration, itemVersion));
|
||||||
|
|
||||||
|
if (plaintext is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plaintext.Length != CryptoSpec.SymmetricKeySize)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(plaintext);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encrypts an item's payload under its data key.</summary>
|
||||||
|
/// <param name="dataKey">The item's data key.</param>
|
||||||
|
/// <param name="plaintext">The payload.</param>
|
||||||
|
/// <param name="resourceType">What kind of item this is.</param>
|
||||||
|
/// <param name="itemId">The item.</param>
|
||||||
|
/// <param name="dataKeyId">The data key's identifier, stored as <c>content_key_id</c>.</param>
|
||||||
|
/// <param name="keyGeneration">Vault key generation in force.</param>
|
||||||
|
/// <param name="itemVersion">Item version.</param>
|
||||||
|
public static byte[] SealPayload(
|
||||||
|
ReadOnlySpan<byte> dataKey,
|
||||||
|
ReadOnlySpan<byte> plaintext,
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid itemId,
|
||||||
|
Guid dataKeyId,
|
||||||
|
uint keyGeneration,
|
||||||
|
uint itemVersion)
|
||||||
|
{
|
||||||
|
RequireDataKey(dataKey);
|
||||||
|
|
||||||
|
return DshCrypto.Seal(
|
||||||
|
dataKey,
|
||||||
|
plaintext,
|
||||||
|
DshAad.ItemPayload(resourceType, itemId, dataKeyId, keyGeneration, itemVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrypts an item's payload.</summary>
|
||||||
|
/// <returns>The payload, or <see langword="null"/> on any mismatch. A null here after a
|
||||||
|
/// successful data key unwrap means the server moved, rolled back or substituted the blob.</returns>
|
||||||
|
public static byte[]? TryOpenPayload(
|
||||||
|
ReadOnlySpan<byte> dataKey,
|
||||||
|
ReadOnlySpan<byte> envelope,
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid itemId,
|
||||||
|
Guid dataKeyId,
|
||||||
|
uint keyGeneration,
|
||||||
|
uint itemVersion)
|
||||||
|
{
|
||||||
|
RequireDataKey(dataKey);
|
||||||
|
|
||||||
|
return DshCrypto.Open(
|
||||||
|
dataKey,
|
||||||
|
envelope,
|
||||||
|
DshAad.ItemPayload(resourceType, itemId, dataKeyId, keyGeneration, itemVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireDataKey(ReadOnlySpan<byte> dataKey)
|
||||||
|
{
|
||||||
|
if (dataKey.Length != CryptoSpec.SymmetricKeySize)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"A data key is {CryptoSpec.SymmetricKeySize} bytes, got {dataKey.Length}.",
|
||||||
|
nameof(dataKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The master key derived from a vault passphrase, and the subkeys it yields. See docs/crypto.md §3.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// RAM only. Never persisted, never transmitted, never written to a keystore. What <em>is</em>
|
||||||
|
/// cached locally is the KDF salt and the wrapped bundle, because unlock has to work with no
|
||||||
|
/// network: a salt fetched at unlock time would mean an offline launch cannot open the vault, which
|
||||||
|
/// is the single most common thing a user does on a plane.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Argon2id parameters travel with each wrap rather than being global, so raising them later is a
|
||||||
|
/// per-user migration at next unlock rather than a breaking change — an older client can still open
|
||||||
|
/// its own wrap. See docs/crypto.md §2 for why the memory unit needs guarding.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MasterKey : IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] material = new byte[CryptoSpec.MasterKeySize];
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
private MasterKey(ReadOnlySpan<byte> derived) => derived.CopyTo(material);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stretches a passphrase into the master key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The cost is deliberately high enough to be noticeable — a few hundred milliseconds on a
|
||||||
|
/// desktop at the default profile — because this is the only thing standing between a stolen
|
||||||
|
/// database dump and every credential in the vault.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="passphrase">The user's vault passphrase.</param>
|
||||||
|
/// <param name="salt">Per-wrap salt, at least 16 bytes. Not a secret.</param>
|
||||||
|
/// <param name="profile">Argon2id cost parameters.</param>
|
||||||
|
public static MasterKey Derive(string passphrase, ReadOnlySpan<byte> salt, Argon2Profile profile)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(passphrase);
|
||||||
|
ArgumentNullException.ThrowIfNull(profile);
|
||||||
|
|
||||||
|
if (salt.Length < CryptoSpec.SaltSize)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Salt must be at least {CryptoSpec.SaltSize} bytes, got {salt.Length}.",
|
||||||
|
nameof(salt));
|
||||||
|
}
|
||||||
|
|
||||||
|
var derived = profile
|
||||||
|
.CreateAlgorithm()
|
||||||
|
.DeriveBytes(passphrase, salt, CryptoSpec.MasterKeySize);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new MasterKey(derived);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(derived);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the key that encrypts the client's on-disk cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Domain-separated from the bundle's key-encryption key by its HKDF info label, so a cache
|
||||||
|
/// record can never be opened with the wrap key or the reverse — the two live in very different
|
||||||
|
/// threat models and must not share a key.
|
||||||
|
/// </remarks>
|
||||||
|
public void DeriveLocalCacheKey(Span<byte> destination) =>
|
||||||
|
DeriveSubkey(CryptoSpec.DerivationLabels.LocalCache, destination);
|
||||||
|
|
||||||
|
/// <summary>Wraps a bundle under the passphrase-derived key-encryption key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The key-encryption key is never handed out. Callers that only need to wrap or unwrap should
|
||||||
|
/// not be holding it, and one that does is one more place it can be leaked or logged.
|
||||||
|
/// </remarks>
|
||||||
|
public byte[] WrapBundle(UserSecretBundle bundle, in AadDescriptor descriptor)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bundle);
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek);
|
||||||
|
return bundle.WrapUnder(kek, descriptor);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(kek);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a bundle wrapped by <see cref="WrapBundle"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The bundle, or <see langword="null"/> when the passphrase is wrong or the wrap does not
|
||||||
|
/// belong to this descriptor. A wrong passphrase is the overwhelmingly common case, so it is a
|
||||||
|
/// return value rather than an exception.
|
||||||
|
/// </returns>
|
||||||
|
public UserSecretBundle? TryOpenBundle(ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DeriveSubkey(CryptoSpec.DerivationLabels.PassphraseKek, kek);
|
||||||
|
return UserSecretBundle.TryOpenUnder(kek, envelope, descriptor);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(kek);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// HKDF-Expand rather than the full extract-then-expand: the master key is already a uniformly
|
||||||
|
/// random Argon2id output, so there is no low-entropy input left for an extract step to
|
||||||
|
/// condition. RFC 5869 §3.3 covers exactly this case.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// That choice is why the master key is <see cref="CryptoSpec.MasterKeySize"/> bytes rather than
|
||||||
|
/// 32: skipping extract means the master key <em>is</em> the PRK, and .NET's
|
||||||
|
/// <c>HKDF.Expand</c> rejects a PRK shorter than the hash output.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void DeriveSubkey(ReadOnlySpan<byte> info, Span<byte> destination)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
if (destination.Length != CryptoSpec.SymmetricKeySize)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Subkeys are {CryptoSpec.SymmetricKeySize} bytes, got {destination.Length}.",
|
||||||
|
nameof(destination));
|
||||||
|
}
|
||||||
|
|
||||||
|
HKDF.Expand(HashAlgorithmName.SHA512, material, destination, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using NSec.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vault keys and the grants that wrap them to members. See docs/crypto.md §3 and §6.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A vault key is 32 random bytes, one per vault per generation. It never leaves a client in
|
||||||
|
/// plaintext: each member holds it sealed to their own X25519 key, which is why the server can store
|
||||||
|
/// every grant and read none of them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Sealing is anonymous-sender by construction, so a grant proves nothing about who created it. That
|
||||||
|
/// is why every grant additionally carries a detached Ed25519 signature from the granter — without
|
||||||
|
/// one, a server could fabricate a grant containing a key of its own choosing and the recipient
|
||||||
|
/// would have no way to tell. The signature makes it detectable and attributable; it cannot make it
|
||||||
|
/// impossible, because verification would require the server to hold the key.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class VaultKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a fresh vault key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returned as an array the caller owns and must zero. There is no wrapper type here on purpose:
|
||||||
|
/// the key is needed as a raw span by every item operation, and a type that had to be unwrapped
|
||||||
|
/// at each call site would be unwrapped carelessly.
|
||||||
|
/// </remarks>
|
||||||
|
public static byte[] Create() =>
|
||||||
|
RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seals a vault key to a member's encryption key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="vaultKey">The 32-byte vault key.</param>
|
||||||
|
/// <param name="recipientEncryptionPublicKey">
|
||||||
|
/// The recipient's X25519 public key. <b>Verify this key before calling.</b> Wrapping to an
|
||||||
|
/// unverified key is the one mistake that undoes end-to-end encryption entirely — check the
|
||||||
|
/// identity-provider binding, the pinned fingerprint and the key log head first. See ADR 0001.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="vaultId">The vault, which the AAD binds this grant to.</param>
|
||||||
|
/// <param name="keyGeneration">
|
||||||
|
/// The generation in force. Part of the AAD, so a server cannot re-serve a superseded
|
||||||
|
/// generation's grant after a rekey.
|
||||||
|
/// </param>
|
||||||
|
public static byte[] WrapTo(
|
||||||
|
ReadOnlySpan<byte> vaultKey,
|
||||||
|
ReadOnlySpan<byte> recipientEncryptionPublicKey,
|
||||||
|
Guid vaultId,
|
||||||
|
uint keyGeneration)
|
||||||
|
{
|
||||||
|
RequireVaultKey(vaultKey);
|
||||||
|
|
||||||
|
return DshCrypto.SealTo(
|
||||||
|
recipientEncryptionPublicKey,
|
||||||
|
vaultKey,
|
||||||
|
DshAad.VaultKeyGrant(vaultId, keyGeneration));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a grant with the recipient's private key.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The vault key, or <see langword="null"/> if the grant is not for this key, not for this vault,
|
||||||
|
/// or not for this generation. A malicious granter can seal garbage; that surfaces here as a
|
||||||
|
/// null, and the grant's signature names who did it.
|
||||||
|
/// </returns>
|
||||||
|
public static byte[]? TryUnwrap(
|
||||||
|
Key recipientKey,
|
||||||
|
ReadOnlySpan<byte> envelope,
|
||||||
|
Guid vaultId,
|
||||||
|
uint keyGeneration)
|
||||||
|
{
|
||||||
|
var plaintext = DshCrypto.OpenSealed(
|
||||||
|
recipientKey,
|
||||||
|
envelope,
|
||||||
|
DshAad.VaultKeyGrant(vaultId, keyGeneration));
|
||||||
|
|
||||||
|
if (plaintext is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grant that opens but does not contain a key-sized value is not a vault key. Failing here
|
||||||
|
// rather than passing it on means the error names the grant instead of surfacing later as an
|
||||||
|
// unexplained item decryption failure.
|
||||||
|
if (plaintext.Length != CryptoSpec.SymmetricKeySize)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(plaintext);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void RequireVaultKey(ReadOnlySpan<byte> vaultKey)
|
||||||
|
{
|
||||||
|
if (vaultKey.Length != CryptoSpec.SymmetricKeySize)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"A vault key is {CryptoSpec.SymmetricKeySize} bytes, got {vaultKey.Length}.",
|
||||||
|
nameof(vaultKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Globalization;
|
||||||
|
using NSec.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user secret bundle and the passphrase-derived keys that wrap it. See docs/crypto.md §3.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The encoding is checked against an independent implementation written here rather than by
|
||||||
|
/// round-tripping production code against itself. A round trip passes just as happily when both
|
||||||
|
/// directions are wrong in the same way, and this format cannot be changed after a single bundle has
|
||||||
|
/// been stored — the server holds no keys, so only clients could ever re-encrypt.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class UserSecretBundleTests
|
||||||
|
{
|
||||||
|
private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
private static readonly Guid Bob = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
|
||||||
|
private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The layout of §3.1, pinned. Built from the fixed keys below at generation 1 and
|
||||||
|
/// <see cref="CreatedAt"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This constant is the golden vector for the bundle format. A change here that is not a
|
||||||
|
/// deliberate, versioned format change makes every stored wrap unopenable, with no server-side
|
||||||
|
/// remedy and no rollback.
|
||||||
|
/// </remarks>
|
||||||
|
private const string ExpectedEncodingHex =
|
||||||
|
"647368312f62756e646c652f7631" // "dsh1/bundle/v1"
|
||||||
|
+ "0001" // version 1
|
||||||
|
+ "00000001" // key generation 1
|
||||||
|
+ "000001977420dc7b" // created at, Unix ms
|
||||||
|
+ "4041424344454647484950515253545556575859606162636465666768697071" // x25519 sk
|
||||||
|
+ "8081828384858687888990919293949596979899a0a1a2a3a4a5a6a7a8a9b0b1"; // ed25519 sk
|
||||||
|
|
||||||
|
private static byte[] EncryptionPrivateKey { get; } =
|
||||||
|
Convert.FromHexString("4041424344454647484950515253545556575859606162636465666768697071");
|
||||||
|
|
||||||
|
private static byte[] SigningPrivateKey { get; } =
|
||||||
|
Convert.FromHexString("8081828384858687888990919293949596979899a0a1a2a3a4a5a6a7a8a9b0b1");
|
||||||
|
|
||||||
|
// ---- Encoding ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheEncodedLength_IsNinetyTwoBytes()
|
||||||
|
{
|
||||||
|
UserSecretBundle.EncodedLength.ShouldBe(92);
|
||||||
|
ExpectedEncodingHex.Length.ShouldBe(UserSecretBundle.EncodedLength * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheDecoder_AcceptsAnIndependentlyBuiltEncoding()
|
||||||
|
{
|
||||||
|
// Proves the reader matches the specification rather than matching our writer.
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var envelope = DshCrypto.Seal(kek, Convert.FromHexString(ExpectedEncodingHex), descriptor);
|
||||||
|
|
||||||
|
using var bundle = UserSecretBundle.TryOpenUnder(kek, envelope, descriptor);
|
||||||
|
|
||||||
|
bundle.ShouldNotBeNull();
|
||||||
|
bundle.KeyGeneration.ShouldBe(1u);
|
||||||
|
bundle.CreatedAt.ToUnixTimeMilliseconds().ShouldBe(CreatedAt.ToUnixTimeMilliseconds());
|
||||||
|
bundle.EncryptionPublicKey.ShouldBe(PublicKeyOf(KeyAgreementAlgorithm.X25519, EncryptionPrivateKey));
|
||||||
|
bundle.SigningPublicKey.ShouldBe(PublicKeyOf(SignatureAlgorithm.Ed25519, SigningPrivateKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheEncoder_ProducesWhatAnIndependentDecoderExpects()
|
||||||
|
{
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt, keyGeneration: 3);
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice, keyGeneration: 3);
|
||||||
|
|
||||||
|
var envelope = bundle.WrapUnder(kek, descriptor);
|
||||||
|
var plaintext = DshCrypto.Open(kek, envelope, descriptor);
|
||||||
|
|
||||||
|
plaintext.ShouldNotBeNull();
|
||||||
|
plaintext.Length.ShouldBe(UserSecretBundle.EncodedLength);
|
||||||
|
|
||||||
|
var decoded = DecodeIndependently(plaintext);
|
||||||
|
|
||||||
|
decoded.Label.ShouldBe("dsh1/bundle/v1");
|
||||||
|
decoded.Version.ShouldBe(1);
|
||||||
|
decoded.KeyGeneration.ShouldBe(3u);
|
||||||
|
decoded.CreatedAtUnixMilliseconds.ShouldBe(CreatedAt.ToUnixTimeMilliseconds());
|
||||||
|
|
||||||
|
// The private keys in the encoding must be the ones whose public halves the bundle reports.
|
||||||
|
PublicKeyOf(KeyAgreementAlgorithm.X25519, decoded.EncryptionPrivateKey)
|
||||||
|
.ShouldBe(bundle.EncryptionPublicKey);
|
||||||
|
PublicKeyOf(SignatureAlgorithm.Ed25519, decoded.SigningPrivateKey)
|
||||||
|
.ShouldBe(bundle.SigningPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASubMillisecondTimestamp_IsTruncatedSoTheEncodingIsStable()
|
||||||
|
{
|
||||||
|
using var exact = UserSecretBundle.Create(CreatedAt);
|
||||||
|
using var noisy = UserSecretBundle.Create(CreatedAt.AddTicks(9_999));
|
||||||
|
|
||||||
|
noisy.CreatedAt.ShouldBe(exact.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The keys inside actually work ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheSigningKey_ProducesSignaturesItsPublicHalfVerifies()
|
||||||
|
{
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
|
||||||
|
var statement = KeyStatementCodec.Encode(new KeyStatementFields(
|
||||||
|
1, "https://idp.example", "alice", null,
|
||||||
|
bundle.EncryptionPublicKey, bundle.SigningPublicKey,
|
||||||
|
1, CreatedAt, "laptop"));
|
||||||
|
|
||||||
|
var signature = DshSignatures.SignKeyStatement(bundle.SigningKey, statement);
|
||||||
|
|
||||||
|
DshSignatures.VerifyKeyStatement(bundle.SigningPublicKey, statement, signature).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheEncryptionKey_OpensWhatWasSealedToItsPublicHalf()
|
||||||
|
{
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
var grant = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, Alice, 1);
|
||||||
|
|
||||||
|
VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, Alice, 1).ShouldBe(vaultKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheTwoKeys_AreDistinct()
|
||||||
|
{
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
|
||||||
|
bundle.EncryptionPublicKey.ShouldNotBe(bundle.SigningPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The AAD binding ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AWrapForAnotherUser_DoesNotOpen()
|
||||||
|
{
|
||||||
|
// The property that matters most. Without it a server could hand Bob's wrap to Alice, and if
|
||||||
|
// they ever shared a passphrase she would silently unlock his identity.
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
var envelope = bundle.WrapUnder(kek, DshAad.UserSecretBundle(Alice));
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(kek, envelope, DshAad.UserSecretBundle(Bob)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AWrapFromAnotherKeyGeneration_DoesNotOpen()
|
||||||
|
{
|
||||||
|
// Stops a server serving back a superseded bundle after a key rotation.
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt, keyGeneration: 2);
|
||||||
|
var envelope = bundle.WrapUnder(kek, DshAad.UserSecretBundle(Alice, keyGeneration: 2));
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(kek, envelope, DshAad.UserSecretBundle(Alice, keyGeneration: 1))
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AWrongKey_ReturnsNullRatherThanThrowing()
|
||||||
|
{
|
||||||
|
// The overwhelmingly common case is a mistyped passphrase, so this is a return value.
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
var envelope = bundle.WrapUnder(kek, DshAad.UserSecretBundle(Alice));
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(new byte[32], envelope, DshAad.UserSecretBundle(Alice))
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASealedWrap_DoesNotOpenAsASymmetricOne()
|
||||||
|
{
|
||||||
|
using var device = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var sealedWrap = bundle.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor);
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(new byte[32], sealedWrap, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Device wraps ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADeviceWrap_OpensWithTheDeviceKey()
|
||||||
|
{
|
||||||
|
using var device = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
using var original = UserSecretBundle.Create(CreatedAt);
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var wrap = original.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor);
|
||||||
|
|
||||||
|
using var reopened = UserSecretBundle.TryOpenSealed(device, wrap, descriptor);
|
||||||
|
|
||||||
|
reopened.ShouldNotBeNull();
|
||||||
|
reopened.EncryptionPublicKey.ShouldBe(original.EncryptionPublicKey);
|
||||||
|
reopened.SigningPublicKey.ShouldBe(original.SigningPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADeviceWrap_DoesNotOpenWithAnotherDevicesKey()
|
||||||
|
{
|
||||||
|
using var device = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
using var other = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var wrap = bundle.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor);
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenSealed(other, wrap, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManyWrapsOfOneBundle_AllYieldTheSameIdentity()
|
||||||
|
{
|
||||||
|
// The load-bearing property of the whole hierarchy: a passphrase change re-wraps ~92 bytes
|
||||||
|
// and touches one row, because every wrap protects the same bundle.
|
||||||
|
using var device = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
|
||||||
|
Span<byte> passphraseKek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(passphraseKek);
|
||||||
|
|
||||||
|
Span<byte> recoveryKek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
recoveryKek.Fill(0x5A);
|
||||||
|
|
||||||
|
using var viaPassphrase = UserSecretBundle.TryOpenUnder(
|
||||||
|
passphraseKek, bundle.WrapUnder(passphraseKek, descriptor), descriptor);
|
||||||
|
using var viaRecovery = UserSecretBundle.TryOpenUnder(
|
||||||
|
recoveryKek, bundle.WrapUnder(recoveryKek, descriptor), descriptor);
|
||||||
|
using var viaDevice = UserSecretBundle.TryOpenSealed(
|
||||||
|
device,
|
||||||
|
bundle.SealTo(device.PublicKey.Export(KeyBlobFormat.RawPublicKey), descriptor),
|
||||||
|
descriptor);
|
||||||
|
|
||||||
|
foreach (var opened in new[] { viaPassphrase, viaRecovery, viaDevice })
|
||||||
|
{
|
||||||
|
opened.ShouldNotBeNull();
|
||||||
|
opened.EncryptionPublicKey.ShouldBe(bundle.EncryptionPublicKey);
|
||||||
|
opened.SigningPublicKey.ShouldBe(bundle.SigningPublicKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Malformed plaintext ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(91)]
|
||||||
|
[InlineData(93)]
|
||||||
|
[InlineData(200)]
|
||||||
|
public void APlaintextOfTheWrongLength_IsRejected(int length)
|
||||||
|
{
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var envelope = DshCrypto.Seal(kek, new byte[length], descriptor);
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APlaintextWithTheWrongLabel_IsRejected()
|
||||||
|
{
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
var tampered = Convert.FromHexString(ExpectedEncodingHex);
|
||||||
|
tampered[0] ^= 0xFF;
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var envelope = DshCrypto.Seal(kek, tampered, descriptor);
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APlaintextWithAnUnknownVersion_IsRejected()
|
||||||
|
{
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
var tampered = Convert.FromHexString(ExpectedEncodingHex);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(tampered.AsSpan(14), 2);
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var envelope = DshCrypto.Seal(kek, tampered, descriptor);
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APlaintextWithGenerationZero_IsRejected()
|
||||||
|
{
|
||||||
|
Span<byte> kek = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
FillKek(kek);
|
||||||
|
|
||||||
|
var tampered = Convert.FromHexString(ExpectedEncodingHex);
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(tampered.AsSpan(16), 0);
|
||||||
|
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
var envelope = DshCrypto.Seal(kek, tampered, descriptor);
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(kek, envelope, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lifetime ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UsingADisposedBundle_Throws()
|
||||||
|
{
|
||||||
|
var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
bundle.Dispose();
|
||||||
|
|
||||||
|
Should.Throw<ObjectDisposedException>(() => bundle.SigningKey);
|
||||||
|
Should.Throw<ObjectDisposedException>(() => bundle.EncryptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DisposingTwice_IsHarmless()
|
||||||
|
{
|
||||||
|
var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
|
||||||
|
bundle.Dispose();
|
||||||
|
Should.NotThrow(bundle.Dispose);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_RejectsGenerationZero()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() => UserSecretBundle.Create(CreatedAt, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Independent codec, for the differential assertions above ----
|
||||||
|
|
||||||
|
private static byte[] PublicKeyOf(Algorithm algorithm, byte[] privateKey)
|
||||||
|
{
|
||||||
|
using var key = Key.Import(algorithm, privateKey, KeyBlobFormat.RawPrivateKey);
|
||||||
|
return key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillKek(Span<byte> destination)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < destination.Length; i++)
|
||||||
|
{
|
||||||
|
destination[i] = (byte)(0x10 + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads §3.1 from the document, independently of the production decoder.</summary>
|
||||||
|
private static DecodedBundle DecodeIndependently(byte[] encoded) =>
|
||||||
|
new(
|
||||||
|
Label: System.Text.Encoding.ASCII.GetString(encoded, 0, 14),
|
||||||
|
Version: BinaryPrimitives.ReadUInt16BigEndian(encoded.AsSpan(14)),
|
||||||
|
KeyGeneration: BinaryPrimitives.ReadUInt32BigEndian(encoded.AsSpan(16)),
|
||||||
|
CreatedAtUnixMilliseconds: BinaryPrimitives.ReadInt64BigEndian(encoded.AsSpan(20)),
|
||||||
|
EncryptionPrivateKey: encoded[28..60],
|
||||||
|
SigningPrivateKey: encoded[60..92]);
|
||||||
|
|
||||||
|
private sealed record DecodedBundle(
|
||||||
|
string Label,
|
||||||
|
int Version,
|
||||||
|
uint KeyGeneration,
|
||||||
|
long CreatedAtUnixMilliseconds,
|
||||||
|
byte[] EncryptionPrivateKey,
|
||||||
|
byte[] SigningPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Passphrase stretching and subkey derivation. See docs/crypto.md §2 and §3.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Uses the cheapest profile throughout. These tests are about the shape of the hierarchy, not about
|
||||||
|
/// how long Argon2id takes; the default profile would add several seconds per case for no extra
|
||||||
|
/// coverage. Cost calibration is a measurement, recorded in docs/platform-flags.md.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MasterKeyTests
|
||||||
|
{
|
||||||
|
private static readonly Guid Alice = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
private static readonly DateTimeOffset CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
|
||||||
|
private const string Passphrase = "correct horse battery staple";
|
||||||
|
|
||||||
|
private static byte[] Salt { get; } =
|
||||||
|
[.. Enumerable.Range(0, CryptoSpec.SaltSize).Select(i => (byte)(0x20 + i))];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThePassphrase_UnlocksTheBundleItWrapped()
|
||||||
|
{
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
|
||||||
|
byte[] wrap;
|
||||||
|
byte[] expectedSigningKey;
|
||||||
|
|
||||||
|
using (var master = Derive())
|
||||||
|
using (var bundle = UserSecretBundle.Create(CreatedAt))
|
||||||
|
{
|
||||||
|
wrap = master.WrapBundle(bundle, descriptor);
|
||||||
|
expectedSigningKey = bundle.SigningPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh derivation, as a later unlock on another device would do.
|
||||||
|
using var reopenedMaster = Derive();
|
||||||
|
using var reopened = reopenedMaster.TryOpenBundle(wrap, descriptor);
|
||||||
|
|
||||||
|
reopened.ShouldNotBeNull();
|
||||||
|
reopened.SigningPublicKey.ShouldBe(expectedSigningKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AWrongPassphrase_ReturnsNull()
|
||||||
|
{
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
|
||||||
|
using var master = Derive();
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
var wrap = master.WrapBundle(bundle, descriptor);
|
||||||
|
|
||||||
|
using var wrong = MasterKey.Derive("not the passphrase", Salt, Argon2Profile.RandomSecret);
|
||||||
|
|
||||||
|
wrong.TryOpenBundle(wrap, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADifferentSalt_ProducesADifferentKey()
|
||||||
|
{
|
||||||
|
// Which is why the salt has to be cached locally: unlock must work offline, and fetching the
|
||||||
|
// salt at unlock time would make an offline launch impossible.
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
|
||||||
|
using var master = Derive();
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
var wrap = master.WrapBundle(bundle, descriptor);
|
||||||
|
|
||||||
|
var otherSalt = Salt.ToArray();
|
||||||
|
otherSalt[0] ^= 0xFF;
|
||||||
|
|
||||||
|
using var other = MasterKey.Derive(Passphrase, otherSalt, Argon2Profile.RandomSecret);
|
||||||
|
|
||||||
|
other.TryOpenBundle(wrap, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheLocalCacheKey_CannotOpenABundleWrap()
|
||||||
|
{
|
||||||
|
// Domain separation, tested through behaviour rather than by comparing derived bytes. The
|
||||||
|
// cache and the vault live in different threat models and must not share a key.
|
||||||
|
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||||
|
|
||||||
|
using var master = Derive();
|
||||||
|
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||||
|
var wrap = master.WrapBundle(bundle, descriptor);
|
||||||
|
|
||||||
|
Span<byte> cacheKey = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
master.DeriveLocalCacheKey(cacheKey);
|
||||||
|
|
||||||
|
UserSecretBundle.TryOpenUnder(cacheKey, wrap, descriptor).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheLocalCacheKey_IsStableForTheSamePassphraseAndSalt()
|
||||||
|
{
|
||||||
|
Span<byte> first = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
Span<byte> second = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
|
||||||
|
using (var master = Derive())
|
||||||
|
{
|
||||||
|
master.DeriveLocalCacheKey(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var master = Derive())
|
||||||
|
{
|
||||||
|
master.DeriveLocalCacheKey(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
first.SequenceEqual(second).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASaltShorterThanTheSpecifiedMinimum_IsRejected()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() =>
|
||||||
|
MasterKey.Derive(Passphrase, new byte[CryptoSpec.SaltSize - 1], Argon2Profile.RandomSecret));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void AnEmptyPassphrase_IsRejected(string? passphrase)
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() =>
|
||||||
|
MasterKey.Derive(passphrase!, Salt, Argon2Profile.RandomSecret));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UsingADisposedMasterKey_Throws()
|
||||||
|
{
|
||||||
|
var master = Derive();
|
||||||
|
master.Dispose();
|
||||||
|
|
||||||
|
Should.Throw<ObjectDisposedException>(() =>
|
||||||
|
{
|
||||||
|
var buffer = new byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
master.DeriveLocalCacheKey(buffer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASubkeyBufferOfTheWrongSize_IsRejected()
|
||||||
|
{
|
||||||
|
using var master = Derive();
|
||||||
|
|
||||||
|
Should.Throw<ArgumentException>(() => master.DeriveLocalCacheKey(new byte[16]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheCheapestProfile_IsStillTheSpecifiedFloor()
|
||||||
|
{
|
||||||
|
// Guards the constant these tests lean on: if RandomSecret ever dropped below the server's
|
||||||
|
// enrollment floor, the tests would be exercising parameters the API rejects.
|
||||||
|
Argon2Profile.RandomSecret.MemoryKibibytes
|
||||||
|
.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
64 * 1024,
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"64 MiB is the enrollment floor."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MasterKey Derive() =>
|
||||||
|
MasterKey.Derive(Passphrase, Salt, Argon2Profile.RandomSecret);
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
using NSec.Cryptography;
|
||||||
|
|
||||||
|
namespace DodoSSH.Crypto.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vault key grants, per-item data keys, and the AAD binding that holds them in place.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Most of these are negative. The AAD binding is the most valuable structural property in the
|
||||||
|
/// design — it is what stops a server that holds every ciphertext from moving one between rows,
|
||||||
|
/// rolling a row back, or replaying a revoked grant — and a property like that is only demonstrated
|
||||||
|
/// by the substitutions that fail.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VaultAndItemKeyTests
|
||||||
|
{
|
||||||
|
private static readonly Guid VaultA = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
private static readonly Guid VaultB = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
private static readonly Guid ItemA = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
private static readonly Guid ItemB = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
private static readonly Guid DataKeyId = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
|
||||||
|
// ---- Vault keys ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AVaultKey_IsThirtyTwoRandomBytes()
|
||||||
|
{
|
||||||
|
var first = VaultKeys.Create();
|
||||||
|
var second = VaultKeys.Create();
|
||||||
|
|
||||||
|
first.Length.ShouldBe(CryptoSpec.SymmetricKeySize);
|
||||||
|
first.ShouldNotBe(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AGrant_OpensForItsRecipient()
|
||||||
|
{
|
||||||
|
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
|
||||||
|
var grant = VaultKeys.WrapTo(
|
||||||
|
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
|
||||||
|
|
||||||
|
VaultKeys.TryUnwrap(member, grant, VaultA, 1).ShouldBe(vaultKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AGrantForAnotherVault_DoesNotOpen()
|
||||||
|
{
|
||||||
|
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
|
||||||
|
var grant = VaultKeys.WrapTo(
|
||||||
|
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
|
||||||
|
|
||||||
|
VaultKeys.TryUnwrap(member, grant, VaultB, 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AGrantFromASupersededGeneration_DoesNotOpen()
|
||||||
|
{
|
||||||
|
// This is what makes a rekey stick against a malicious server: it cannot re-serve the old
|
||||||
|
// generation's grant to a removed member. Bounded to future reads only — whatever they
|
||||||
|
// already downloaded is already gone, which is why offboarding means rotating the SSH
|
||||||
|
// credentials themselves.
|
||||||
|
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
|
||||||
|
var grant = VaultKeys.WrapTo(
|
||||||
|
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
|
||||||
|
|
||||||
|
VaultKeys.TryUnwrap(member, grant, VaultA, 2).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AGrantForAnotherMember_DoesNotOpen()
|
||||||
|
{
|
||||||
|
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
using var intruder = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
|
||||||
|
var grant = VaultKeys.WrapTo(
|
||||||
|
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
|
||||||
|
|
||||||
|
VaultKeys.TryUnwrap(intruder, grant, VaultA, 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AGrantContainingSomethingOtherThanAKey_IsRejected()
|
||||||
|
{
|
||||||
|
// A malicious granter can seal anything; the recipient finds out here rather than later, when
|
||||||
|
// an unexplained item decryption failure would look like data corruption.
|
||||||
|
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
|
||||||
|
var bogus = DshCrypto.SealTo(
|
||||||
|
member.PublicKey.Export(KeyBlobFormat.RawPublicKey),
|
||||||
|
new byte[16],
|
||||||
|
DshAad.VaultKeyGrant(VaultA, 1));
|
||||||
|
|
||||||
|
VaultKeys.TryUnwrap(member, bogus, VaultA, 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WrappingAKeyOfTheWrongLength_Throws()
|
||||||
|
{
|
||||||
|
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
|
||||||
|
|
||||||
|
Should.Throw<ArgumentException>(() => VaultKeys.WrapTo(
|
||||||
|
new byte[16], member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Item data keys ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADataKey_RoundTripsUnderTheVaultKey()
|
||||||
|
{
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var wrapped = ItemKeys.WrapDataKey(
|
||||||
|
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
|
||||||
|
|
||||||
|
ItemKeys.TryUnwrapDataKey(
|
||||||
|
vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemA, 1, 1).ShouldBe(dataKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADataKeyFromAnotherItem_DoesNotOpen()
|
||||||
|
{
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var wrapped = ItemKeys.WrapDataKey(
|
||||||
|
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
|
||||||
|
|
||||||
|
ItemKeys.TryUnwrapDataKey(
|
||||||
|
vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemB, 1, 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADataKeyFromAnotherItemVersion_DoesNotOpen()
|
||||||
|
{
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var wrapped = ItemKeys.WrapDataKey(
|
||||||
|
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 2);
|
||||||
|
|
||||||
|
ItemKeys.TryUnwrapDataKey(
|
||||||
|
vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemA, 1, 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADataKeyForAnotherResourceType_DoesNotOpen()
|
||||||
|
{
|
||||||
|
// An id collision across tables would otherwise let a credential's key open a host's.
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var wrapped = ItemKeys.WrapDataKey(
|
||||||
|
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
|
||||||
|
|
||||||
|
ItemKeys.TryUnwrapDataKey(
|
||||||
|
vaultKey, wrapped, CryptoSpec.AadResourceType.Credential, ItemA, 1, 1).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Item payloads ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APayload_RoundTripsUnderItsDataKey()
|
||||||
|
{
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
var plaintext = "ssh -p 2222 dodo@bastion.internal"u8.ToArray();
|
||||||
|
|
||||||
|
var envelope = ItemKeys.SealPayload(
|
||||||
|
dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
|
||||||
|
|
||||||
|
ItemKeys.TryOpenPayload(
|
||||||
|
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
|
||||||
|
.ShouldBe(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APayloadPastedOntoAnotherItem_DoesNotOpen()
|
||||||
|
{
|
||||||
|
// The headline property. A server holding every ciphertext cannot move one row's bytes onto
|
||||||
|
// another row, which is a guarantee no amount of access control provides.
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
var plaintext = "secret"u8.ToArray();
|
||||||
|
|
||||||
|
var envelope = ItemKeys.SealPayload(
|
||||||
|
dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
|
||||||
|
|
||||||
|
ItemKeys.TryOpenPayload(
|
||||||
|
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemB, DataKeyId, 1, 1)
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APayloadRolledBackToAnEarlierVersion_DoesNotOpen()
|
||||||
|
{
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var envelope = ItemKeys.SealPayload(
|
||||||
|
dataKey, "v2"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 2);
|
||||||
|
|
||||||
|
ItemKeys.TryOpenPayload(
|
||||||
|
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APayloadFromASupersededKeyGeneration_DoesNotOpen()
|
||||||
|
{
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var envelope = ItemKeys.SealPayload(
|
||||||
|
dataKey, "old"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
|
||||||
|
|
||||||
|
ItemKeys.TryOpenPayload(
|
||||||
|
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 2, 1)
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AMetadataBlob_DoesNotOpenAsAPayload()
|
||||||
|
{
|
||||||
|
// Distinct purposes on the same item, so a server cannot swap the two — a substitution that
|
||||||
|
// would leave a client decrypting a password where it expected a display name.
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var metadata = DshCrypto.Seal(
|
||||||
|
dataKey,
|
||||||
|
"display name"u8,
|
||||||
|
DshAad.ItemMetadata(CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1));
|
||||||
|
|
||||||
|
ItemKeys.TryOpenPayload(
|
||||||
|
dataKey, metadata, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APayloadUnderAnotherDataKeyId_DoesNotOpen()
|
||||||
|
{
|
||||||
|
// content_key_id is part of the binding, which is what lets M5 add per-item grants without
|
||||||
|
// reworking the AAD.
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
var envelope = ItemKeys.SealPayload(
|
||||||
|
dataKey, "x"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
|
||||||
|
|
||||||
|
ItemKeys.TryOpenPayload(
|
||||||
|
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, Guid.Empty, 1, 1)
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheFullChain_WorksEndToEnd()
|
||||||
|
{
|
||||||
|
// Enrollment through to a stored item: identity key, vault key sealed to it, data key wrapped
|
||||||
|
// under the vault key, payload under the data key. Every link uses only what the layer above
|
||||||
|
// it hands over, which is the shape the client actually follows.
|
||||||
|
using var bundle = UserSecretBundle.Create(
|
||||||
|
DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_000));
|
||||||
|
|
||||||
|
var vaultKey = VaultKeys.Create();
|
||||||
|
var grant = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, VaultA, 1);
|
||||||
|
|
||||||
|
var recoveredVaultKey = VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, VaultA, 1);
|
||||||
|
recoveredVaultKey.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
var wrappedDataKey = ItemKeys.WrapDataKey(
|
||||||
|
dataKey, recoveredVaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
|
||||||
|
|
||||||
|
var plaintext = """{"hostname":"bastion.internal","username":"dodo"}"""u8.ToArray();
|
||||||
|
var payload = ItemKeys.SealPayload(
|
||||||
|
dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
|
||||||
|
|
||||||
|
// Now the read path, holding nothing but the identity key and the stored ciphertexts.
|
||||||
|
var readVaultKey = VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, VaultA, 1);
|
||||||
|
readVaultKey.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var readDataKey = ItemKeys.TryUnwrapDataKey(
|
||||||
|
readVaultKey, wrappedDataKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
|
||||||
|
readDataKey.ShouldNotBeNull();
|
||||||
|
|
||||||
|
ItemKeys.TryOpenPayload(
|
||||||
|
readDataKey, payload, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
|
||||||
|
.ShouldBe(plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user