radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

identity_storage.rs (21459B)


      1 use std::ffi::OsString;
      2 use std::fs::{self, OpenOptions};
      3 use std::io::Write;
      4 use std::path::{Path, PathBuf};
      5 
      6 use anyhow::{Context, Result, bail};
      7 use chacha20poly1305::aead::{Aead, KeyInit, Payload};
      8 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
      9 use nostr::{Keys, SecretKey};
     10 use radroots_secrets::context::{
     11     EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId,
     12 };
     13 use radroots_secrets::envelope::{
     14     ENVELOPE_VERSION, LEGACY_ENVELOPE_VERSION, LegacyV1ResealAuthority, Nonce, SealMaterial,
     15     SealRequest,
     16 };
     17 use radroots_secrets::error::Operation;
     18 use radroots_secrets::id::{BackendKind, KeyVersion};
     19 use radroots_secrets::wrapping::{
     20     BoxFuture, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret,
     21 };
     22 use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef};
     23 use serde::{Deserialize, Serialize};
     24 use zeroize::Zeroize;
     25 
     26 const RADROOTSD_IDENTITY_KEY_SLOT: &str = "radrootsd_identity";
     27 const WRAPPING_KEY_BYTES: usize = 32;
     28 const WRAPPING_NONCE_BYTES: usize = 24;
     29 const LEGACY_WRAPPED_KEY_VERSION: u8 = 1;
     30 const WRAPPED_KEY_VERSION: u8 = 2;
     31 const WRAPPING_AAD_DOMAIN: &[u8] = b"radrootsd.wrapped_data_key.v2";
     32 
     33 struct DaemonFileKeyWrapping {
     34     key_path: PathBuf,
     35 }
     36 
     37 impl DaemonFileKeyWrapping {
     38     fn new(identity_path: &Path) -> Self {
     39         Self {
     40             key_path: encrypted_identity_key_path(identity_path),
     41         }
     42     }
     43 
     44     fn load_or_create_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
     45         if let Ok(raw) = fs::read(&self.key_path) {
     46             return key_from_bytes(raw.as_slice());
     47         }
     48         if let Some(parent) = self
     49             .key_path
     50             .parent()
     51             .filter(|path| !path.as_os_str().is_empty())
     52         {
     53             fs::create_dir_all(parent).map_err(|_| radroots_secrets::Error::BackendFailure {
     54                 backend: BackendKind::External,
     55                 operation: radroots_secrets::error::Operation::Provision,
     56             })?;
     57         }
     58         let key: [u8; WRAPPING_KEY_BYTES] = rand::random();
     59         match OpenOptions::new()
     60             .write(true)
     61             .create_new(true)
     62             .open(&self.key_path)
     63         {
     64             Ok(mut file) => {
     65                 file.write_all(&key)
     66                     .map_err(|_| secret_backend_failure(Operation::Write))?;
     67                 file.sync_all()
     68                     .map_err(|_| secret_backend_failure(Operation::Write))?;
     69                 set_secret_permissions(&self.key_path)
     70                     .map_err(|_| secret_backend_failure(Operation::Write))?;
     71                 Ok(key)
     72             }
     73             Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
     74                 let raw = fs::read(&self.key_path)
     75                     .map_err(|_| secret_backend_failure(Operation::Read))?;
     76                 key_from_bytes(raw.as_slice())
     77             }
     78             Err(_) => Err(secret_backend_failure(Operation::Provision)),
     79         }
     80     }
     81 
     82     fn load_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
     83         let raw = fs::read(&self.key_path).map_err(|_| secret_backend_failure(Operation::Read))?;
     84         key_from_bytes(raw.as_slice())
     85     }
     86 }
     87 
     88 impl KeyWrapping for DaemonFileKeyWrapping {
     89     fn wrap<'a>(
     90         &'a self,
     91         request: WrapRequest<'a>,
     92     ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> {
     93         Box::pin(async move {
     94             validate_daemon_reference(request.reference(), Operation::Wrap)?;
     95             let mut key = self.load_or_create_key()?;
     96             let nonce: [u8; WRAPPING_NONCE_BYTES] = rand::random();
     97             let aad = wrapping_aad(request.reference(), request.context());
     98             let ciphertext = request.plaintext().expose_secret(|plaintext| {
     99                 XChaCha20Poly1305::new(Key::from_slice(&key)).encrypt(
    100                     XNonce::from_slice(&nonce),
    101                     Payload {
    102                         msg: plaintext,
    103                         aad: aad.as_slice(),
    104                     },
    105                 )
    106             });
    107             key.zeroize();
    108             let ciphertext = ciphertext.map_err(|_| secret_backend_failure(Operation::Wrap))?;
    109             let mut wrapped = Vec::with_capacity(1 + nonce.len() + ciphertext.len());
    110             wrapped.push(WRAPPED_KEY_VERSION);
    111             wrapped.extend_from_slice(&nonce);
    112             wrapped.extend_from_slice(ciphertext.as_slice());
    113             WrappedSecret::from_bytes(wrapped)
    114         })
    115     }
    116 
    117     fn unwrap<'a>(
    118         &'a self,
    119         request: UnwrapRequest<'a>,
    120     ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> {
    121         Box::pin(async move {
    122             validate_daemon_reference(request.reference(), Operation::Unwrap)?;
    123             let aad = wrapping_aad(request.reference(), request.context());
    124             self.unwrap_with_aad(request.wrapped(), WRAPPED_KEY_VERSION, aad.as_slice())
    125         })
    126     }
    127 
    128     fn unwrap_legacy_v1<'a>(
    129         &'a self,
    130         request: LegacyV1UnwrapRequest<'a>,
    131     ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> {
    132         Box::pin(async move {
    133             validate_daemon_reference(request.reference(), Operation::Unwrap)?;
    134             self.unwrap_with_aad(
    135                 request.wrapped(),
    136                 LEGACY_WRAPPED_KEY_VERSION,
    137                 request.reference().id().as_str().as_bytes(),
    138             )
    139         })
    140     }
    141 }
    142 
    143 impl DaemonFileKeyWrapping {
    144     fn unwrap_with_aad(
    145         &self,
    146         wrapped: &WrappedSecret,
    147         expected_version: u8,
    148         aad: &[u8],
    149     ) -> Result<SecretMaterial, radroots_secrets::Error> {
    150         let wrapped = wrapped.as_bytes();
    151         if wrapped.len() <= 1 + WRAPPING_NONCE_BYTES || wrapped[0] != expected_version {
    152             return Err(secret_backend_failure(Operation::Unwrap));
    153         }
    154         let mut key = self.load_key()?;
    155         let plaintext = XChaCha20Poly1305::new(Key::from_slice(&key)).decrypt(
    156             XNonce::from_slice(&wrapped[1..1 + WRAPPING_NONCE_BYTES]),
    157             Payload {
    158                 msg: &wrapped[1 + WRAPPING_NONCE_BYTES..],
    159                 aad,
    160             },
    161         );
    162         key.zeroize();
    163         SecretMaterial::from_slice(
    164             &plaintext.map_err(|_| secret_backend_failure(Operation::Unwrap))?,
    165         )
    166     }
    167 }
    168 
    169 fn wrapping_aad(reference: &SecretRef, context: &EnvelopeContext) -> Vec<u8> {
    170     let id = reference.id().as_str().as_bytes();
    171     let mut aad = Vec::with_capacity(WRAPPING_AAD_DOMAIN.len() + 2 + id.len() + 4 + 32);
    172     aad.extend_from_slice(WRAPPING_AAD_DOMAIN);
    173     aad.extend_from_slice(
    174         &u16::try_from(id.len())
    175             .expect("validated secret identifier length fits u16")
    176             .to_be_bytes(),
    177     );
    178     aad.extend_from_slice(id);
    179     aad.extend_from_slice(&reference.key_version().get().to_be_bytes());
    180     aad.extend_from_slice(&context.authentication_digest());
    181     aad
    182 }
    183 
    184 fn validate_daemon_reference(
    185     reference: &SecretRef,
    186     operation: Operation,
    187 ) -> Result<(), radroots_secrets::Error> {
    188     if reference.backend() != BackendKind::External
    189         || reference.key_version().get() != 1
    190         || reference.id().as_str() != RADROOTSD_IDENTITY_KEY_SLOT
    191     {
    192         return Err(secret_backend_failure(operation));
    193     }
    194     Ok(())
    195 }
    196 
    197 fn identity_secret_ref() -> Result<SecretRef, radroots_secrets::Error> {
    198     Ok(SecretRef::new(
    199         SecretId::parse(RADROOTSD_IDENTITY_KEY_SLOT)?,
    200         BackendKind::External,
    201         KeyVersion::new(1)?,
    202     ))
    203 }
    204 
    205 fn identity_envelope_context() -> Result<EnvelopeContext, radroots_secrets::Error> {
    206     Ok(EnvelopeContext::new(
    207         EnvelopePurpose::parse("radroots.service_identity")?,
    208         EnvelopeSubject::parse("service", "radrootsd")?,
    209         PayloadSchemaId::parse("radroots.daemon_identity.v1")?,
    210     ))
    211 }
    212 
    213 fn secret_backend_failure(operation: Operation) -> radroots_secrets::Error {
    214     radroots_secrets::Error::BackendFailure {
    215         backend: BackendKind::External,
    216         operation,
    217     }
    218 }
    219 
    220 fn key_from_bytes(raw: &[u8]) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> {
    221     raw.try_into()
    222         .map_err(|_| secret_backend_failure(Operation::Read))
    223 }
    224 
    225 /// Host-private service signing identity.
    226 ///
    227 /// Public identity values cross package boundaries through
    228 /// `radroots_identity`; secret-key generation and custody remain daemon-owned.
    229 #[derive(Clone)]
    230 pub(crate) struct DaemonIdentity {
    231     keys: Keys,
    232 }
    233 
    234 impl core::fmt::Debug for DaemonIdentity {
    235     fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    236         formatter
    237             .debug_struct("DaemonIdentity")
    238             .field("public_key", &self.public_key_hex())
    239             .finish_non_exhaustive()
    240     }
    241 }
    242 
    243 #[derive(Deserialize, Serialize)]
    244 #[serde(deny_unknown_fields)]
    245 struct DaemonIdentityFile {
    246     secret_key: String,
    247     #[serde(skip_serializing_if = "Option::is_none")]
    248     public_key: Option<String>,
    249     #[serde(skip_serializing_if = "Option::is_none")]
    250     identifier: Option<String>,
    251     #[serde(skip_serializing_if = "Option::is_none")]
    252     metadata: Option<nostr::Event>,
    253     #[serde(skip_serializing_if = "Option::is_none")]
    254     application_handler: Option<nostr::Event>,
    255 }
    256 
    257 impl DaemonIdentity {
    258     pub(crate) fn generate() -> Self {
    259         Self {
    260             keys: Keys::generate(),
    261         }
    262     }
    263 
    264     pub(crate) const fn keys(&self) -> &Keys {
    265         &self.keys
    266     }
    267 
    268     pub(crate) fn public_key(&self) -> nostr::PublicKey {
    269         self.keys.public_key()
    270     }
    271 
    272     pub(crate) fn public_key_hex(&self) -> String {
    273         self.public_key().to_hex()
    274     }
    275 
    276     #[cfg(test)]
    277     pub(crate) fn id(&self) -> String {
    278         self.public_key_hex()
    279     }
    280 
    281     fn to_file(&self) -> DaemonIdentityFile {
    282         DaemonIdentityFile {
    283             secret_key: self.keys.secret_key().to_secret_hex(),
    284             public_key: Some(self.public_key_hex()),
    285             identifier: None,
    286             metadata: None,
    287             application_handler: None,
    288         }
    289     }
    290 
    291     fn from_file(file: DaemonIdentityFile) -> Result<Self> {
    292         let secret_key = SecretKey::parse(file.secret_key.as_str())
    293             .map_err(|_| anyhow::anyhow!("invalid daemon identity secret"))?;
    294         let identity = Self {
    295             keys: Keys::new(secret_key),
    296         };
    297         if file
    298             .public_key
    299             .as_deref()
    300             .is_some_and(|expected| expected != identity.public_key_hex())
    301         {
    302             bail!("daemon identity public key does not match encrypted secret");
    303         }
    304         Ok(identity)
    305     }
    306 }
    307 
    308 pub fn encrypted_identity_key_path(path: impl AsRef<Path>) -> PathBuf {
    309     let mut value = OsString::from(path.as_ref().as_os_str());
    310     value.push(".key");
    311     PathBuf::from(value)
    312 }
    313 
    314 pub fn load_service_identity(path: Option<&Path>, allow_generate: bool) -> Result<DaemonIdentity> {
    315     let path = resolved_identity_path(path);
    316     if path.exists() {
    317         return load_encrypted_identity(&path);
    318     }
    319     if !allow_generate {
    320         bail!(
    321             "daemon identity generation is not allowed at {}",
    322             path.display()
    323         );
    324     }
    325 
    326     let identity = DaemonIdentity::generate();
    327     store_encrypted_identity(&path, &identity)?;
    328     Ok(identity)
    329 }
    330 
    331 pub fn store_encrypted_identity(path: impl AsRef<Path>, identity: &DaemonIdentity) -> Result<()> {
    332     let path = path.as_ref();
    333     if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) {
    334         fs::create_dir_all(parent)?;
    335     }
    336     let payload = serde_json::to_vec(&identity.to_file())?;
    337     let plaintext = SecretMaterial::from_slice(payload.as_slice())?;
    338     let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>())?;
    339     let nonce = Nonce::new(rand::random());
    340     let wrapping = DaemonFileKeyWrapping::new(path);
    341     let context = identity_envelope_context()?;
    342     let envelope = futures_executor::block_on(EncryptedEnvelope::seal(
    343         &wrapping,
    344         SealRequest::new(
    345             identity_secret_ref()?,
    346             context,
    347             &plaintext,
    348             SealMaterial::new(data_key, nonce),
    349         ),
    350     ))?;
    351     persist_envelope(path, envelope.encode()?.as_slice())
    352 }
    353 
    354 fn persist_envelope(path: &Path, encoded: &[u8]) -> Result<()> {
    355     let mut temporary = tempfile::NamedTempFile::new_in(
    356         path.parent()
    357             .filter(|parent| !parent.as_os_str().is_empty())
    358             .unwrap_or(Path::new(".")),
    359     )?;
    360     temporary.write_all(encoded)?;
    361     temporary.as_file().sync_all()?;
    362     set_file_permissions(temporary.as_file())?;
    363     temporary.persist(path)?;
    364     sync_parent(path)?;
    365     Ok(())
    366 }
    367 
    368 pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<DaemonIdentity> {
    369     let path = path.as_ref();
    370     let encoded = fs::read(path)?;
    371     let envelope = EncryptedEnvelope::decode(encoded.as_slice())
    372         .context("decode encrypted daemon identity")?;
    373     let wrapping = DaemonFileKeyWrapping::new(path);
    374     let context = identity_envelope_context()?;
    375     if envelope.version() == LEGACY_ENVELOPE_VERSION {
    376         return migrate_legacy_identity(path, envelope, &wrapping, context);
    377     }
    378     if envelope.version() != ENVELOPE_VERSION {
    379         bail!("unsupported encrypted daemon identity version");
    380     }
    381     open_identity(&envelope, &wrapping, &context)
    382 }
    383 
    384 fn open_identity(
    385     envelope: &EncryptedEnvelope,
    386     wrapping: &DaemonFileKeyWrapping,
    387     context: &EnvelopeContext,
    388 ) -> Result<DaemonIdentity> {
    389     let payload = futures_executor::block_on(envelope.open(wrapping, context))
    390         .context("open encrypted daemon identity")?;
    391     let file: DaemonIdentityFile = payload.expose_secret(|bytes| serde_json::from_slice(bytes))?;
    392     DaemonIdentity::from_file(file)
    393 }
    394 
    395 fn migrate_legacy_identity(
    396     path: &Path,
    397     envelope: EncryptedEnvelope,
    398     wrapping: &DaemonFileKeyWrapping,
    399     context: EnvelopeContext,
    400 ) -> Result<DaemonIdentity> {
    401     let expected_reference = identity_secret_ref()?;
    402     let resealed = futures_executor::block_on(envelope.reseal_legacy_v1(
    403         wrapping,
    404         &LegacyV1ResealAuthority::new(),
    405         &expected_reference,
    406         identity_secret_ref()?,
    407         context.clone(),
    408         &valid_identity_payload,
    409         SealMaterial::new(
    410             SecretMaterial::from_slice(&rand::random::<[u8; 32]>())?,
    411             Nonce::new(rand::random()),
    412         ),
    413     ))
    414     .context("migrate legacy encrypted daemon identity")?;
    415     let envelope = resealed.into_envelope();
    416     let identity = open_identity(&envelope, wrapping, &context)?;
    417     persist_envelope(path, envelope.encode()?.as_slice())?;
    418     Ok(identity)
    419 }
    420 
    421 fn valid_identity_payload(bytes: &[u8]) -> bool {
    422     serde_json::from_slice::<DaemonIdentityFile>(bytes)
    423         .ok()
    424         .and_then(|file| DaemonIdentity::from_file(file).ok())
    425         .is_some()
    426 }
    427 
    428 #[cfg(unix)]
    429 fn set_secret_permissions(path: &Path) -> std::io::Result<()> {
    430     use std::os::unix::fs::PermissionsExt;
    431     fs::set_permissions(path, fs::Permissions::from_mode(0o600))
    432 }
    433 
    434 #[cfg(not(unix))]
    435 fn set_secret_permissions(_path: &Path) -> std::io::Result<()> {
    436     Ok(())
    437 }
    438 
    439 fn set_file_permissions(file: &fs::File) -> std::io::Result<()> {
    440     #[cfg(unix)]
    441     {
    442         use std::os::unix::fs::PermissionsExt;
    443         file.set_permissions(fs::Permissions::from_mode(0o600))
    444     }
    445     #[cfg(not(unix))]
    446     {
    447         let _ = file;
    448         Ok(())
    449     }
    450 }
    451 
    452 fn sync_parent(path: &Path) -> std::io::Result<()> {
    453     let parent = path
    454         .parent()
    455         .filter(|parent| !parent.as_os_str().is_empty())
    456         .unwrap_or(Path::new("."));
    457     fs::File::open(parent)?.sync_all()
    458 }
    459 
    460 fn resolved_identity_path(path: Option<&Path>) -> PathBuf {
    461     path.map(Path::to_path_buf).unwrap_or_else(|| {
    462         crate::app::paths::default_identity_path_for_process()
    463             .expect("resolve canonical radrootsd identity path")
    464     })
    465 }
    466 
    467 #[cfg(test)]
    468 mod tests {
    469     use std::fs;
    470 
    471     use chacha20poly1305::aead::{Aead, KeyInit, Payload};
    472     use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
    473     use radroots_secrets::EncryptedEnvelope;
    474     use radroots_secrets::envelope::{ENVELOPE_VERSION, LEGACY_ENVELOPE_VERSION};
    475 
    476     use super::{
    477         DaemonIdentity, RADROOTSD_IDENTITY_KEY_SLOT, encrypted_identity_key_path,
    478         identity_envelope_context, load_service_identity, set_secret_permissions,
    479     };
    480 
    481     #[test]
    482     fn load_service_identity_generates_encrypted_identity_artifacts() {
    483         let temp = tempfile::tempdir().expect("tempdir");
    484         let path = temp.path().join("radrootsd-identity.secret.json");
    485 
    486         let generated =
    487             load_service_identity(Some(&path), true).expect("generate encrypted identity");
    488         let loaded = load_service_identity(Some(&path), false).expect("load encrypted identity");
    489 
    490         assert_eq!(generated.id(), loaded.id());
    491         assert!(path.is_file());
    492         assert!(encrypted_identity_key_path(&path).is_file());
    493         let envelope = EncryptedEnvelope::decode(&fs::read(&path).expect("read envelope"))
    494             .expect("decode envelope");
    495         assert_eq!(envelope.version(), ENVELOPE_VERSION);
    496         assert_eq!(
    497             envelope.context(),
    498             Some(&identity_envelope_context().expect("identity context"))
    499         );
    500     }
    501 
    502     #[test]
    503     fn load_service_identity_fails_when_wrapping_key_is_missing() {
    504         let temp = tempfile::tempdir().expect("tempdir");
    505         let path = temp.path().join("radrootsd-identity.secret.json");
    506         let _ = load_service_identity(Some(&path), true).expect("generate encrypted identity");
    507         std::fs::remove_file(encrypted_identity_key_path(&path)).expect("remove wrapping key");
    508 
    509         let err = load_service_identity(Some(&path), false)
    510             .expect_err("missing wrapping key should fail");
    511         assert!(err.to_string().contains("identity"));
    512     }
    513 
    514     #[test]
    515     fn load_service_identity_atomically_migrates_legacy_v1() {
    516         let temp = tempfile::tempdir().expect("tempdir");
    517         let path = temp.path().join("radrootsd-identity.secret.json");
    518         let expected = DaemonIdentity::generate();
    519         store_legacy_identity(&path, &expected);
    520 
    521         let loaded = load_service_identity(Some(&path), false).expect("migrate legacy identity");
    522         assert_eq!(loaded.id(), expected.id());
    523 
    524         let encoded = fs::read(&path).expect("read migrated identity");
    525         let migrated = EncryptedEnvelope::decode(&encoded).expect("decode migrated identity");
    526         assert_eq!(migrated.version(), ENVELOPE_VERSION);
    527         assert_eq!(
    528             migrated.context(),
    529             Some(&identity_envelope_context().expect("identity context"))
    530         );
    531         let reloaded = load_service_identity(Some(&path), false).expect("reload migrated identity");
    532         assert_eq!(reloaded.id(), expected.id());
    533     }
    534 
    535     fn store_legacy_identity(path: &std::path::Path, identity: &DaemonIdentity) {
    536         const NONCE_BYTES: usize = 24;
    537         const TAG_BYTES: usize = 16;
    538 
    539         let wrapping_key = [0x11; 32];
    540         let data_key = [0x22; 32];
    541         let wrapping_nonce = [0x33; NONCE_BYTES];
    542         let envelope_nonce = [0x44; NONCE_BYTES];
    543         let payload = serde_json::to_vec(&identity.to_file()).expect("encode identity payload");
    544 
    545         let wrapped_ciphertext = XChaCha20Poly1305::new(Key::from_slice(&wrapping_key))
    546             .encrypt(
    547                 XNonce::from_slice(&wrapping_nonce),
    548                 Payload {
    549                     msg: &data_key,
    550                     aad: RADROOTSD_IDENTITY_KEY_SLOT.as_bytes(),
    551                 },
    552             )
    553             .expect("wrap legacy data key");
    554         let mut wrapped = Vec::with_capacity(1 + NONCE_BYTES + wrapped_ciphertext.len());
    555         wrapped.push(1);
    556         wrapped.extend_from_slice(&wrapping_nonce);
    557         wrapped.extend_from_slice(&wrapped_ciphertext);
    558 
    559         let id = RADROOTSD_IDENTITY_KEY_SLOT.as_bytes();
    560         let ciphertext_len = u32::try_from(payload.len() + TAG_BYTES).expect("ciphertext length");
    561         let mut encoded = Vec::new();
    562         encoded.extend_from_slice(b"RRS1");
    563         encoded.extend_from_slice(&LEGACY_ENVELOPE_VERSION.to_be_bytes());
    564         encoded.push(1); // XChaCha20-Poly1305
    565         encoded.push(1); // provider-wrapped key source
    566         encoded.push(4); // external backend
    567         encoded.extend_from_slice(&1_u32.to_be_bytes());
    568         encoded.extend_from_slice(&u16::try_from(id.len()).expect("id length").to_be_bytes());
    569         encoded.extend_from_slice(id);
    570         encoded.extend_from_slice(&envelope_nonce);
    571         encoded.extend_from_slice(
    572             &u32::try_from(wrapped.len())
    573                 .expect("wrapped length")
    574                 .to_be_bytes(),
    575         );
    576         encoded.extend_from_slice(&wrapped);
    577         encoded.extend_from_slice(&ciphertext_len.to_be_bytes());
    578         let ciphertext = XChaCha20Poly1305::new(Key::from_slice(&data_key))
    579             .encrypt(
    580                 XNonce::from_slice(&envelope_nonce),
    581                 Payload {
    582                     msg: &payload,
    583                     aad: &encoded,
    584                 },
    585             )
    586             .expect("encrypt legacy payload");
    587         encoded.extend_from_slice(&ciphertext);
    588 
    589         fs::write(path, encoded).expect("write legacy envelope");
    590         let key_path = encrypted_identity_key_path(path);
    591         fs::write(&key_path, wrapping_key).expect("write wrapping key");
    592         set_secret_permissions(&key_path).expect("secure wrapping key");
    593     }
    594 }