lys-core 0.1.0

Domain-agnostic cryptographic trust primitives — certificate authority, Merkle transparency log, signed attestations, sealed envelopes, and Ed25519 key management.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! [`SealedEnvelope`] — the on-the-wire payload produced by [`seal`] and
//! consumed by [`open`].
//!
//! Encryption is X25519 ephemeral key agreement plus AES-256-GCM with a
//! HKDF-SHA256-derived key and nonce. Each [`seal`] generates a fresh
//! ephemeral X25519 keypair; the ephemeral public half travels in the
//! envelope, the secret half drops at end of scope (forward secrecy per
//! envelope). The recipient's long-term X25519 key is required to recompute
//! the shared secret and decrypt.
//!
//! HKDF expansion produces 44 bytes — 32 for the AES-256 key and 12 for the
//! GCM nonce — so the nonce is bound to the shared secret and never reused
//! by accident across distinct seals (fresh ephemeral key ⇒ fresh shared
//! secret ⇒ fresh nonce). The HKDF `info` input binds both public keys
//! (`SEAL_INFO || ephemeral_public_key || recipient_public_key`, the `NaCl`
//! `crypto_box_seal` pattern) so the derived key is committed to the exact
//! key pair that produced the shared secret — defense in depth against any
//! key-substitution confusion.
//!
//! **Contributory-behaviour check.** X25519 accepts low-order points and
//! silently produces an all-zero shared secret for them. Both [`seal`] and
//! [`open`] reject non-contributory Diffie-Hellman results: sealing to a
//! low-order recipient key fails with [`TrustError::Seal`], and opening an
//! envelope whose ephemeral point is low-order fails with
//! [`TrustError::UnsealFailed`]. An attacker can therefore never force a
//! predictable shared secret.
//!
//! **Single failure arbiter.** [`open`] performs no independent plausibility
//! checks on the envelope fields — AES-GCM tag verification is the sole
//! arbiter of decryption failure. A wrong recipient key yields a wrong
//! derived AES key; a tampered nonce or ephemeral point yields a key/nonce
//! pair that never encrypted the ciphertext; every case surfaces as the same
//! constant-shape GCM tag failure mapped to [`TrustError::UnsealFailed`].
//! This preserves the non-oracle property: the caller (and any observer of
//! timing) cannot learn *why* an unseal failed.
//!
//! The unsealed payload is returned as `Vec<u8>`. The trust crate does not
//! interpret it — consumers decide what the bytes mean (sealed API token,
//! credential bundle, etc.).

use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use hkdf::Hkdf;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;

use crate::error::{TrustError, TrustResult};

/// Domain-separation tag for HKDF expansion. Bound to this crate's
/// sealed-envelope format so the same shared secret used in any other
/// protocol context (e.g. session-key derivation in a consumer) cannot
/// collide. The full HKDF `info` is this tag concatenated with the ephemeral
/// and recipient public keys — see [`derive_key_and_nonce`].
const SEAL_INFO: &[u8] = b"lys-sealed-envelope/v1";

/// HKDF output length: 32-byte AES-256 key + 12-byte AES-GCM nonce.
const HKDF_OUTPUT_LEN: usize = 44;

/// AES-256 key length in bytes.
const AES_KEY_LEN: usize = 32;

/// AES-GCM nonce length in bytes.
const NONCE_LEN: usize = 12;

/// Sealed payload bound to a single recipient X25519 public key.
///
/// Constructed by [`seal`] and consumed by [`open`]. The envelope is the
/// wire format — `serde` is derived so consumers can carry it through any
/// transport or storage layer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedEnvelope {
    /// X25519 public half of the ephemeral keypair generated by [`seal`].
    /// The matching ephemeral secret is dropped at end of seal scope; the
    /// recipient combines this with their long-term X25519 secret to
    /// recompute the same shared secret.
    pub ephemeral_public_key: [u8; 32],

    /// AES-256-GCM ciphertext of the original payload, with the 16-byte
    /// authentication tag appended (per the `aes-gcm` crate convention).
    pub ciphertext: Vec<u8>,

    /// 12-byte AES-GCM nonce, derived from the same HKDF expansion that
    /// produces the AES key. Bound to the shared secret, never randomly
    /// generated, so distinct seals produce distinct nonces. [`open`] uses
    /// this field as the operative nonce; any tampering surfaces as an
    /// AES-GCM tag failure.
    pub nonce: [u8; NONCE_LEN],
}

impl SealedEnvelope {
    /// Canonical byte representation of the envelope, used by the
    /// authenticated composition ([`super::authenticated::sign_and_seal`])
    /// as the body of the attested message (prefixed there with a
    /// sealed-envelope context tag — see [`super::authenticated`]).
    ///
    /// The encoding is `ephemeral_public_key || ciphertext || nonce`, which
    /// covers every byte that travels with the envelope. Binding the sender
    /// signature to this exact byte sequence means an adversary cannot
    /// substitute a different ciphertext, ephemeral key, or nonce without
    /// invalidating the signature.
    pub fn attestation_bytes(&self) -> Vec<u8> {
        let mut bytes = Vec::with_capacity(
            self.ephemeral_public_key.len() + self.ciphertext.len() + self.nonce.len(),
        );
        bytes.extend_from_slice(&self.ephemeral_public_key);
        bytes.extend_from_slice(&self.ciphertext);
        bytes.extend_from_slice(&self.nonce);
        bytes
    }
}

/// Encrypts `payload` for the holder of the X25519 secret matching
/// `recipient_public_key`, returning a [`SealedEnvelope`].
///
/// A fresh X25519 ephemeral keypair is generated per call. The ephemeral
/// secret is combined with the recipient's public key via Diffie-Hellman;
/// the shared secret is run through HKDF-SHA256 with an `info` input of
/// `SEAL_INFO || ephemeral_public_key || recipient_public_key` to produce a
/// 32-byte AES-256 key and a 12-byte AES-GCM nonce. The nonce is derived
/// from the same expansion as the key, so it is unique whenever the shared
/// secret is unique (which is always, since the ephemeral key is fresh).
/// Binding both public keys into the derivation commits the key material to
/// the exact key pair involved (`NaCl` `crypto_box_seal` pattern).
///
/// The Diffie-Hellman result is checked for contributory behaviour: a
/// recipient public key that is a low-order point would force an all-zero
/// shared secret regardless of the ephemeral secret, so it is rejected.
///
/// # Errors
///
/// Returns [`TrustError::Seal`] if `recipient_public_key` is a low-order
/// point (non-contributory Diffie-Hellman), if HKDF expansion fails (only
/// possible for output lengths above 255 × 32 bytes — never for the 44-byte
/// output produced here), or if AES-GCM encryption fails (only possible if
/// the payload length exceeds `2^39 - 256` bytes, the AES-GCM hard limit).
pub fn seal(payload: &[u8], recipient_public_key: &[u8; 32]) -> TrustResult<SealedEnvelope> {
    // `x25519-dalek 2`'s `EphemeralSecret::random_from_rng` requires an RNG
    // matching the rand_core version it was compiled against, which does
    // not line up with the pinned `rand 0.9`. Generating the 32-byte
    // ephemeral scalar from `rand` first and constructing a single-use
    // `StaticSecret` is the same workaround the sibling crypto crates
    // already use. The secret is dropped at end of scope, so forward
    // secrecy per envelope is preserved.
    let mut ephemeral_seed = Zeroizing::new([0u8; 32]);
    rand::rng().fill_bytes(ephemeral_seed.as_mut_slice());
    let ephemeral_secret = StaticSecret::from(*ephemeral_seed);
    let ephemeral_public = PublicKey::from(&ephemeral_secret);
    let recipient_public = PublicKey::from(*recipient_public_key);
    let shared = ephemeral_secret.diffie_hellman(&recipient_public);
    if !shared.was_contributory() {
        return Err(TrustError::Seal {
            reason: "recipient public key is a low-order point".to_string(),
        });
    }

    let (key_bytes, nonce_bytes) = derive_key_and_nonce(
        shared.as_bytes(),
        &ephemeral_public.to_bytes(),
        recipient_public_key,
    )?;

    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&*key_bytes));
    let nonce = Nonce::from_slice(&nonce_bytes);
    let ciphertext = cipher
        .encrypt(
            nonce,
            Payload {
                msg: payload,
                aad: &[],
            },
        )
        .map_err(|e| TrustError::Seal {
            reason: format!("AES-256-GCM encryption failed: {e}"),
        })?;

    Ok(SealedEnvelope {
        ephemeral_public_key: ephemeral_public.to_bytes(),
        ciphertext,
        nonce: nonce_bytes,
    })
}

/// Decrypts `envelope` using `recipient_private_key`.
///
/// Recomputes the X25519 shared secret from `envelope.ephemeral_public_key`
/// and `recipient_private_key` (rejecting low-order ephemeral points via the
/// contributory-behaviour check), re-derives the AES-256 key via the same
/// HKDF expansion used by [`seal`] — binding the envelope's ephemeral public
/// key and the recipient public key derived from `recipient_private_key`
/// into the `info` input — and decrypts the ciphertext using
/// `envelope.nonce` as the operative nonce.
///
/// AES-GCM tag verification is the single failure arbiter: a wrong recipient
/// key derives a wrong AES key, a tampered nonce or ephemeral point breaks
/// the key/nonce binding, and each case fails the tag check identically. No
/// independent pre-checks are performed, so failure timing does not reveal
/// *which* field was wrong. Every unseal failure — wrong key, tampered
/// ciphertext, tampered nonce, tampered or low-order ephemeral key, or the
/// practically unreachable HKDF expansion failure — collapses to
/// [`TrustError::UnsealFailed`]; the caller cannot distinguish them by error
/// variant, which is the desired non-oracle behaviour.
///
/// # Errors
///
/// Returns [`TrustError::UnsealFailed`] if the ephemeral public key is a
/// low-order point, if AES-GCM authentication or decryption fails, or in
/// the practically unreachable case that HKDF expansion fails.
pub fn open(
    envelope: &SealedEnvelope,
    recipient_private_key: &StaticSecret,
) -> TrustResult<Vec<u8>> {
    let ephemeral_public = PublicKey::from(envelope.ephemeral_public_key);
    let recipient_public = PublicKey::from(recipient_private_key);
    let shared = recipient_private_key.diffie_hellman(&ephemeral_public);
    if !shared.was_contributory() {
        // A low-order ephemeral point forces an all-zero shared secret
        // regardless of the recipient's private key — an attacker-chosen,
        // predictable key. Reject before any key material is derived.
        return Err(TrustError::UnsealFailed);
    }

    let (key_bytes, _derived_nonce) = derive_key_and_nonce(
        shared.as_bytes(),
        &envelope.ephemeral_public_key,
        &recipient_public.to_bytes(),
    )
    .map_err(|_err| TrustError::UnsealFailed)?;

    // The envelope's own nonce is the operative nonce; the derived nonce is
    // intentionally not compared against it. If the envelope nonce was
    // tampered with, it differs from the nonce the ciphertext was encrypted
    // under and the GCM tag check below fails — AES-GCM stays the single
    // failure arbiter and no early-exit timing signal is introduced.
    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&*key_bytes));
    let nonce = Nonce::from_slice(&envelope.nonce);
    cipher
        .decrypt(
            nonce,
            Payload {
                msg: &envelope.ciphertext,
                aad: &[],
            },
        )
        .map_err(|_err| TrustError::UnsealFailed)
}

/// HKDF-SHA256 expansion of the X25519 shared secret into the AES key and
/// GCM nonce used by [`seal`] and [`open`].
///
/// The `info` input is `SEAL_INFO || ephemeral_public_key ||
/// recipient_public_key`, binding the derived key material to both halves of
/// the key agreement (the `NaCl` `crypto_box_seal` pattern). Both sides must
/// pass identical values: [`seal`] has both keys directly, [`open`] takes
/// the ephemeral key from the envelope and derives the recipient public key
/// from the recipient's private key.
///
/// The AES key is returned wrapped in [`Zeroizing`] so it is overwritten on
/// drop; the nonce is not a secret and ships in the envelope.
fn derive_key_and_nonce(
    shared_secret: &[u8],
    ephemeral_public_key: &[u8; 32],
    recipient_public_key: &[u8; 32],
) -> TrustResult<(Zeroizing<[u8; AES_KEY_LEN]>, [u8; NONCE_LEN])> {
    let hk = Hkdf::<Sha256>::new(None, shared_secret);
    let mut info = Vec::with_capacity(SEAL_INFO.len() + 64);
    info.extend_from_slice(SEAL_INFO);
    info.extend_from_slice(ephemeral_public_key);
    info.extend_from_slice(recipient_public_key);
    let mut okm = Zeroizing::new([0u8; HKDF_OUTPUT_LEN]);
    hk.expand(&info, okm.as_mut_slice())
        .map_err(|e| TrustError::Seal {
            reason: format!("HKDF expand failed: {e}"),
        })?;
    let mut key = Zeroizing::new([0u8; AES_KEY_LEN]);
    key.copy_from_slice(&okm[..AES_KEY_LEN]);
    let mut nonce = [0u8; NONCE_LEN];
    nonce.copy_from_slice(&okm[AES_KEY_LEN..]);
    Ok((key, nonce))
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;
    use crate::keys::identity::Ed25519Identity;
    use tempfile::tempdir;

    /// The canonical order-8 low-order point on Curve25519 (little-endian
    /// u-coordinate), from the curve25519 "blacklisted" small-order list.
    const LOW_ORDER_POINT_ORDER_8: [u8; 32] = [
        0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4,
        0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49,
        0xb8, 0x00,
    ];

    fn identity() -> Ed25519Identity {
        let dir = tempdir().unwrap();
        Ed25519Identity::load_or_generate(&dir.path().join("id.key")).unwrap()
    }

    #[test]
    fn seal_returns_envelope_with_expected_field_shapes() {
        let recipient = identity();
        let payload = b"sealed credential bundle";
        let env = seal(payload, &recipient.x25519_public_key()).unwrap();
        assert_eq!(env.ephemeral_public_key.len(), 32);
        assert_eq!(env.nonce.len(), 12);
        // AES-GCM ciphertext = plaintext + 16-byte authentication tag.
        assert_eq!(env.ciphertext.len(), payload.len() + 16);
    }

    #[test]
    fn seal_open_roundtrip_returns_original_payload() {
        let recipient = identity();
        let payload = b"API token: super-secret";
        let env = seal(payload, &recipient.x25519_public_key()).unwrap();
        let opened = open(&env, &recipient.x25519_static_secret()).unwrap();
        assert_eq!(opened.as_slice(), payload);
    }

    #[test]
    fn open_with_wrong_private_key_returns_unseal_failed() {
        let recipient = identity();
        let wrong = identity();
        let env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
        let err = open(&env, &wrong.x25519_static_secret()).unwrap_err();
        assert!(matches!(err, TrustError::UnsealFailed));
    }

    #[test]
    fn tampered_ciphertext_returns_unseal_failed() {
        let recipient = identity();
        let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
        // Flip one bit in the ciphertext; AES-GCM authentication tag will fail.
        env.ciphertext[0] ^= 0x01;
        let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
        assert!(matches!(err, TrustError::UnsealFailed));
    }

    #[test]
    fn tampered_nonce_returns_unseal_failed() {
        let recipient = identity();
        let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
        env.nonce[0] ^= 0x01;
        // The derived AES key is still correct, but the operative nonce no
        // longer matches the one the ciphertext was encrypted under — the
        // GCM tag check is the single arbiter and rejects it.
        let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
        assert!(matches!(err, TrustError::UnsealFailed));
    }

    #[test]
    fn tampered_ephemeral_key_returns_unseal_failed() {
        let recipient = identity();
        let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
        // Replace ephemeral key with an unrelated one — the shared secret
        // changes, the derived AES key is wrong, and the GCM tag fails.
        let other = identity();
        env.ephemeral_public_key = other.x25519_public_key();
        let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
        assert!(matches!(err, TrustError::UnsealFailed));
    }

    #[test]
    fn low_order_ephemeral_key_all_zero_returns_unseal_failed() {
        let recipient = identity();
        let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
        // The all-zero point is low-order: Diffie-Hellman with it yields an
        // all-zero shared secret regardless of the private key. The
        // contributory-behaviour check must reject it.
        env.ephemeral_public_key = [0u8; 32];
        let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
        assert!(matches!(err, TrustError::UnsealFailed));
    }

    #[test]
    fn low_order_ephemeral_key_order_8_returns_unseal_failed() {
        let recipient = identity();
        let mut env = seal(b"payload", &recipient.x25519_public_key()).unwrap();
        env.ephemeral_public_key = LOW_ORDER_POINT_ORDER_8;
        let err = open(&env, &recipient.x25519_static_secret()).unwrap_err();
        assert!(matches!(err, TrustError::UnsealFailed));
    }

    #[test]
    fn sealing_to_low_order_recipient_key_returns_seal_error() {
        for low_order in [[0u8; 32], LOW_ORDER_POINT_ORDER_8] {
            let err = seal(b"payload", &low_order).unwrap_err();
            match err {
                TrustError::Seal { reason } => {
                    assert!(
                        reason.contains("low-order"),
                        "unexpected seal error reason: {reason}"
                    );
                }
                other => panic!("expected TrustError::Seal, got {other:?}"),
            }
        }
    }

    #[test]
    fn two_seals_of_same_payload_produce_distinct_ciphertexts_and_keys() {
        let recipient = identity();
        let payload = b"identical payload";
        let env_a = seal(payload, &recipient.x25519_public_key()).unwrap();
        let env_b = seal(payload, &recipient.x25519_public_key()).unwrap();
        assert_ne!(env_a.ephemeral_public_key, env_b.ephemeral_public_key);
        assert_ne!(env_a.ciphertext, env_b.ciphertext);
        assert_ne!(env_a.nonce, env_b.nonce);
    }

    #[test]
    fn empty_payload_seals_and_opens() {
        let recipient = identity();
        let env = seal(&[], &recipient.x25519_public_key()).unwrap();
        // Empty plaintext still produces a 16-byte authentication tag.
        assert_eq!(env.ciphertext.len(), 16);
        let opened = open(&env, &recipient.x25519_static_secret()).unwrap();
        assert!(opened.is_empty());
    }

    #[test]
    fn sealed_envelope_round_trips_through_postcard() {
        let recipient = identity();
        let env = seal(b"persisted", &recipient.x25519_public_key()).unwrap();
        let bytes = postcard::to_allocvec(&env).unwrap();
        let restored: SealedEnvelope = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(restored, env);
        let opened = open(&restored, &recipient.x25519_static_secret()).unwrap();
        assert_eq!(opened.as_slice(), b"persisted");
    }

    #[test]
    fn attestation_bytes_concatenates_in_declared_order() {
        let recipient = identity();
        let env = seal(b"x", &recipient.x25519_public_key()).unwrap();
        let bytes = env.attestation_bytes();
        let expected_len = env.ephemeral_public_key.len() + env.ciphertext.len() + env.nonce.len();
        assert_eq!(bytes.len(), expected_len);
        assert_eq!(&bytes[..32], env.ephemeral_public_key.as_slice());
        assert_eq!(
            &bytes[32..32 + env.ciphertext.len()],
            env.ciphertext.as_slice()
        );
        assert_eq!(&bytes[32 + env.ciphertext.len()..], env.nonce.as_slice());
    }
}