use thiserror::Error;
use crate::event::{Event, EventBuilder, Kind, Tag, TagKind};
use crate::key::{PublicKey, SecretKey};
use crate::nips::nip44::{self, Nip44Error};
use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError};
pub const KIND_DRAFT_WRAP: Kind = Kind::DRAFT_WRAP;
pub const KIND_PRIVATE_STORAGE_RELAYS: Kind = Kind::PRIVATE_STORAGE_RELAYS;
const KIND_TAG: &str = "k";
const EXPIRATION_TAG: &str = "expiration";
const RELAY_TAG: &str = "relay";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftWrap {
pub identifier: String,
pub draft_kind: Kind,
pub expiration: Option<Timestamp>,
pub ciphertext: String,
pub extra_tags: Vec<Tag>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PrivateStorageRelays {
pub ciphertext: String,
pub extra_tags: Vec<Tag>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DraftError {
#[error("unexpected kind for NIP-37 event: {}", .0.as_u16())]
WrongKind(Kind),
#[error("draft wrap missing `d` identifier")]
MissingIdentifier,
#[error("draft wrap missing `k` tag (wrapped draft kind)")]
MissingDraftKind,
#[error("draft wrap `k` tag value `{0}` is not a valid kind")]
InvalidDraftKind(String),
#[error(transparent)]
InvalidTimestamp(#[from] TimestampError),
#[error(transparent)]
Encryption(#[from] Nip44Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
InvalidRelayUrl(#[from] RelayUrlError),
}
impl DraftWrap {
#[must_use]
pub fn new(
identifier: impl Into<String>,
draft_kind: Kind,
ciphertext: impl Into<String>,
) -> Self {
Self {
identifier: identifier.into(),
draft_kind,
expiration: None,
ciphertext: ciphertext.into(),
extra_tags: Vec::new(),
}
}
pub fn encrypt(
identifier: impl Into<String>,
draft_kind: Kind,
plaintext_event_json: &str,
secret: &SecretKey,
public_key: &PublicKey,
) -> Result<Self, DraftError> {
let ciphertext = nip44::encrypt(secret, public_key, plaintext_event_json)?;
Ok(Self::new(identifier, draft_kind, ciphertext))
}
pub fn decrypt(
&self,
secret: &SecretKey,
public_key: &PublicKey,
) -> Result<Option<String>, DraftError> {
if self.ciphertext.is_empty() {
return Ok(None);
}
Ok(Some(nip44::decrypt(secret, public_key, &self.ciphertext)?))
}
#[must_use]
pub const fn is_tombstone(&self) -> bool {
self.ciphertext.is_empty()
}
pub fn from_event(event: &Event) -> Result<Self, DraftError> {
if event.kind != KIND_DRAFT_WRAP {
return Err(DraftError::WrongKind(event.kind));
}
let mut identifier: Option<String> = None;
let mut draft_kind: Option<Kind> = None;
let mut expiration: Option<Timestamp> = None;
let mut extra_tags: Vec<Tag> = Vec::new();
for tag in &event.tags {
absorb_draft_tag(
tag,
&mut identifier,
&mut draft_kind,
&mut expiration,
&mut extra_tags,
)?;
}
Ok(Self {
identifier: identifier.ok_or(DraftError::MissingIdentifier)?,
draft_kind: draft_kind.ok_or(DraftError::MissingDraftKind)?,
expiration,
ciphertext: event.content.clone(),
extra_tags,
})
}
}
fn absorb_draft_tag(
tag: &Tag,
identifier: &mut Option<String>,
draft_kind: &mut Option<Kind>,
expiration: &mut Option<Timestamp>,
extra_tags: &mut Vec<Tag>,
) -> Result<(), DraftError> {
match tag.kind() {
TagKind::SingleLetter(s)
if !s.uppercase && s.character == crate::event::Alphabet::D && identifier.is_none() =>
{
*identifier = tag.get(1).map(str::to_owned);
}
_ if tag.name() == KIND_TAG && draft_kind.is_none() => {
let raw = tag.get(1).ok_or(DraftError::MissingDraftKind)?;
let value = raw
.parse::<u16>()
.map_err(|_| DraftError::InvalidDraftKind(raw.to_owned()))?;
*draft_kind = Some(Kind::new(value));
}
_ if tag.name() == EXPIRATION_TAG => {
if let Some(raw) = tag.get(1) {
*expiration = Some(raw.parse::<Timestamp>()?);
}
}
_ => extra_tags.push(tag.clone()),
}
Ok(())
}
impl PrivateStorageRelays {
#[must_use]
pub fn new(ciphertext: impl Into<String>) -> Self {
Self {
ciphertext: ciphertext.into(),
extra_tags: Vec::new(),
}
}
pub fn encrypt(
relays: &[RelayUrl],
secret: &SecretKey,
public_key: &PublicKey,
) -> Result<Self, DraftError> {
let payload: Vec<Vec<String>> = relays
.iter()
.map(|relay| vec![RELAY_TAG.to_owned(), relay.as_str().to_owned()])
.collect();
let plaintext = serde_json::to_string(&payload)?;
let ciphertext = nip44::encrypt(secret, public_key, &plaintext)?;
Ok(Self::new(ciphertext))
}
pub fn decrypt(
&self,
secret: &SecretKey,
public_key: &PublicKey,
) -> Result<Vec<RelayUrl>, DraftError> {
if self.ciphertext.is_empty() {
return Ok(Vec::new());
}
let plaintext = nip44::decrypt(secret, public_key, &self.ciphertext)?;
let rows: Vec<Vec<String>> = serde_json::from_str(&plaintext)?;
let mut relays: Vec<RelayUrl> = Vec::new();
for row in rows {
let mut iter = row.into_iter();
let head = iter.next();
let value = iter.next();
if head.as_deref() != Some(RELAY_TAG) {
continue;
}
if let Some(raw) = value {
relays.push(RelayUrl::parse(&raw)?);
}
}
Ok(relays)
}
pub fn from_event(event: &Event) -> Result<Self, DraftError> {
if event.kind != KIND_PRIVATE_STORAGE_RELAYS {
return Err(DraftError::WrongKind(event.kind));
}
Ok(Self {
ciphertext: event.content.clone(),
extra_tags: event.tags.iter().cloned().collect(),
})
}
}
impl EventBuilder {
#[must_use]
pub fn draft_wrap(draft: &DraftWrap) -> Self {
let mut builder = Self::new(KIND_DRAFT_WRAP, draft.ciphertext.clone());
builder = builder.tag(Tag::d(&draft.identifier)).tag(Tag::with(
&TagKind::from_wire(KIND_TAG),
[draft.draft_kind.as_u16().to_string()],
));
if let Some(ts) = draft.expiration {
builder = builder.tag(Tag::with(
&TagKind::from_wire(EXPIRATION_TAG),
[ts.as_secs().to_string()],
));
}
for tag in &draft.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
#[must_use]
pub fn private_storage_relays(list: &PrivateStorageRelays) -> Self {
let mut builder = Self::new(KIND_PRIVATE_STORAGE_RELAYS, list.ciphertext.clone());
for tag in &list.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
#[test]
fn draft_round_trip() {
let plaintext = r#"{"kind":1,"content":"hi"}"#;
let draft = DraftWrap::encrypt(
"draft-1",
Kind::TEXT_NOTE,
plaintext,
keys().secret_key(),
keys().public_key(),
)
.unwrap();
let event = EventBuilder::draft_wrap(&draft)
.sign_with_keys(&keys())
.unwrap();
let parsed = DraftWrap::from_event(&event).unwrap();
assert_eq!(parsed.identifier, draft.identifier);
assert_eq!(parsed.draft_kind, Kind::TEXT_NOTE);
let decrypted = parsed
.decrypt(keys().secret_key(), keys().public_key())
.unwrap();
assert_eq!(decrypted.as_deref(), Some(plaintext));
}
#[test]
fn tombstone_decrypts_as_none() {
let draft = DraftWrap::new("d", Kind::TEXT_NOTE, "");
assert!(draft.is_tombstone());
let decrypted = draft
.decrypt(keys().secret_key(), keys().public_key())
.unwrap();
assert!(decrypted.is_none());
}
#[test]
fn private_storage_relays_round_trip() {
let relays = vec![
RelayUrl::parse("wss://private.example/").unwrap(),
RelayUrl::parse("wss://other.example/").unwrap(),
];
let list = PrivateStorageRelays::encrypt(&relays, keys().secret_key(), keys().public_key())
.unwrap();
let event = EventBuilder::private_storage_relays(&list)
.sign_with_keys(&keys())
.unwrap();
let parsed = PrivateStorageRelays::from_event(&event).unwrap();
let decrypted = parsed
.decrypt(keys().secret_key(), keys().public_key())
.unwrap();
assert_eq!(decrypted, relays);
}
#[test]
fn missing_kind_is_rejected() {
let event = EventBuilder::new(KIND_DRAFT_WRAP, "ct")
.tag(Tag::d("foo"))
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
DraftWrap::from_event(&event),
Err(DraftError::MissingDraftKind)
));
}
#[test]
fn wrong_kind_is_rejected() {
let event = EventBuilder::text_note("not a draft")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
DraftWrap::from_event(&event),
Err(DraftError::WrongKind(_))
));
assert!(matches!(
PrivateStorageRelays::from_event(&event),
Err(DraftError::WrongKind(_))
));
}
#[test]
fn invalid_k_tag_is_rejected() {
let event = EventBuilder::new(KIND_DRAFT_WRAP, "")
.tag(Tag::d("foo"))
.tag(Tag::with(
&TagKind::from_wire(KIND_TAG),
["not-a-number".to_owned()],
))
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
DraftWrap::from_event(&event),
Err(DraftError::InvalidDraftKind(raw)) if raw == "not-a-number"
));
}
}