Skip to main content

octra_sqlite/client/
session.rs

1use super::config::{Config, load_config};
2use super::error::{Error, ErrorKind, Result};
3use super::wallet::{
4    discover_wallet_path, load_wallet, normalized_public_key_b64, signing_key_from_text,
5};
6use crate::protocol::target::{DatabaseTarget, ReadMode, parse_database_target};
7use base64::{Engine as _, engine::general_purpose};
8use ed25519_dalek::{Signer, SigningKey};
9use std::collections::BTreeSet;
10use std::env;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use zeroize::Zeroize;
14
15const PUBLIC_VIEW_CALLER: &str = "oct11111111111111111111111111111111111111111111";
16
17/// Explicit options for opening a client or database.
18///
19/// Prefer `wallet` for normal use. Inline `private_key` and `public_key`
20/// fields exist for controlled services and tests that already manage secret
21/// material outside the local wallet file.
22///
23/// `ClientOptions` intentionally does not implement `Debug` because it may
24/// contain private key material.
25///
26/// ```compile_fail
27/// let _ = format!("{:?}", octra_sqlite::ClientOptions::default());
28/// ```
29#[derive(Clone, Default)]
30pub struct ClientOptions {
31    /// Saved database name, raw Circle ID, or `oct://` database URI.
32    pub target: Option<String>,
33    /// Local wallet JSON path.
34    pub wallet: Option<PathBuf>,
35    /// Octra RPC URL override.
36    pub rpc: Option<String>,
37    /// Caller address override for read/session construction.
38    pub caller: Option<String>,
39    /// Inline private key material. Prefer `wallet` outside controlled tests.
40    pub private_key: Option<String>,
41    /// Optional public key material used to verify the private key.
42    pub public_key: Option<String>,
43}
44
45/// Resolved Octra session used by the raw client layer.
46#[derive(Clone)]
47pub struct Session {
48    target: DatabaseTarget,
49    wallet_path: Option<PathBuf>,
50    wallet_load_error: Option<String>,
51    rpc: String,
52    rpc_override: bool,
53    caller: String,
54    signer: Option<Arc<LocalSigner>>,
55}
56
57struct LocalSigner {
58    key: SigningKey,
59    public_key_b64: String,
60}
61
62impl LocalSigner {
63    fn from_private_key_text(private_key: &str, public_key: Option<String>) -> Result<Self> {
64        let key = signing_key_from_text(private_key)?;
65        let derived_public_key = key.verifying_key().to_bytes();
66        let public_key_b64 = match public_key {
67            Some(text) => normalized_public_key_b64(&text, &derived_public_key)?,
68            None => general_purpose::STANDARD.encode(derived_public_key),
69        };
70        Ok(Self {
71            key,
72            public_key_b64,
73        })
74    }
75
76    fn public_key_b64(&self) -> &str {
77        &self.public_key_b64
78    }
79
80    fn intent_public_key(&self) -> [u8; 32] {
81        self.key.verifying_key().to_bytes()
82    }
83
84    fn sign_text_b64(&self, message: &str) -> String {
85        general_purpose::STANDARD.encode(self.key.sign(message.as_bytes()).to_bytes())
86    }
87
88    fn sign_bytes_hex(&self, message: &[u8]) -> String {
89        hex::encode(self.key.sign(message).to_bytes())
90    }
91}
92
93impl Session {
94    /// Return the resolved database target.
95    pub fn target(&self) -> &DatabaseTarget {
96        &self.target
97    }
98
99    /// Return the resolved wallet path when one is configured.
100    pub fn wallet_path(&self) -> Option<&Path> {
101        self.wallet_path.as_deref()
102    }
103
104    /// Return a deferred wallet-load error retained for signed operations.
105    pub fn wallet_load_error(&self) -> Option<&str> {
106        self.wallet_load_error.as_deref()
107    }
108
109    /// Return the resolved Octra RPC URL.
110    pub fn rpc(&self) -> &str {
111        &self.rpc
112    }
113
114    /// Return the caller address used for view authentication.
115    pub fn caller(&self) -> &str {
116        &self.caller
117    }
118
119    /// Return the signing public key as standard base64.
120    pub fn public_key_b64(&self) -> Result<&str> {
121        Ok(self.signer()?.public_key_b64())
122    }
123
124    /// Clone this session around a different already-resolved database target.
125    pub fn with_database_target(&self, target: DatabaseTarget) -> Session {
126        Session {
127            target,
128            wallet_path: self.wallet_path.clone(),
129            wallet_load_error: self.wallet_load_error.clone(),
130            rpc: self.rpc.clone(),
131            rpc_override: self.rpc_override,
132            caller: self.caller.clone(),
133            signer: self.signer.clone(),
134        }
135    }
136
137    /// Resolve and open another database using this session's local context.
138    pub fn open_database(&self, target: impl Into<String>) -> Result<Session> {
139        let config = load_config()?;
140        let mut target = resolve_database_target(&target.into(), &config)?;
141        if target.rpc.is_empty() {
142            target.rpc = self.rpc.clone();
143        }
144        Ok(Session {
145            rpc: open_database_rpc(&self.rpc, self.rpc_override, Some(target.rpc.clone())),
146            target,
147            wallet_path: self.wallet_path.clone(),
148            wallet_load_error: self.wallet_load_error.clone(),
149            rpc_override: self.rpc_override,
150            caller: self.caller.clone(),
151            signer: self.signer.clone(),
152        })
153    }
154
155    /// Return the 32-byte signing public key used by OSW1 intent.
156    pub fn intent_public_key(&self) -> Result<[u8; 32]> {
157        Ok(self.signer()?.intent_public_key())
158    }
159
160    pub(crate) fn sign_view_auth_b64(&self, message: &str) -> Result<String> {
161        Ok(self.signer()?.sign_text_b64(message))
162    }
163
164    pub(crate) fn sign_program_info_b64(&self, message: &str) -> Result<String> {
165        Ok(self.signer()?.sign_text_b64(message))
166    }
167
168    pub(crate) fn sign_transaction_b64(&self, message: &str) -> Result<String> {
169        Ok(self.signer()?.sign_text_b64(message))
170    }
171
172    pub(crate) fn sign_owner_write_hex(&self, message: &[u8]) -> Result<String> {
173        Ok(self.signer()?.sign_bytes_hex(message))
174    }
175
176    fn signer(&self) -> Result<&LocalSigner> {
177        if let Some(error) = &self.wallet_load_error {
178            return Err(Error::with_kind(
179                ErrorKind::Wallet,
180                format!(
181                    "wallet failed to load; public reads can continue without it, but signed operations require a valid wallet: {error}"
182                ),
183            ));
184        }
185        self.signer.as_deref().ok_or_else(|| {
186            Error::with_kind(
187                ErrorKind::Wallet,
188                "wallet private key is required for signed Octra operations",
189            )
190        })
191    }
192}
193
194/// Resolve a database session from explicit options and local config.
195pub fn build_session(options: &ClientOptions) -> Result<Session> {
196    let config = load_config()?;
197    let target_value = options
198        .target
199        .clone()
200        .or_else(|| config.default_database.clone())
201        .or_else(|| env::var("OCTRA_SQLITE_DATABASE").ok())
202        .or_else(|| env::var("OCTRA_SQLITE_TARGET").ok())
203        .or_else(|| env::var("OCTRA_CIRCLE_ID").ok())
204        .ok_or_else(|| {
205            Error::with_kind(
206                ErrorKind::Config,
207                "no database supplied and no default database is configured",
208            )
209        })?;
210    let target = resolve_database_target(&target_value, &config)?;
211    build_session_for_target(options, &config, target)
212}
213
214/// Resolve a network control-plane session that is not bound to a database.
215pub fn build_control_session(options: &ClientOptions, network: &str) -> Result<Session> {
216    let config = load_config()?;
217    let target = DatabaseTarget {
218        raw: format!("oct://{network}"),
219        network: network.to_string(),
220        circle: String::new(),
221        rpc: config.rpc_for_network(network).unwrap_or_default(),
222        read_mode: ReadMode::Sealed,
223    };
224    build_session_for_target(options, &config, target)
225}
226
227/// Resolve the wallet path according to explicit, environment, and config precedence.
228pub fn resolve_wallet_path(options: &ClientOptions, config: &Config) -> Option<PathBuf> {
229    options
230        .wallet
231        .clone()
232        .or_else(|| env::var("OCTRA_WALLET").ok().map(PathBuf::from))
233        .or_else(|| config.wallet.as_ref().map(PathBuf::from))
234        .or_else(discover_wallet_path)
235}
236
237/// Resolve a saved name, Circle ID, or `oct://` URI into a database target.
238pub fn resolve_database_target(value: &str, config: &Config) -> Result<DatabaseTarget> {
239    let mut seen = BTreeSet::new();
240    let mut chain = Vec::new();
241    resolve_database_target_inner(value, config, &mut seen, &mut chain)
242}
243
244fn resolve_database_target_inner(
245    value: &str,
246    config: &Config,
247    seen: &mut BTreeSet<String>,
248    chain: &mut Vec<String>,
249) -> Result<DatabaseTarget> {
250    if let Some(database) = config.databases.get(value) {
251        if !seen.insert(value.to_string()) {
252            chain.push(value.to_string());
253            return Err(Error::with_kind(
254                ErrorKind::Config,
255                format!("cyclic database alias: {}", chain.join(" -> ")),
256            ));
257        }
258        chain.push(value.to_string());
259        let mut target = resolve_database_target_inner(database, config, seen, chain)?;
260        chain.pop();
261        seen.remove(value);
262        apply_target_metadata(value, config, &mut target);
263        return Ok(target);
264    }
265    let mut target = parse_database_target(value, config.network.as_deref(), None)
266        .map_err(|error| Error::with_code(ErrorKind::Config, "target_error", error.to_string()))?;
267    if target.rpc.is_empty() {
268        target.rpc = config.rpc_for_network(&target.network).unwrap_or_default();
269    }
270    apply_target_metadata(value, config, &mut target);
271    Ok(target)
272}
273
274fn apply_target_metadata(requested: &str, config: &Config, target: &mut DatabaseTarget) {
275    if target.read_mode == ReadMode::Auto
276        && let Some(metadata) = config.metadata_for_target(requested, target)
277    {
278        target.read_mode = metadata.read_mode;
279    }
280}
281
282fn build_session_for_target(
283    options: &ClientOptions,
284    config: &Config,
285    mut target: DatabaseTarget,
286) -> Result<Session> {
287    let explicit_rpc = first_string([options.rpc.clone(), env::var("OCTRA_RPC_URL").ok()]);
288    if let Some(rpc) = explicit_rpc.clone() {
289        target.rpc = rpc;
290    }
291    let rpc_override = explicit_rpc.is_some();
292    let wallet_path = resolve_wallet_path(options, config);
293    let mut wallet_load_error = None;
294    let wallet = match load_wallet(wallet_path.as_deref()) {
295        Ok(wallet) => wallet,
296        Err(error) if target.read_mode.allows_unsigned_read() => {
297            wallet_load_error = Some(error.to_string());
298            Default::default()
299        }
300        Err(error) => return Err(error),
301    };
302    let wallet_rpc = wallet.rpc;
303    let rpc = choose_session_rpc(
304        explicit_rpc,
305        Some(target.rpc.clone()),
306        config.rpc.clone(),
307        wallet_rpc,
308    )
309    .ok_or_else(|| {
310        Error::with_kind(
311            ErrorKind::Config,
312            "RPC is required; run octra-sqlite setup, pass --rpc, or set OCTRA_RPC_URL",
313        )
314    })?;
315    let caller = first_string([
316        options.caller.clone(),
317        wallet.addr,
318        wallet.address,
319        env::var("OCTRA_CALLER").ok(),
320    ])
321    .unwrap_or_else(|| PUBLIC_VIEW_CALLER.to_string());
322    let private_key = first_secret_string([
323        options.private_key.clone(),
324        wallet.priv_field,
325        wallet.priv_,
326        wallet.private_key,
327        wallet.private_key_b64,
328        env::var("OCTRA_PRIVATE_KEY_B64").ok(),
329    ]);
330    let supplied_public_key = first_string([
331        options.public_key.clone(),
332        wallet.pub_field,
333        wallet.pub_,
334        wallet.public_key,
335        wallet.public_key_b64,
336        env::var("OCTRA_PUBLIC_KEY_B64").ok(),
337    ]);
338    let signer = match private_key {
339        Some(mut private_key) => {
340            let signer = LocalSigner::from_private_key_text(&private_key, supplied_public_key);
341            private_key.zeroize();
342            Some(Arc::new(signer?))
343        }
344        None if target.read_mode.allows_unsigned_read() => None,
345        None => {
346            return Err(Error::with_kind(
347                ErrorKind::Wallet,
348                "wallet private key is required; pass --wallet or OCTRA_PRIVATE_KEY_B64",
349            ));
350        }
351    };
352    Ok(Session {
353        target,
354        wallet_path,
355        wallet_load_error,
356        rpc,
357        rpc_override,
358        caller,
359        signer,
360    })
361}
362
363fn first_string(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
364    values
365        .into_iter()
366        .find_map(|value| value.filter(|v| !v.is_empty()))
367}
368
369fn first_secret_string(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
370    let mut selected = None;
371    for mut value in values.into_iter().flatten() {
372        if value.is_empty() {
373            value.zeroize();
374            continue;
375        }
376        if selected.is_none() {
377            selected = Some(value);
378        } else {
379            value.zeroize();
380        }
381    }
382    selected
383}
384
385fn open_database_rpc(current_rpc: &str, rpc_override: bool, target_rpc: Option<String>) -> String {
386    if rpc_override {
387        return current_rpc.to_string();
388    }
389    first_string([target_rpc, Some(current_rpc.to_string())]).unwrap()
390}
391
392fn choose_session_rpc(
393    explicit_rpc: Option<String>,
394    target_rpc: Option<String>,
395    config_rpc: Option<String>,
396    wallet_rpc: Option<String>,
397) -> Option<String> {
398    first_string([explicit_rpc, target_rpc, config_rpc, wallet_rpc])
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn target_network_rpc_wins_over_wallet_rpc() {
407        assert_eq!(
408            choose_session_rpc(
409                None,
410                Some("https://devnet.octrascan.io/rpc".to_string()),
411                Some("https://config.example/rpc".to_string()),
412                Some("http://wallet.example/rpc".to_string()),
413            )
414            .as_deref(),
415            Some("https://devnet.octrascan.io/rpc")
416        );
417    }
418
419    #[test]
420    fn explicit_rpc_wins_over_target_network_rpc() {
421        assert_eq!(
422            choose_session_rpc(
423                Some("https://override.example/rpc".to_string()),
424                Some("https://devnet.octrascan.io/rpc".to_string()),
425                Some("https://config.example/rpc".to_string()),
426                Some("http://wallet.example/rpc".to_string()),
427            )
428            .as_deref(),
429            Some("https://override.example/rpc")
430        );
431    }
432
433    #[test]
434    fn wallet_rpc_is_only_a_fallback() {
435        assert_eq!(
436            choose_session_rpc(
437                None,
438                Some(String::new()),
439                None,
440                Some("http://wallet.example/rpc".to_string()),
441            )
442            .as_deref(),
443            Some("http://wallet.example/rpc")
444        );
445    }
446
447    #[test]
448    fn open_database_rpc_uses_target_network_unless_rpc_was_explicit() {
449        assert_eq!(
450            open_database_rpc(
451                "https://devnet.octrascan.io/rpc",
452                false,
453                Some("https://octra.network/rpc".to_string()),
454            ),
455            "https://octra.network/rpc"
456        );
457        assert_eq!(
458            open_database_rpc(
459                "http://127.0.0.1:8080/rpc",
460                true,
461                Some("https://octra.network/rpc".to_string()),
462            ),
463            "http://127.0.0.1:8080/rpc"
464        );
465    }
466
467    #[test]
468    fn resolve_database_target_follows_aliases() {
469        let mut config = Config {
470            network: Some("devnet".to_string()),
471            ..Config::default()
472        };
473        config.databases.insert("a".to_string(), "b".to_string());
474        config
475            .databases
476            .insert("b".to_string(), "oct://devnet/octABC".to_string());
477        let target = resolve_database_target("a", &config).unwrap();
478        assert_eq!(target.circle, "octABC");
479    }
480
481    #[test]
482    fn explicit_uri_read_mode_wins_over_saved_metadata() {
483        let mut config = Config::default();
484        config.database_metadata.insert(
485            "database".to_string(),
486            super::super::config::DatabaseMetadata {
487                uri: "oct://devnet/octABC".to_string(),
488                network: "devnet".to_string(),
489                circle: "octABC".to_string(),
490                read_mode: ReadMode::Sealed,
491                ..super::super::config::DatabaseMetadata::default()
492            },
493        );
494        let target =
495            resolve_database_target("oct://devnet/octABC?read_mode=public", &config).unwrap();
496        assert_eq!(target.read_mode, ReadMode::Public);
497    }
498
499    #[test]
500    fn resolve_database_target_rejects_self_alias_cycle() {
501        let mut config = Config::default();
502        config.databases.insert("a".to_string(), "a".to_string());
503        let error = resolve_database_target("a", &config).unwrap_err();
504        assert_eq!(error.kind(), ErrorKind::Config);
505        assert!(error.to_string().contains("a -> a"));
506    }
507
508    #[test]
509    fn resolve_database_target_rejects_multi_alias_cycle() {
510        let mut config = Config::default();
511        config.databases.insert("a".to_string(), "b".to_string());
512        config.databases.insert("b".to_string(), "a".to_string());
513        let error = resolve_database_target("a", &config).unwrap_err();
514        assert_eq!(error.kind(), ErrorKind::Config);
515        assert!(error.to_string().contains("a -> b -> a"));
516    }
517
518    #[test]
519    fn supplied_public_key_must_match_private_key() {
520        let error = match build_session(&ClientOptions {
521            target: Some("oct://devnet/octABC".to_string()),
522            rpc: Some("mock://rpc".to_string()),
523            caller: Some("octCaller".to_string()),
524            private_key: Some(
525                "0101010101010101010101010101010101010101010101010101010101010101".to_string(),
526            ),
527            public_key: Some(general_purpose::STANDARD.encode([2u8; 32])),
528            ..ClientOptions::default()
529        }) {
530            Ok(_) => panic!("mismatched public key should fail"),
531            Err(error) => error,
532        };
533        assert_eq!(error.kind(), ErrorKind::Wallet);
534        assert!(
535            error
536                .to_string()
537                .contains("wallet public key does not match private key")
538        );
539    }
540
541    #[test]
542    fn accepts_explicit_64_byte_keypair_form() {
543        let seed = [3u8; 32];
544        let key = SigningKey::from_bytes(&seed);
545        let public_key = key.verifying_key().to_bytes();
546        let mut keypair = Vec::from(seed);
547        keypair.extend_from_slice(&public_key);
548        let session = build_session(&ClientOptions {
549            target: Some("oct://devnet/octABC".to_string()),
550            rpc: Some("mock://rpc".to_string()),
551            caller: Some("octCaller".to_string()),
552            private_key: Some(hex::encode(keypair)),
553            public_key: Some(general_purpose::STANDARD.encode(public_key)),
554            ..ClientOptions::default()
555        })
556        .unwrap();
557        assert_eq!(
558            session.public_key_b64().unwrap(),
559            general_purpose::STANDARD.encode(public_key)
560        );
561    }
562
563    #[test]
564    fn public_read_preserves_wallet_load_error_for_signed_operations() {
565        let path = std::env::temp_dir().join(format!(
566            "octra-sqlite-invalid-wallet-{}.json",
567            std::process::id()
568        ));
569        std::fs::write(&path, "{").unwrap();
570        let session = build_session(&ClientOptions {
571            target: Some("oct://devnet/octABC?read_mode=public".to_string()),
572            wallet: Some(path.clone()),
573            rpc: Some("mock://rpc".to_string()),
574            ..ClientOptions::default()
575        })
576        .unwrap();
577        let _ = std::fs::remove_file(&path);
578        assert!(
579            session
580                .wallet_load_error()
581                .is_some_and(|error| error.contains("parsing wallet"))
582        );
583        let error = session.intent_public_key().unwrap_err();
584        assert_eq!(error.kind(), ErrorKind::Wallet);
585        assert!(error.to_string().contains("wallet failed to load"));
586        assert!(error.to_string().contains("parsing wallet"));
587    }
588
589    #[test]
590    fn rejects_private_keys_with_ambiguous_length() {
591        let error = signing_key_from_text("0102").unwrap_err();
592        assert_eq!(error.kind(), ErrorKind::Wallet);
593        assert!(
594            error
595                .to_string()
596                .contains("32-byte seed or 64-byte keypair")
597        );
598    }
599}