Skip to main content

voidnote/
lib.rs

1//! # VoidNote — Official Rust SDK
2//!
3//! Zero-knowledge self-destructing notes and live encrypted streams.
4//! The key lives in the link. We never see it.
5//!
6//! ## Quick start
7//!
8//! ```rust,no_run
9//! use voidnote::{read, create, CreateOptions};
10//!
11//! #[tokio::main]
12//! async fn main() -> voidnote::Result<()> {
13//!     // Create a note
14//!     let note = create("my secret message", CreateOptions {
15//!         api_key: "vn_...".into(),
16//!         max_views: Some(1),
17//!         ..Default::default()
18//!     }).await?;
19//!     println!("share: {}", note.url);
20//!
21//!     // Read it back
22//!     let result = read(&note.url).await?;
23//!     println!("{}", result.content);
24//!     Ok(())
25//! }
26//! ```
27
28use std::fmt;
29
30use aes_gcm::{
31    aead::{Aead, KeyInit, OsRng},
32    Aes256Gcm, Key, Nonce,
33};
34use futures_core::Stream;
35use hex::{decode as hex_decode, encode as hex_encode};
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38
39// ── Re-export for convenience ─────────────────────────────────────────────────
40
41pub use self::stream::StreamHandle;
42
43const DEFAULT_BASE: &str = "https://voidnote.net";
44
45// ── Error ─────────────────────────────────────────────────────────────────────
46
47/// All errors this SDK can produce.
48#[derive(Debug)]
49pub enum Error {
50    /// Token is not a valid 64-char hex string
51    InvalidToken,
52    /// An API key was required but not provided
53    MissingApiKey,
54    /// HTTP transport error (DNS, TLS, timeout)
55    Network(reqwest::Error),
56    /// Server returned a non-2xx status code
57    Http { status: u16, body: String },
58    /// Response JSON could not be parsed
59    Json(serde_json::Error),
60    /// AES-GCM authentication failed (wrong key or tampered data)
61    DecryptionFailed,
62    /// Hex string is invalid
63    InvalidHex(hex::FromHexError),
64}
65
66impl fmt::Display for Error {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Error::InvalidToken => write!(f, "invalid token: expected 64-char hex string"),
70            Error::MissingApiKey => write!(f, "api_key is required"),
71            Error::Network(e) => write!(f, "network error: {e}"),
72            Error::Http { status, body } => write!(f, "HTTP {status}: {body}"),
73            Error::Json(e) => write!(f, "JSON parse error: {e}"),
74            Error::DecryptionFailed => write!(f, "decryption failed: wrong key or tampered data"),
75            Error::InvalidHex(e) => write!(f, "invalid hex: {e}"),
76        }
77    }
78}
79
80impl std::error::Error for Error {}
81
82impl From<reqwest::Error> for Error {
83    fn from(e: reqwest::Error) -> Self {
84        Error::Network(e)
85    }
86}
87
88impl From<serde_json::Error> for Error {
89    fn from(e: serde_json::Error) -> Self {
90        Error::Json(e)
91    }
92}
93
94impl From<hex::FromHexError> for Error {
95    fn from(e: hex::FromHexError) -> Self {
96        Error::InvalidHex(e)
97    }
98}
99
100/// SDK result type.
101pub type Result<T> = std::result::Result<T, Error>;
102
103// ── Types ─────────────────────────────────────────────────────────────────────
104
105/// Decrypted contents of a VoidNote.
106#[derive(Debug, Clone)]
107pub struct ReadResult {
108    pub content: String,
109    pub title: Option<String>,
110    pub view_count: u32,
111    pub max_views: u32,
112    /// True if the note was destroyed after this read (view limit reached).
113    pub destroyed: bool,
114}
115
116/// Options for creating a note.
117#[derive(Debug, Clone, Default)]
118pub struct CreateOptions {
119    /// Required: vn_... API key from your dashboard
120    pub api_key: String,
121    pub title: Option<String>,
122    /// 1–100; defaults to 1
123    pub max_views: Option<u8>,
124    /// Hours until expiry. Valid values: 1, 6, 24, 72, 168, 720. Defaults to 24.
125    pub expires_in: Option<u16>,
126    /// Note type: "secure" (default, confirm to reveal) or "pipe" (auto-reveal for scripts).
127    pub note_type: Option<String>,
128    /// Override the API base URL (default: https://voidnote.net)
129    pub base: Option<String>,
130}
131
132/// Result of creating a note.
133#[derive(Debug, Clone)]
134pub struct CreateResult {
135    /// Full shareable URL (contains the decryption key)
136    pub url: String,
137    pub expires_at: String,
138}
139
140/// Options for creating a Void Stream.
141#[derive(Debug, Clone, Default)]
142pub struct StreamOptions {
143    /// Required: vn_... API key from your dashboard
144    pub api_key: String,
145    pub title: Option<String>,
146    /// TTL in seconds: 3600 (1h), 21600 (6h), or 86400 (24h). Defaults to 3600.
147    pub ttl: Option<u32>,
148    /// Override the API base URL (default: https://voidnote.net)
149    pub base: Option<String>,
150}
151
152// ── Public API ────────────────────────────────────────────────────────────────
153
154/// Read and decrypt a VoidNote.
155///
156/// `url_or_token` may be a full URL (`https://voidnote.net/note/<token>`)
157/// or a raw 64-char hex token.
158pub async fn read(url_or_token: &str) -> Result<ReadResult> {
159    read_from(url_or_token, DEFAULT_BASE).await
160}
161
162/// Like [`read`] but allows overriding the API base URL.
163pub async fn read_from(url_or_token: &str, base: &str) -> Result<ReadResult> {
164    let token = extract_token(url_or_token)?;
165    let token_id = &token[..32];
166    let secret = &token[32..];
167
168    let url = format!("{base}/api/note/{token_id}");
169    let resp = reqwest::get(&url).await?;
170
171    let status = resp.status().as_u16();
172    let body = resp.text().await?;
173
174    if status == 404 {
175        return Err(Error::Http {
176            status,
177            body: "note not found or already destroyed".into(),
178        });
179    }
180    if status != 200 {
181        return Err(Error::Http { status, body });
182    }
183
184    // The API uses a mix of snake_case and camelCase; accept both.
185    #[derive(Deserialize)]
186    struct RawResponse {
187        encrypted_content: Option<String>,
188        iv: String,
189        title: Option<String>,
190        // Accept both naming conventions
191        #[serde(alias = "viewCount", default)]
192        view_count: u32,
193        #[serde(alias = "maxViews", default)]
194        max_views: u32,
195        #[serde(default)]
196        destroyed: bool,
197    }
198
199    let raw: RawResponse = serde_json::from_str(&body)?;
200    let enc_hex = raw.encrypted_content.as_deref().unwrap_or_default();
201
202    let content = decrypt_content(enc_hex, &raw.iv, secret)?;
203
204    Ok(ReadResult {
205        content,
206        title: raw.title,
207        view_count: raw.view_count,
208        max_views: raw.max_views,
209        destroyed: raw.destroyed,
210    })
211}
212
213/// Create and encrypt a VoidNote client-side. Requires an API key.
214/// The server never sees the plaintext.
215pub async fn create(content: &str, opts: CreateOptions) -> Result<CreateResult> {
216    if opts.api_key.is_empty() {
217        return Err(Error::MissingApiKey);
218    }
219    let base = opts.base.as_deref().unwrap_or(DEFAULT_BASE);
220
221    let (full_token, token_id, secret) = generate_token();
222    let (enc_hex, iv_hex) = encrypt_content(content, &secret)?;
223
224    let max_views = opts.max_views.unwrap_or(1);
225
226    #[derive(Serialize)]
227    #[serde(rename_all = "camelCase")]
228    struct CreateBody<'a> {
229        token_id: &'a str,
230        encrypted_content: &'a str,
231        iv: &'a str,
232        max_views: u8,
233        title: Option<&'a str>,
234        expires_in: u16,
235        note_type: &'a str,
236    }
237
238    let expires_in = opts.expires_in.unwrap_or(24);
239    let note_type_str = opts.note_type.as_deref().unwrap_or("secure").to_owned();
240
241    let payload = CreateBody {
242        token_id: &token_id,
243        encrypted_content: &enc_hex,
244        iv: &iv_hex,
245        max_views,
246        title: opts.title.as_deref(),
247        expires_in,
248        note_type: &note_type_str,
249    };
250
251    let client = reqwest::Client::new();
252    let resp = client
253        .post(format!("{base}/api/notes"))
254        .bearer_auth(&opts.api_key)
255        .json(&payload)
256        .send()
257        .await?;
258
259    let status = resp.status().as_u16();
260    let body = resp.text().await?;
261
262    if status != 200 && status != 201 {
263        return Err(Error::Http { status, body });
264    }
265
266    #[derive(Deserialize, Default)]
267    #[serde(default)]
268    struct CreateResponse {
269        #[serde(rename = "siteUrl")]
270        site_url: Option<String>,
271        #[serde(rename = "expiresAt", alias = "expires_at")]
272        expires_at: Option<String>,
273    }
274
275    let raw: CreateResponse = serde_json::from_str(&body).unwrap_or_default();
276    let site = raw.site_url.as_deref().unwrap_or(base);
277
278    Ok(CreateResult {
279        url: format!("{site}/note/{full_token}"),
280        expires_at: raw.expires_at.unwrap_or_default(),
281    })
282}
283
284/// Create a new Void Stream. Requires an API key. Costs 1 credit.
285///
286/// Returns a [`StreamHandle`] with `.write()`, `.close()`, and `.watch()`.
287pub async fn create_stream(opts: StreamOptions) -> Result<StreamHandle> {
288    if opts.api_key.is_empty() {
289        return Err(Error::MissingApiKey);
290    }
291    let base = opts.base.unwrap_or_else(|| DEFAULT_BASE.into());
292    let ttl = opts.ttl.unwrap_or(3600);
293
294    let (full_token, token_id, secret) = generate_token();
295
296    #[derive(Serialize)]
297    #[serde(rename_all = "camelCase")]
298    struct StreamBody<'a> {
299        token_id: &'a str,
300        title: Option<&'a str>,
301        ttl: u32,
302    }
303
304    let payload = StreamBody {
305        token_id: &token_id,
306        title: opts.title.as_deref(),
307        ttl,
308    };
309
310    let client = reqwest::Client::new();
311    let resp = client
312        .post(format!("{base}/api/stream"))
313        .bearer_auth(&opts.api_key)
314        .json(&payload)
315        .send()
316        .await?;
317
318    let status = resp.status().as_u16();
319    let body = resp.text().await?;
320
321    if status != 200 && status != 201 {
322        return Err(Error::Http { status, body });
323    }
324
325    #[derive(Deserialize, Default)]
326    #[serde(default)]
327    struct StreamResponse {
328        #[serde(rename = "siteUrl")]
329        site_url: Option<String>,
330        #[serde(rename = "expiresAt")]
331        expires_at: Option<String>,
332    }
333
334    let raw: StreamResponse = serde_json::from_str(&body).unwrap_or_default();
335    let site = raw.site_url.as_deref().unwrap_or(&base);
336
337    // Derive key once; stored in handle for reuse across write/watch
338    let key = derive_key_bytes(&secret)?;
339
340    Ok(StreamHandle {
341        url: format!("{site}/stream/{full_token}"),
342        expires_at: raw.expires_at.unwrap_or_default(),
343        full_token,
344        secret,
345        key,
346        base,
347        client: reqwest::Client::new(),
348    })
349}
350
351// ── StreamHandle ──────────────────────────────────────────────────────────────
352
353pub mod stream {
354    use super::*;
355
356    /// A live Void Stream handle. Write encrypted messages and close to self-destruct.
357    pub struct StreamHandle {
358        /// Shareable URL — share this with viewers (contains the decryption key)
359        pub url: String,
360        pub expires_at: String,
361        pub(crate) full_token: String,
362        pub(crate) secret: String,
363        pub(crate) key: [u8; 32], // pre-derived AES key
364        pub(crate) base: String,
365        pub(crate) client: reqwest::Client,
366    }
367
368    impl StreamHandle {
369        /// Encrypt `content` client-side and push it to the stream.
370        pub async fn write(&self, content: &str) -> Result<()> {
371            let (enc_hex, iv_hex) = encrypt_content(content, &self.secret)?;
372
373            #[derive(Serialize)]
374            #[serde(rename_all = "camelCase")]
375            struct WriteBody<'a> {
376                encrypted_content: &'a str,
377                iv: &'a str,
378            }
379
380            let resp = self
381                .client
382                .post(format!(
383                    "{}/api/stream/{}/write",
384                    self.base, self.full_token
385                ))
386                .json(&WriteBody {
387                    encrypted_content: &enc_hex,
388                    iv: &iv_hex,
389                })
390                .send()
391                .await?;
392
393            if !resp.status().is_success() {
394                let status = resp.status().as_u16();
395                let body = resp.text().await.unwrap_or_default();
396                return Err(Error::Http { status, body });
397            }
398            Ok(())
399        }
400
401        /// Close the stream. Viewers receive a "closed" event and all content self-destructs.
402        pub async fn close(&self) -> Result<()> {
403            let resp = self
404                .client
405                .post(format!(
406                    "{}/api/stream/{}/close",
407                    self.base, self.full_token
408                ))
409                .json(&serde_json::json!({}))
410                .send()
411                .await?;
412
413            if !resp.status().is_success() {
414                let status = resp.status().as_u16();
415                let body = resp.text().await.unwrap_or_default();
416                return Err(Error::Http { status, body });
417            }
418            Ok(())
419        }
420
421        /// Watch the stream as an async [`Stream`] of decrypted messages.
422        ///
423        /// Automatically reconnects using SSE `Last-Event-ID` until the stream
424        /// closes or expires.
425        ///
426        /// ```rust,ignore
427        /// use futures::StreamExt;
428        /// // stream is a StreamHandle from create_stream()
429        /// let mut msgs = stream.watch();
430        /// while let Some(Ok(msg)) = msgs.next().await {
431        ///     println!("{msg}");
432        /// }
433        /// ```
434        pub fn watch(&self) -> impl Stream<Item = Result<String>> + Send + 'static {
435            let base = self.base.clone();
436            let full_token = self.full_token.clone();
437            let key = self.key;
438            let client = self.client.clone();
439
440            async_stream::stream! {
441                let mut last_id: Option<String> = None;
442
443                'outer: loop {
444                    let mut req = client
445                        .get(format!("{base}/api/stream/{full_token}/events"));
446
447                    if let Some(ref id) = last_id {
448                        req = req.header("Last-Event-ID", id);
449                    }
450
451                    let resp = match req.send().await {
452                        Ok(r) => r,
453                        Err(e) => {
454                            yield Err(Error::Network(e));
455                            return;
456                        }
457                    };
458
459                    if !resp.status().is_success() {
460                        yield Err(Error::Http {
461                            status: resp.status().as_u16(),
462                            body: resp.text().await.unwrap_or_default(),
463                        });
464                        return;
465                    }
466
467                    use futures::StreamExt as _;
468                    let mut byte_stream = resp.bytes_stream();
469
470                    let mut buf = String::new();
471                    let mut event_id = String::new();
472                    let mut event_data = String::new();
473
474                    while let Some(chunk) = byte_stream.next().await {
475                        let chunk = match chunk {
476                            Ok(c) => c,
477                            Err(_) => break, // reconnect
478                        };
479
480                        let text = match std::str::from_utf8(&chunk) {
481                            Ok(s) => s,
482                            Err(_) => continue,
483                        };
484
485                        buf.push_str(text);
486
487                        // Process complete lines
488                        while let Some(nl_pos) = buf.find('\n') {
489                            let raw_line = &buf[..nl_pos];
490                            let line = raw_line.trim_end_matches('\r');
491                            let owned_line = line.to_owned();
492                            buf.drain(..nl_pos + 1);
493
494                            if let Some(id) = owned_line.strip_prefix("id: ") {
495                                event_id = id.to_owned();
496                            } else if let Some(data) = owned_line.strip_prefix("data: ") {
497                                event_data = data.to_owned();
498                            } else if owned_line.is_empty() && !event_data.is_empty() {
499                                // End of SSE event — process it
500                                if !event_id.is_empty() {
501                                    last_id = Some(event_id.clone());
502                                    event_id.clear();
503                                }
504
505                                let data = event_data.clone();
506                                event_data.clear();
507
508                                #[derive(Deserialize)]
509                                struct SseEvent {
510                                    #[serde(rename = "type")]
511                                    event_type: Option<String>,
512                                    enc: Option<String>,
513                                    iv: Option<String>,
514                                }
515
516                                let evt: SseEvent = match serde_json::from_str(&data) {
517                                    Ok(e) => e,
518                                    Err(_) => continue,
519                                };
520
521                                if let Some(ref t) = evt.event_type {
522                                    if t == "closed" || t == "expired" {
523                                        return; // stream ended
524                                    }
525                                }
526
527                                if let (Some(enc_hex), Some(iv_hex)) = (evt.enc, evt.iv) {
528                                    match decrypt_with_key(&enc_hex, &iv_hex, &key) {
529                                        Ok(plaintext) => yield Ok(plaintext),
530                                        Err(_) => continue, // tampered / wrong key — skip
531                                    }
532                                }
533                            }
534                        }
535                    }
536                    // Connection dropped — reconnect (continue outer loop)
537                    continue 'outer;
538                }
539            }
540        }
541    }
542
543    impl fmt::Debug for StreamHandle {
544        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
545            f.debug_struct("StreamHandle")
546                .field("url", &self.url)
547                .field("expires_at", &self.expires_at)
548                .finish()
549        }
550    }
551}
552
553// ── Buy / Credits API ─────────────────────────────────────────────────────────
554
555/// Options for creating a cryptocurrency payment order.
556#[derive(Debug, Clone)]
557pub struct CryptoOrderOptions {
558    /// Required: vn_... API key from your dashboard
559    pub api_key: String,
560    /// Number of credits to purchase
561    pub credits: u32,
562    /// Cryptocurrency ticker symbol (e.g. "BTC", "ETH", "LTC")
563    pub currency: String,
564    /// Override the API base URL (default: https://voidnote.net)
565    pub base: Option<String>,
566}
567
568/// A cryptocurrency payment order returned by the API.
569#[derive(Debug, Clone, Deserialize)]
570#[serde(rename_all = "camelCase")]
571pub struct CryptoOrder {
572    /// Unique order identifier
573    pub order_id: String,
574    /// Wallet address to send payment to
575    pub address: String,
576    /// Amount to send in the requested cryptocurrency
577    pub amount: String,
578    /// Cryptocurrency ticker (e.g. "BTC")
579    pub currency: String,
580    /// Number of credits that will be added on payment
581    pub credits: u32,
582    /// ISO 8601 timestamp when the order expires
583    pub expires_at: String,
584}
585
586/// Options for submitting a cryptocurrency transaction for an existing order.
587#[derive(Debug, Clone)]
588pub struct SubmitPaymentOptions {
589    /// Required: vn_... API key from your dashboard
590    pub api_key: String,
591    /// Order ID returned by [`create_crypto_order`]
592    pub order_id: String,
593    /// On-chain transaction hash / TXID
594    pub tx_hash: String,
595    /// Override the API base URL (default: https://voidnote.net)
596    pub base: Option<String>,
597}
598
599/// Result of submitting a cryptocurrency payment transaction.
600#[derive(Debug, Clone, Deserialize)]
601#[serde(rename_all = "camelCase")]
602pub struct SubmitPaymentResult {
603    /// Whether the submission was accepted
604    pub success: bool,
605    /// Human-readable status message
606    pub message: String,
607    /// Current status of the order (e.g. "pending", "confirming", "complete")
608    pub status: String,
609}
610
611/// Create a cryptocurrency payment order to purchase credits.
612///
613/// Returns a [`CryptoOrder`] containing the wallet address and exact amount to send.
614pub async fn create_crypto_order(opts: CryptoOrderOptions) -> Result<CryptoOrder> {
615    if opts.api_key.is_empty() {
616        return Err(Error::MissingApiKey);
617    }
618    let base = opts.base.as_deref().unwrap_or(DEFAULT_BASE);
619
620    #[derive(Serialize)]
621    #[serde(rename_all = "camelCase")]
622    struct OrderBody {
623        credits: u32,
624        currency: String,
625    }
626
627    let payload = OrderBody {
628        credits: opts.credits,
629        currency: opts.currency.clone(),
630    };
631
632    let client = reqwest::Client::new();
633    let resp = client
634        .post(format!("{base}/api/buy/crypto/create-order"))
635        .bearer_auth(&opts.api_key)
636        .json(&payload)
637        .send()
638        .await?;
639
640    let status = resp.status().as_u16();
641    let body = resp.text().await?;
642
643    if status != 200 && status != 201 {
644        return Err(Error::Http { status, body });
645    }
646
647    let order: CryptoOrder = serde_json::from_str(&body)?;
648    Ok(order)
649}
650
651/// Submit an on-chain transaction hash for an existing crypto payment order.
652///
653/// Call this after broadcasting your transaction. The API will monitor the
654/// blockchain and credit your account once the required confirmations are reached.
655pub async fn submit_crypto_payment(opts: SubmitPaymentOptions) -> Result<SubmitPaymentResult> {
656    if opts.api_key.is_empty() {
657        return Err(Error::MissingApiKey);
658    }
659    let base = opts.base.as_deref().unwrap_or(DEFAULT_BASE);
660
661    #[derive(Serialize)]
662    #[serde(rename_all = "camelCase")]
663    struct SubmitBody {
664        order_id: String,
665        tx_hash: String,
666    }
667
668    let payload = SubmitBody {
669        order_id: opts.order_id.clone(),
670        tx_hash: opts.tx_hash.clone(),
671    };
672
673    let client = reqwest::Client::new();
674    let resp = client
675        .post(format!("{base}/api/buy/crypto/submit-tx"))
676        .bearer_auth(&opts.api_key)
677        .json(&payload)
678        .send()
679        .await?;
680
681    let status = resp.status().as_u16();
682    let body = resp.text().await?;
683
684    if status != 200 && status != 201 {
685        return Err(Error::Http { status, body });
686    }
687
688    let result: SubmitPaymentResult = serde_json::from_str(&body)?;
689    Ok(result)
690}
691
692// ── Blocking wrappers ─────────────────────────────────────────────────────────
693
694/// Synchronous wrapper around the async API.
695/// Requires a Tokio runtime to be available (or use [`tokio::runtime::Runtime`]).
696pub mod blocking {
697    use super::*;
698
699    fn rt() -> tokio::runtime::Runtime {
700        tokio::runtime::Runtime::new().expect("failed to create tokio runtime")
701    }
702
703    /// Blocking version of [`read`].
704    pub fn read(url_or_token: &str) -> Result<ReadResult> {
705        rt().block_on(super::read(url_or_token))
706    }
707
708    /// Blocking version of [`create`].
709    pub fn create(content: &str, opts: CreateOptions) -> Result<CreateResult> {
710        rt().block_on(super::create(content, opts))
711    }
712
713    /// Blocking version of [`create_stream`].
714    pub fn create_stream(opts: StreamOptions) -> Result<StreamHandle> {
715        rt().block_on(super::create_stream(opts))
716    }
717
718    /// Blocking version of [`create_crypto_order`].
719    pub fn create_crypto_order(opts: CryptoOrderOptions) -> Result<CryptoOrder> {
720        rt().block_on(super::create_crypto_order(opts))
721    }
722
723    /// Blocking version of [`submit_crypto_payment`].
724    pub fn submit_crypto_payment(opts: SubmitPaymentOptions) -> Result<SubmitPaymentResult> {
725        rt().block_on(super::submit_crypto_payment(opts))
726    }
727}
728
729// ── Internal: crypto ──────────────────────────────────────────────────────────
730
731/// Generate a 32-byte random token, returning (full_token_hex, token_id, secret).
732fn generate_token() -> (String, String, String) {
733    use aes_gcm::aead::rand_core::RngCore;
734    let mut raw = [0u8; 32];
735    OsRng.fill_bytes(&mut raw);
736    let full = hex_encode(raw);
737    let token_id = full[..32].to_owned();
738    let secret = full[32..].to_owned();
739    (full, token_id, secret)
740}
741
742/// Derive a 32-byte AES key: key = SHA-256(hex_decode(secret))
743fn derive_key_bytes(secret_hex: &str) -> Result<[u8; 32]> {
744    let secret_bytes = hex_decode(secret_hex)?;
745    let hash = Sha256::digest(&secret_bytes);
746    let mut key = [0u8; 32];
747    key.copy_from_slice(&hash);
748    Ok(key)
749}
750
751/// AES-256-GCM encrypt. Returns (ciphertext_hex, iv_hex).
752/// Output ciphertext is ciphertext || 16-byte tag (Go SDK compatible).
753fn encrypt_content(plaintext: &str, secret_hex: &str) -> Result<(String, String)> {
754    use aes_gcm::aead::rand_core::RngCore;
755
756    let key_bytes = derive_key_bytes(secret_hex)?;
757    let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
758    let cipher = Aes256Gcm::new(key);
759
760    let mut iv_bytes = [0u8; 12];
761    OsRng.fill_bytes(&mut iv_bytes);
762    let nonce = Nonce::from_slice(&iv_bytes);
763
764    // aes-gcm Seal returns ciphertext || tag automatically
765    let ciphertext = cipher
766        .encrypt(nonce, plaintext.as_bytes())
767        .map_err(|_| Error::DecryptionFailed)?;
768
769    Ok((hex_encode(&ciphertext), hex_encode(iv_bytes)))
770}
771
772/// AES-256-GCM decrypt. Expects ciphertext || tag (16 bytes).
773fn decrypt_content(enc_hex: &str, iv_hex: &str, secret_hex: &str) -> Result<String> {
774    let key_bytes = derive_key_bytes(secret_hex)?;
775    decrypt_with_key(enc_hex, iv_hex, &key_bytes)
776}
777
778/// Decrypt using a pre-derived 32-byte key (avoids SHA-256 per call in watch loop).
779fn decrypt_with_key(enc_hex: &str, iv_hex: &str, key: &[u8; 32]) -> Result<String> {
780    let ct_with_tag = hex_decode(enc_hex)?;
781    let iv_bytes = hex_decode(iv_hex)?;
782
783    if iv_bytes.len() != 12 {
784        return Err(Error::DecryptionFailed);
785    }
786
787    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
788    let nonce = Nonce::from_slice(&iv_bytes);
789
790    let plaintext = cipher
791        .decrypt(nonce, ct_with_tag.as_ref())
792        .map_err(|_| Error::DecryptionFailed)?;
793
794    String::from_utf8(plaintext).map_err(|_| Error::DecryptionFailed)
795}
796
797/// Extract the 64-char hex token from a URL or return the raw string.
798fn extract_token(url_or_token: &str) -> Result<String> {
799    let s = if url_or_token.starts_with("http") {
800        url_or_token
801            .trim_end_matches('/')
802            .split('/')
803            .last()
804            .unwrap_or("")
805    } else {
806        url_or_token
807    };
808
809    if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
810        return Err(Error::InvalidToken);
811    }
812    Ok(s.to_lowercase())
813}
814
815// ── Tests ─────────────────────────────────────────────────────────────────────
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn test_encrypt_decrypt_roundtrip() {
823        let (_, _, secret) = generate_token();
824        let plaintext = "Hello, VoidNote!";
825        let (enc, iv) = encrypt_content(plaintext, &secret).unwrap();
826        let decrypted = decrypt_content(&enc, &iv, &secret).unwrap();
827        assert_eq!(plaintext, decrypted);
828    }
829
830    #[test]
831    fn test_token_generation_format() {
832        let (full, id, secret) = generate_token();
833        assert_eq!(full.len(), 64);
834        assert_eq!(id.len(), 32);
835        assert_eq!(secret.len(), 32);
836        assert_eq!(&full[..32], id);
837        assert_eq!(&full[32..], secret);
838        assert!(full.chars().all(|c| c.is_ascii_hexdigit()));
839    }
840
841    #[test]
842    fn test_extract_token_from_url() {
843        let token = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899";
844        let url = format!("https://voidnote.net/note/{token}");
845        let extracted = extract_token(&url).unwrap();
846        assert_eq!(token, extracted);
847    }
848
849    #[test]
850    fn test_extract_token_raw() {
851        let token = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899";
852        let extracted = extract_token(token).unwrap();
853        assert_eq!(token, extracted);
854    }
855
856    #[test]
857    fn test_extract_token_invalid() {
858        assert!(extract_token("tooshort").is_err());
859        assert!(extract_token("zz000000000000000000000000000000000000000000000000000000000000000").is_err());
860    }
861
862    #[test]
863    fn test_key_derivation_deterministic() {
864        let secret = "aabbccddeeff00112233445566778899";
865        let k1 = derive_key_bytes(secret).unwrap();
866        let k2 = derive_key_bytes(secret).unwrap();
867        assert_eq!(k1, k2);
868    }
869
870    #[test]
871    fn test_decryption_wrong_key_fails() {
872        let (_, _, secret) = generate_token();
873        let (_, _, wrong_secret) = generate_token();
874        let (enc, iv) = encrypt_content("secret", &secret).unwrap();
875        assert!(decrypt_content(&enc, &iv, &wrong_secret).is_err());
876    }
877
878    #[test]
879    fn test_tampered_ciphertext_fails() {
880        let (_, _, secret) = generate_token();
881        let (mut enc, iv) = encrypt_content("secret", &secret).unwrap();
882        // Flip a byte in the ciphertext hex
883        let last = enc.len() - 1;
884        enc.replace_range(last.., if enc.ends_with('f') { "0" } else { "f" });
885        assert!(decrypt_content(&enc, &iv, &secret).is_err());
886    }
887}