// file: crates/ksp-wallet-lib/src/payload.rs // version: 6 //! Plaintext payload codecs shared by the initial native `.kspwallet` V1/V2 protected compartments. use base64::Engine; // rust-rules: trait-import use std::str::FromStr; // rust-rules: trait-import /// Crate-internal `MetadataPayloadV1` state shared across the owning crate. pub(crate) struct MetadataPayloadV1 { pubkey: ksp_core_lib::Pubkey, alias: std::option::Option, notes: std::vec::Vec, } impl crate::MetadataPayloadV1 { /// Returns the current pubkey. pub(crate) const fn pubkey(&self) -> &ksp_core_lib::Pubkey { return &self.pubkey; } /// Builds `MetadataPayloadV1` from info. pub(crate) fn from_info(info: &crate::WalletInfo) -> Self { let alias = info.alias().map(std::string::String::from); let notes = info.notes().to_vec(); return Self { pubkey: *info.pubkey(), alias, notes }; } /// Updates alias. pub(crate) fn set_alias(&mut self, alias: std::option::Option) -> ksp_core_lib::Result<()> { let validation_result = validate_alias(alias.as_deref()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } self.alias = alias; return std::result::Result::Ok(()); } /// Adds note. pub(crate) fn add_note(&mut self, text: std::string::String) -> ksp_core_lib::Result { if self.notes.len() >= crate::KSPWALLET_V1_MAX_NOTES { return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes")); } let validation_result = validate_note_text(text.as_str()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } let note_id_bytes = match crate::random_bytes::<{ crate::KSPWALLET_V1_NOTE_ID_BYTES }>() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let note_id = encode_base64url(note_id_bytes.as_slice()); for note in &self.notes { if note.id() == note_id.as_str() { return std::result::Result::Err(random_note_identifier_error()); } } self.notes.push(crate::WalletNote::new(note_id.clone(), text)); return std::result::Result::Ok(note_id); } /// Updates note. pub(crate) fn update_note(&mut self, note_id: &str, text: std::string::String) -> ksp_core_lib::Result<()> { let validation_result = validate_note_text(text.as_str()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } let mut index = 0_usize; while index < self.notes.len() { if self.notes[index].id() == note_id { self.notes[index] = crate::WalletNote::new(std::string::String::from(note_id), text); return std::result::Result::Ok(()); } index += 1; } return std::result::Result::Err(note_not_found_error()); } /// Deletes note. pub(crate) fn delete_note(&mut self, note_id: &str) -> ksp_core_lib::Result<()> { let mut index = 0_usize; while index < self.notes.len() { if self.notes[index].id() == note_id { self.notes.remove(index); return std::result::Result::Ok(()); } index += 1; } return std::result::Result::Err(note_not_found_error()); } /// Executes the crate-internal encode operation for `MetadataPayloadV1`. pub(crate) fn encode(&self) -> ksp_core_lib::Result> { let validation_result = validate_alias(self.alias.as_deref()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } if self.notes.len() > crate::KSPWALLET_V1_MAX_NOTES { return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes")); } let mut raw_notes = std::vec::Vec::with_capacity(self.notes.len()); for note in &self.notes { let validation_result = validate_note_text(note.text()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } raw_notes.push(RawMetadataNoteV1 { id: std::string::String::from(note.id()), text: std::string::String::from(note.text()) }); } let raw = RawMetadataPayloadV1 { pubkey: self.pubkey.to_string(), alias: self.alias.clone(), notes: raw_notes }; let serialized_result = serde_json::to_vec(&raw); let serialized = match serialized_result { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata payload cannot be serialized", "metadata")), }; if serialized.len() > crate::KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES { return std::result::Result::Err(metadata_error("Wallet metadata payload exceeds the V1 plaintext limit", "metadata")); } return std::result::Result::Ok(serialized); } /// Consumes this value and returns info. pub(crate) fn into_info(self, format_version: u32, capability: crate::WalletCapability) -> crate::WalletInfo { return crate::WalletInfo::new(format_version, capability, self.pubkey, self.alias, self.notes); } } /// Crate-internal `OwnerControlMaterialV1` state shared across the owning crate. pub(crate) struct OwnerControlMaterialV1 { admin_signing_secret: [u8; crate::SECRET_KEY_BYTES], metadata_key: crate::SecretKeyV1, secret_key: crate::SecretKeyV1, } impl OwnerControlMaterialV1 { /// Consumes this value and returns parts. pub(crate) fn into_parts(mut self) -> ([u8; crate::SECRET_KEY_BYTES], crate::SecretKeyV1, crate::SecretKeyV1) { let mut admin_signing_secret = [0_u8; crate::SECRET_KEY_BYTES]; std::mem::swap(&mut admin_signing_secret, &mut self.admin_signing_secret); let metadata_key = std::mem::replace(&mut self.metadata_key, crate::SecretKeyV1::from_bytes([0_u8; crate::SECRET_KEY_BYTES])); let secret_key = std::mem::replace(&mut self.secret_key, crate::SecretKeyV1::from_bytes([0_u8; crate::SECRET_KEY_BYTES])); return (admin_signing_secret, metadata_key, secret_key); } } impl std::ops::Drop for OwnerControlMaterialV1 { fn drop(&mut self) { zeroize::Zeroize::zeroize(&mut self.admin_signing_secret); } } #[derive(serde::Deserialize, serde::Serialize)] #[serde(deny_unknown_fields)] struct RawMetadataPayloadV1 { pubkey: std::string::String, alias: std::option::Option, notes: std::vec::Vec, } #[derive(serde::Deserialize, serde::Serialize)] #[serde(deny_unknown_fields)] struct RawMetadataNoteV1 { id: std::string::String, text: std::string::String, } /// Encodes initial metadata payload. pub(crate) fn encode_initial_metadata_payload( pubkey: ksp_core_lib::Pubkey, metadata: crate::WalletCreateMetadataV1, ) -> ksp_core_lib::Result<(std::vec::Vec, crate::MetadataPayloadV1)> { let (alias, note_texts) = metadata.into_parts(); let validation_result = validate_alias(alias.as_deref()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } if note_texts.len() > crate::KSPWALLET_V1_MAX_NOTES { return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes")); } let mut notes = std::vec::Vec::with_capacity(note_texts.len()); let mut note_ids = std::collections::BTreeSet::new(); for text in note_texts { let validation_result = validate_note_text(text.as_str()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } let note_id_bytes = match crate::random_bytes::<{ crate::KSPWALLET_V1_NOTE_ID_BYTES }>() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let note_id = encode_base64url(note_id_bytes.as_slice()); if !note_ids.insert(note_id.clone()) { return std::result::Result::Err(random_note_identifier_error()); } notes.push(crate::WalletNote::new(note_id, text)); } let payload = crate::MetadataPayloadV1 { pubkey, alias, notes }; let serialized = match payload.encode() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok((serialized, payload)); } /// Decodes metadata payload. pub(crate) fn decode_metadata_payload(source: &[u8]) -> ksp_core_lib::Result { if source.len() > crate::KSPWALLET_V1_MAX_METADATA_PLAINTEXT_BYTES { return std::result::Result::Err(metadata_error("Wallet metadata payload exceeds the V1 plaintext limit", "metadata")); } let raw_result = serde_json::from_slice::(source); let raw = match raw_result { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata payload is invalid", "metadata")), }; let validation_result = validate_alias(raw.alias.as_deref()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } if raw.notes.len() > crate::KSPWALLET_V1_MAX_NOTES { return std::result::Result::Err(metadata_error("Wallet note count exceeds the V1 limit", "metadata.notes")); } let pubkey_result = ksp_core_lib::Pubkey::from_str(raw.pubkey.as_str()); let pubkey = match pubkey_result { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata Pubkey is invalid", "metadata.pubkey")), }; if pubkey.to_string() != raw.pubkey { return std::result::Result::Err(metadata_error("Wallet metadata Pubkey is not canonical Base58", "metadata.pubkey")); } let mut notes = std::vec::Vec::with_capacity(raw.notes.len()); let mut note_ids = std::collections::BTreeSet::new(); for raw_note in raw.notes { let validation_result = validate_note_text(raw_note.text.as_str()); if let std::result::Result::Err(error) = validation_result { return std::result::Result::Err(error); } let id_bytes = match decode_base64url(raw_note.id.as_str()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if id_bytes.len() != crate::KSPWALLET_V1_NOTE_ID_BYTES || encode_base64url(id_bytes.as_slice()) != raw_note.id { return std::result::Result::Err(metadata_error("Wallet note identifier is not canonical V1 Base64url", "metadata.notes.id")); } if !note_ids.insert(raw_note.id.clone()) { return std::result::Result::Err(metadata_error("Wallet note identifiers must be unique", "metadata.notes.id")); } notes.push(crate::WalletNote::new(raw_note.id, raw_note.text)); } return std::result::Result::Ok(crate::MetadataPayloadV1 { pubkey, alias: raw.alias, notes }); } /// Encodes owner control payload. pub(crate) fn encode_owner_control_payload( admin_signing_secret: &[u8; crate::SECRET_KEY_BYTES], metadata_key: &crate::SecretKeyV1, secret_key: &crate::SecretKeyV1, ) -> [u8; crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES] { let mut output = [0_u8; crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES]; output[0..32].copy_from_slice(admin_signing_secret); output[32..64].copy_from_slice(metadata_key.as_bytes()); output[64..96].copy_from_slice(secret_key.as_bytes()); return output; } /// Decodes owner control payload. pub(crate) fn decode_owner_control_payload(source: &[u8]) -> ksp_core_lib::Result { if source.len() != crate::KSPWALLET_V1_OWNER_CONTROL_PLAINTEXT_BYTES { return std::result::Result::Err(metadata_error("Wallet OWNER-control payload length is invalid", "owner_control")); } let mut admin_signing_secret = [0_u8; crate::SECRET_KEY_BYTES]; admin_signing_secret.copy_from_slice(&source[0..32]); let mut metadata_key = [0_u8; crate::SECRET_KEY_BYTES]; metadata_key.copy_from_slice(&source[32..64]); let mut secret_key = [0_u8; crate::SECRET_KEY_BYTES]; secret_key.copy_from_slice(&source[64..96]); return std::result::Result::Ok(OwnerControlMaterialV1 { admin_signing_secret, metadata_key: crate::SecretKeyV1::from_bytes(metadata_key), secret_key: crate::SecretKeyV1::from_bytes(secret_key), }); } fn validate_alias(alias: std::option::Option<&str>) -> ksp_core_lib::Result<()> { if let std::option::Option::Some(alias_value) = alias && alias_value.len() > crate::KSPWALLET_V1_MAX_ALIAS_BYTES { return std::result::Result::Err(metadata_error("Wallet alias exceeds the V1 UTF-8 byte limit", "metadata.alias")); } return std::result::Result::Ok(()); } fn validate_note_text(text: &str) -> ksp_core_lib::Result<()> { if text.len() > crate::KSPWALLET_V1_MAX_NOTE_TEXT_BYTES { return std::result::Result::Err(metadata_error("Wallet note text exceeds the V1 UTF-8 byte limit", "metadata.notes.text")); } return std::result::Result::Ok(()); } fn encode_base64url(bytes: &[u8]) -> std::string::String { return base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); } fn decode_base64url(encoded: &str) -> ksp_core_lib::Result> { let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes()); let value = match decoded { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(metadata_error("Wallet metadata Base64url field is invalid", "metadata.notes.id")), }; return std::result::Result::Ok(value); } fn random_note_identifier_error() -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_RANDOMNESS_FAILED, "Wallet generated a duplicate protected note identifier"); } fn note_not_found_error() -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_NOTE_NOT_FOUND, "Wallet note identifier was not found"); } fn metadata_error(message: &'static str, field: &'static str) -> ksp_core_lib::Error { return ksp_core_lib::Error::new(crate::ERROR_CODE_FORMAT_INVALID, message).with_context("field", field); } #[cfg(test)] #[path = "../unit_tests/payload.rs"] mod tests;