diff options
| author | benj <benj@rse8.com> | 2023-04-24 13:24:45 -0700 |
|---|---|---|
| committer | benj <benj@rse8.com> | 2023-04-24 13:24:45 -0700 |
| commit | eb92f823c31a5e702af7005231f0d6915aad3342 (patch) | |
| tree | bb624786a47accb2dfcfe95d20c00c9624c28a9c /crates/secd | |
| parent | 176aae037400b43cb3971cd968afe59c73b3097a (diff) | |
| download | secdiam-eb92f823c31a5e702af7005231f0d6915aad3342.tar secdiam-eb92f823c31a5e702af7005231f0d6915aad3342.tar.gz secdiam-eb92f823c31a5e702af7005231f0d6915aad3342.tar.bz2 secdiam-eb92f823c31a5e702af7005231f0d6915aad3342.tar.lz secdiam-eb92f823c31a5e702af7005231f0d6915aad3342.tar.xz secdiam-eb92f823c31a5e702af7005231f0d6915aad3342.tar.zst secdiam-eb92f823c31a5e702af7005231f0d6915aad3342.zip | |
email templates, sendgrid, creds, and some experimental things
Started playing with namespace configs and integrating with zanzibar impls. Still lot's of experimenting and dead code going on.
Diffstat (limited to '')
23 files changed, 1197 insertions, 164 deletions
diff --git a/crates/secd/Cargo.toml b/crates/secd/Cargo.toml index 1eb30b1..94ae43c 100644 --- a/crates/secd/Cargo.toml +++ b/crates/secd/Cargo.toml @@ -3,10 +3,12 @@ name = "secd" version = "0.1.0" edition = "2021" + [dependencies] +aes-gcm = "0.10.1" +argon2 = "0.4.1" async-trait = "0.1.59" anyhow = "1.0" -base64 = "0.13.1" clap = { version = "4.0.29", features = ["derive"] } derive_more = "0.99" email_address = "0.2" @@ -15,17 +17,19 @@ lazy_static = "1.4" lettre = "0.10.1" log = "0.4" openssl = "0.10.42" +pest = "2.5.2" prost = "0.9" prost-types = "0.9.0" rand = "0.8" reqwest = { version = "0.11.13", features = ["json"] } +sendgrid = { version = "0.18", features = ["async"] } serde = "1" serde_json = { version = "1.0", features = ["raw_value"] } serde_with = { version = "2.1", features = ["hex"] } sha2 = "0.10.6" strum = "0.24.1" strum_macros = "0.24" -sqlx = { path = "../../../sqlx", features = [ "runtime-async-std-native-tls", "postgres", "uuid", "sqlite", "time" ] } +sqlx = { git = "https://github.com/benjbellon/sqlx.git", branch = "bc/safe-schemas", features = [ "runtime-async-std-native-tls", "postgres", "uuid", "sqlite", "time" ] } time = { version = "0.3", features = [ "serde" ] } thiserror = "1.0" tokio = { version = "1.23.0", feautres = ["rt", "macros"] } diff --git a/crates/secd/README.md b/crates/secd/README.md index 5786d0c..17c333d 100644 --- a/crates/secd/README.md +++ b/crates/secd/README.md @@ -52,3 +52,60 @@ GET /oidc/provider?state=123444 -- state validated by client POST /api/auth/oidc { data ... } motif = start_motif(Oidc, access_token, data) session = complete_motif(motif.id) + +ref = secd.write(User(user, 1), (doc, 3), "editor"); +secd.attach_computed_property(ref, "property_name"); + +secd.check(User(user, 1), (doc, 3), "editor") +secd.compute\_check(User(user, 1), (doc, 3), "editor", ["property", args...], ["property", args...]) +e.g. +secd.compute\_check("User(user, 1), (doc, 3), "editor", ["readable_row", 2134], ["property2", args...]) + +.....NO: A computed property should just be a domain things, and if any data is needed, it can be attached to the auth store for that identity!!!!!!!!!!!!!!!! + +## Namespace stuff... + +use file/path/1 +use file/path/2 + +namespace user { } +namespace role { + relation member { + user | group#member + } + + computed_property (t: timestamp, s: timestamp) { + perform a computation here... + } + + computed_property (s: string) { + s.starts_with("b") + } +} + +namespace group { + relation member { + user | this.admin + } + relation admin { + user + } +} + +namespace doc { + relation owner { user } + relation editor { user | this.owner } + relation viewer { user | this.editor | this.parent#viewer | all(user) } + relation auditor { editor - this.owner } + relation parent { this.doc } +} + +so, basically it's just: + +namespace N { + relation R { + N#R | N#R & N#R - N#R + } +} + +These are `.iam` files. Any `.iam` file can be specified as the main file, and then each use statement will be followed. diff --git a/crates/secd/assets/default_login_email.html b/crates/secd/assets/default_login_email.html new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/crates/secd/assets/default_login_email.html diff --git a/crates/secd/assets/default_signup_email.html b/crates/secd/assets/default_signup_email.html new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/crates/secd/assets/default_signup_email.html diff --git a/crates/secd/assets/default_sms_login.txt b/crates/secd/assets/default_sms_login.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/crates/secd/assets/default_sms_login.txt diff --git a/crates/secd/assets/default_sms_signup.txt b/crates/secd/assets/default_sms_signup.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/crates/secd/assets/default_sms_signup.txt diff --git a/crates/secd/src/auth/n.rs b/crates/secd/src/auth/n.rs index 1d3b2d5..1f32fd6 100644 --- a/crates/secd/src/auth/n.rs +++ b/crates/secd/src/auth/n.rs @@ -1,20 +1,23 @@ -use std::str::FromStr; - use crate::{ client::{ - email::{EmailValidationMessage, Sendable}, + email::{ + parse_email_template, EmailValidationMessage, Sendable, DEFAULT_SIGNIN_EMAIL, + DEFAULT_SIGNUP_EMAIL, + }, store::{ - AddressLens, AddressValidationLens, IdentityLens, SessionLens, Storable, StoreError, + AddressLens, AddressValidationLens, CredentialLens, IdentityLens, SessionLens, + Storable, StoreError, }, }, util, Address, AddressType, AddressValidation, AddressValidationId, AddressValidationMethod, - Credential, CredentialType, Identity, PhoneNumber, Secd, SecdError, Session, SessionToken, + Credential, CredentialType, Identity, IdentityId, Secd, SecdError, Session, SessionToken, ADDRESSS_VALIDATION_CODE_SIZE, ADDRESS_VALIDATION_ALLOWS_ATTEMPTS, ADDRESS_VALIDATION_IDENTITY_SURJECTION, EMAIL_VALIDATION_DURATION, }; use email_address::EmailAddress; use log::warn; use rand::Rng; +use std::str::FromStr; use time::{Duration, OffsetDateTime}; use uuid::Uuid; @@ -22,10 +25,15 @@ impl Secd { pub async fn validate_email( &self, email_address: &str, - identity_id: Option<Uuid>, + identity_id: Option<IdentityId>, ) -> Result<AddressValidation, SecdError> { let email_address = EmailAddress::from_str(email_address)?; - // record address (idempotent operation) + let mut email_template = self + .cfg + .email_signup_message + .clone() + .unwrap_or(DEFAULT_SIGNUP_EMAIL.into()); + let mut address = Address { id: Uuid::new_v4(), t: AddressType::Email { @@ -50,6 +58,12 @@ impl Secd { .into_iter() .next() .ok_or(SecdError::AddressValidationFailed)?; + + email_template = self + .cfg + .email_signin_message + .clone() + .unwrap_or(DEFAULT_SIGNIN_EMAIL.into()); } let secret = hex::encode(rand::thread_rng().gen::<[u8; 32]>()); @@ -76,13 +90,23 @@ impl Secd { validation.write(self.store.clone()).await?; - let msg =EmailValidationMessage { + let msg = EmailValidationMessage { + from_address: self + .cfg + .email_address_from + .clone() + .unwrap_or("SecD <noreply@secd.com>".parse().unwrap()), + replyto_address: self + .cfg + .email_address_replyto + .clone() + .unwrap_or("SecD <noreply@secd.com>".parse().unwrap()), recipient: email_address.clone(), - subject: "Confirm Your Email".into(), - body: format!("This is an email validation message. Click this link [{:?}?s={}] or use the code [{}]", validation.id, secret, code), + subject: "Login Request".into(), + body: parse_email_template(&email_template, validation.id, Some(secret), Some(code))?, }; - match msg.send().await { + match msg.send(self.email_messenger.clone()).await { Ok(_) => { /* TODO: Write down the message*/ } Err(e) => { validation.revoked_at = Some(OffsetDateTime::now_utc()); @@ -95,10 +119,11 @@ impl Secd { } pub async fn validate_sms( &self, - phone_number: &PhoneNumber, + // phone_number: &PhoneNumber, ) -> Result<AddressValidation, SecdError> { todo!() } + pub async fn complete_address_validation( &self, validation_id: &AddressValidationId, @@ -215,21 +240,83 @@ impl Secd { Ok(session) } + pub async fn create_credential( &self, t: CredentialType, - key: String, - value: Option<String>, - ) -> Result<Credential, SecdError> { - todo!() + identity_id: Option<IdentityId>, + ) -> Result<Identity, SecdError> { + let identity = match identity_id { + Some(id) => Identity::find( + self.store.clone(), + &IdentityLens { + id: Some(&id), + address_type: None, + validated_address: None, + session_token_hash: None, + }, + ) + .await? + .into_iter() + .nth(0) + .ok_or(SecdError::IdentityNotFound)?, + + None => { + let id = Identity { + id: Uuid::new_v4(), + address_validations: vec![], + credentials: vec![], + rules: vec![], + metadata: None, + created_at: OffsetDateTime::now_utc(), + deleted_at: None, + }; + id.write(self.store.clone()).await?; + id + } + }; + + let mut credential = match &Credential::find( + self.store.clone(), + &CredentialLens { + id: None, + identity_id: Some(identity.id), + t: Some(&t), + restrict_by_key: Some(false), + }, + ) + .await?[..] + { + [] => Credential { + id: Uuid::new_v4(), + identity_id: identity.id, + t, + created_at: OffsetDateTime::now_utc(), + revoked_at: None, + deleted_at: None, + }, + _ => return Err(SecdError::CredentialAlreadyExists), + }; + + credential.hash(&self.crypter)?; + credential + .write(self.store.clone()) + .await + .map_err(|err| match err { + StoreError::IdempotentCheckAlreadyExists => SecdError::CredentialAlreadyExists, + err => SecdError::StoreError(err), + })?; + + Ok(identity) } pub async fn validate_credential( &self, - t: CredentialType, - key: String, - value: Option<String>, + // t: CredentialType, + // key: String, + // value: Option<String>, ) -> Result<Session, SecdError> { + // Credential::find(store, lens) use key here as unique index todo!() } @@ -254,15 +341,23 @@ impl Secd { } } - pub async fn get_identity(&self, i: &SessionToken) -> Result<Identity, SecdError> { - let token_hash = util::hash(&hex::decode(i)?); + pub async fn get_identity( + &self, + i: Option<IdentityId>, + t: Option<SessionToken>, + ) -> Result<Identity, SecdError> { + let token_hash = match t { + Some(tok) => Some(util::hash(&hex::decode(&tok)?)), + None => None, + }; + let mut i = Identity::find( self.store.clone(), &IdentityLens { - id: None, + id: i.as_ref(), address_type: None, validated_address: None, - session_token_hash: Some(token_hash), + session_token_hash: token_hash, }, ) .await?; @@ -279,6 +374,31 @@ impl Secd { } } + pub async fn update_identity_metadata( + &self, + i: IdentityId, + md: String, + ) -> Result<Identity, SecdError> { + let mut identity = Identity::find( + self.store.clone(), + &IdentityLens { + id: Some(&i), + address_type: None, + validated_address: None, + session_token_hash: None, + }, + ) + .await? + .into_iter() + .nth(0) + .ok_or(SecdError::IdentityNotFound)?; + + identity.metadata = Some(md); + identity.write(self.store.clone()).await?; + + Ok(identity) + } + pub async fn revoke_session(&self, session: &mut Session) -> Result<(), SecdError> { session.revoked_at = Some(OffsetDateTime::now_utc()); session.write(self.store.clone()).await?; diff --git a/crates/secd/src/auth/z/graph.rs b/crates/secd/src/auth/z/graph.rs new file mode 100644 index 0000000..9ca045b --- /dev/null +++ b/crates/secd/src/auth/z/graph.rs @@ -0,0 +1,209 @@ +use std::collections::{HashSet, VecDeque}; + +type NodeIdx = usize; +type EdgeIdx = usize; + +struct RelationGraph { + nodes: Vec<Node>, + edges: Vec<Edge>, +} + +#[derive(Hash, PartialEq, Eq)] +struct Node { + id: u64, + is_namespace: bool, + edge_list_head: Option<EdgeIdx>, +} + +struct Edge { + dst: NodeIdx, + op: Option<Operator>, + next_edge: Option<EdgeIdx>, +} + +enum Operator { + And, + Or, + Difference, + Complement, + AndAll, + OrAll, + DifferenceAll, + ComplementAll, +} + +impl RelationGraph { + pub fn new() -> Self { + // As I think about how to serialize this graph, when reading from the DB + // I should chunk up the namespaces so that I can read-on-demand when I + // encounter a namespace that has not yet been loaded, and in this way + // I don't need to deal with the entire graph... + // + // but that's a totally unnecessary fun optimization. + RelationGraph { + nodes: vec![], + edges: vec![], + } + } + + pub fn add_node(&mut self, id: u64, is_namespace: bool) -> NodeIdx { + let idx = self.nodes.len(); + self.nodes.push(Node { + id, + is_namespace, + edge_list_head: None, + }); + idx + } + + pub fn add_edge( + &mut self, + src: NodeIdx, + dst: NodeIdx, + op: Option<Operator>, + follow: Option<NodeIdx>, + ) { + let edge_idx = self.edges.len(); + let node = &mut self.nodes[src]; + // TODO: dupe check + self.edges.push(Edge { + dst, + op, + next_edge: node.edge_list_head, + }); + node.edge_list_head = Some(edge_idx); + } + + // doc:2#viewer@user:1 + pub fn find_leaves(&self, src: NodeIdx, filterIdx: Option<NodeIdx>) -> Vec<NodeIdx> { + let mut all_leaves = vec![]; + // let mut and_leaves = vec![]; + // let mut exc_leaves = vec![]; + + // the output should basically be leaves to look up + // for example + // the leaves of doc might be (user), <(organization, admin)>, <(role, foo)> + // and since (organization, admin) has leaves (user) as well + // if the user is in that final set, then they are valid + // and those leaves would be queried as relation tuples saved in the DB + + println!("HERE I AM"); + + let start = self.nodes.get(src); + let mut seen = HashSet::new(); + let mut q = VecDeque::new(); + + seen.insert(start); + q.push_back(start); + + // I need to build a chain of ops + // [AND, OR, OR, AND, OR, ...] + // at each stage, I need to apply the operator chain + while let Some(&Some(node)) = q.iter().next() { + if node.is_namespace { + all_leaves.push(node); + } + + // for edge in edge list + + println!("IN QUEUE\n"); + println!("node: {:?}\n", node.id); + q.pop_front(); + } + println!("DONE"); + + // task...starting at doc_viewer, return all leaf nodes + // to do this.... + // check all children + // if child is OR + // if child is leaf, return child + // else return all of these children + // if child is AND + // if child is leaf, return if also present in all other nodes + // else return all children which are also present in other nodes + // if child is Difference + // if child is leaf, do not include and remove if it part of the user set + // else remove all of these children + + // TOTAL pool of leafs + // Intersection leaves (i.e. ONLY leaves which MUST exist) + // exclusion leaves (i.e. leaves which CANNOT exist) + + // bfs build query... + // execute query + + // start at nodeIdx: doc_viewer + // expand until I find all leaf nodes. + // we are interested in leaf nodes with nodeIdx: user + // return all leaf nodeIdx that match the filter. If no filter provided, return all leaf idx. + + // TODO: optionally build the expansion path by pushing every intermediate step as the bfs is walked. + + // with each step of the bfs, perform the operator. + // e.g. if first step is doc_viewer -> user with operator OR then we have leaf user with operator chain [OR] + // the next step might be doc_viewer -> doc_editor with step OR and doc_editor -> user with step OR, so we have leaf user with chain [OR, OR] + // the next step might be doc_viewer -> doc_auditor with OR + // then doc_auditor -> doc_editor with OR + // then doc_editor -> doc_user with OR + // then doc_editor -> doc_owner + // then doc_auditor -> doc_owner with Difference + // + + todo!() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn build_graph_test() { + let mut g = RelationGraph::new(); + let user = g.add_node(1, true); + let role = g.add_node(2, true); + let group = g.add_node(3, true); + let doc = g.add_node(4, true); + + let role_member = g.add_node(5, false); + + let group_member = g.add_node(6, false); + let group_admin = g.add_node(7, false); + + let doc_owner = g.add_node(8, false); + let doc_editor = g.add_node(9, false); + let doc_viewer = g.add_node(10, false); + let doc_auditor = g.add_node(11, false); + let doc_parent = g.add_node(12, false); + + g.add_edge(role, role_member, None, None); + g.add_edge(role_member, user, Some(Operator::Or), None); + g.add_edge(role_member, group_member, Some(Operator::Or), None); + + g.add_edge(group, group_member, None, None); + g.add_edge(group, group_admin, None, None); + + g.add_edge(group_member, user, Some(Operator::Or), None); + g.add_edge(group_member, group_admin, Some(Operator::Or), None); + g.add_edge(group_admin, user, Some(Operator::Or), None); + + g.add_edge(doc, doc_owner, None, None); + g.add_edge(doc, doc_editor, None, None); + g.add_edge(doc, doc_viewer, None, None); + g.add_edge(doc, doc_auditor, None, None); + g.add_edge(doc, doc_parent, None, None); + + g.add_edge(doc_owner, user, Some(Operator::Or), None); + g.add_edge(doc_editor, user, Some(Operator::Or), None); + g.add_edge(doc_editor, doc_owner, Some(Operator::Or), None); + g.add_edge(doc_viewer, user, Some(Operator::Or), None); + g.add_edge(doc_viewer, doc_editor, Some(Operator::Or), None); + g.add_edge(doc_viewer, doc_parent, Some(Operator::Or), Some(doc_viewer)); + g.add_edge(doc_viewer, user, Some(Operator::OrAll), None); + g.add_edge(doc_viewer, doc_auditor, Some(Operator::Or), None); + g.add_edge(doc_auditor, doc_editor, Some(Operator::Difference), None); + + g.find_leaves(doc_viewer, None); + assert_eq!(1, 2); + } +} diff --git a/crates/secd/src/auth/z.rs b/crates/secd/src/auth/z/mod.rs index 31f449c..b364583 100644 --- a/crates/secd/src/auth/z.rs +++ b/crates/secd/src/auth/z/mod.rs @@ -1,6 +1,8 @@ -use uuid::Uuid; +mod graph; -use crate::{Secd, SecdError}; +use crate::{Authorization, Secd, SecdError}; +use async_trait::async_trait; +use uuid::Uuid; pub type Namespace = String; pub type Object = (Namespace, Uuid); @@ -18,8 +20,9 @@ pub enum Subject { UserSet { user: Object, relation: Relation }, } -impl Secd { - pub async fn check(&self, r: &Relationship) -> Result<bool, SecdError> { +#[async_trait] +impl Authorization for Secd { + async fn check(&self, r: &Relationship) -> Result<bool, SecdError> { let spice = self .spice .clone() @@ -27,16 +30,16 @@ impl Secd { Ok(spice.check_permission(r).await?) } - pub async fn expand(&self) -> Result<(), SecdError> { + async fn expand(&self) -> Result<(), SecdError> { todo!() } - pub async fn read(&self) -> Result<(), SecdError> { + async fn read(&self) -> Result<(), SecdError> { todo!() } - pub async fn watch(&self) -> Result<(), SecdError> { + async fn watch(&self) -> Result<(), SecdError> { unimplemented!() } - pub async fn write(&self, ts: &[Relationship]) -> Result<(), SecdError> { + async fn write(&self, ts: &[Relationship]) -> Result<(), SecdError> { let spice = self .spice .clone() @@ -59,3 +62,27 @@ impl Secd { Ok(()) } } + +enum RelationToken { + Start, + Or, + And, + Exclude, +} +struct RelationContainer { + name: Relation, + bins: Vec<(RelationToken, Relation)>, +} + +struct NamespaceContainer { + relations: Vec<RelationContainer>, +} + +impl Secd { + async fn write_namespace(&self, ns: &NamespaceContainer) -> Result<(), SecdError> { + todo!() + } + async fn read_namespace(&self) -> Result<NamespaceContainer, SecdError> { + todo!() + } +} diff --git a/crates/secd/src/client/email/mod.rs b/crates/secd/src/client/email/mod.rs index 915d18c..7c7b233 100644 --- a/crates/secd/src/client/email/mod.rs +++ b/crates/secd/src/client/email/mod.rs @@ -1,68 +1,172 @@ +use async_trait::async_trait; use email_address::EmailAddress; -use lettre::Transport; -use log::error; -use std::collections::HashMap; +use lettre::{ + message::{Mailbox, MultiPart}, + Transport, +}; +use log::{error, warn}; +use reqwest::StatusCode; +use sendgrid::v3::{Content, Email, Personalization}; +use std::sync::Arc; + +use crate::AddressValidationId; + +pub const DEFAULT_SIGNUP_EMAIL: &str = "This email was recently used to signup. Please use the following code to complete your validation: {{secd::validation_code}}. If you did not request this signup link, you can safely ignore this email."; +pub const DEFAULT_SIGNIN_EMAIL: &str = "An account with this email was recently used to signin. Please use the following code to complete your sign in process: {{secd::validation_code}}. If you did not request this signin link, you can safely ingore this email."; #[derive(Debug, thiserror::Error, derive_more::Display)] pub enum EmailMessengerError { FailedToSendEmail, + LibLettreError(#[from] lettre::error::Error), + SendgridError(#[from] sendgrid::SendgridError), } pub struct EmailValidationMessage { + pub from_address: Mailbox, + pub replyto_address: Mailbox, pub recipient: EmailAddress, pub subject: String, pub body: String, } -#[async_trait::async_trait] -pub(crate) trait EmailMessenger { - async fn send_email( - &self, - email_address: &EmailAddress, - template: &str, - template_vars: HashMap<&str, &str>, - ) -> Result<(), EmailMessengerError>; +#[async_trait] +pub(crate) trait EmailMessenger: Send + Sync { + fn get_type(&self) -> MessengerType; + fn get_api_key(&self) -> String; } -pub(crate) struct LocalMailer {} +pub enum MessengerType { + LocalMailer, + Sendgrid, +} -#[async_trait::async_trait] +pub(crate) struct LocalMailer {} +impl LocalMailer { + pub fn new() -> Arc<dyn EmailMessenger + Send + Sync + 'static> { + warn!("You are using the local mailer, which will not work in production!"); + Arc::new(LocalMailer {}) + } +} impl EmailMessenger for LocalMailer { - async fn send_email( - &self, - email_address: &EmailAddress, - template: &str, - template_vars: HashMap<&str, &str>, - ) -> Result<(), EmailMessengerError> { - todo!() + fn get_type(&self) -> MessengerType { + MessengerType::LocalMailer + } + fn get_api_key(&self) -> String { + panic!("unreachable since no API key is expected for LocalMailer"); + } +} +pub(crate) struct Sendgrid { + pub api_key: String, +} +impl Sendgrid { + pub fn new(api_key: String) -> Arc<dyn EmailMessenger + Send + Sync + 'static> { + Arc::new(Sendgrid { api_key }) + } +} +impl EmailMessenger for Sendgrid { + fn get_type(&self) -> MessengerType { + MessengerType::Sendgrid + } + fn get_api_key(&self) -> String { + self.api_key.clone() } } -#[async_trait::async_trait] +#[async_trait] pub(crate) trait Sendable { - async fn send(&self) -> Result<(), EmailMessengerError>; + async fn send(&self, messenge: Arc<dyn EmailMessenger>) -> Result<(), EmailMessengerError>; } -#[async_trait::async_trait] +#[async_trait] impl Sendable for EmailValidationMessage { - // TODO: We need to break this up as before, especially so we can feature - // gate unwanted things like Lettre... - async fn send(&self) -> Result<(), EmailMessengerError> { - // TODO: Get these things from the template... - let email = lettre::Message::builder() - .from("BranchControl <iam@branchcontrol.com>".parse().unwrap()) - .reply_to("BranchControl <iam@branchcontrol.com>".parse().unwrap()) - .to(self.recipient.to_string().parse().unwrap()) - .subject(self.subject.clone()) - .body(self.body.clone()) - .unwrap(); - - let mailer = lettre::SmtpTransport::unencrypted_localhost(); - - mailer.send(&email).map_err(|e| { - error!("failed to send email {:?}", e); - EmailMessengerError::FailedToSendEmail - })?; + async fn send(&self, messenger: Arc<dyn EmailMessenger>) -> Result<(), EmailMessengerError> { + match messenger.get_type() { + MessengerType::LocalMailer => { + let email = lettre::Message::builder() + .from(self.from_address.to_string().parse().unwrap()) + .reply_to(self.replyto_address.to_string().parse().unwrap()) + .to(self.recipient.to_string().parse().unwrap()) + .subject(self.subject.clone()) + .multipart(MultiPart::alternative_plain_html( + "".to_string(), + String::from(self.body.clone()), + ))?; + + let mailer = lettre::SmtpTransport::unencrypted_localhost(); + + mailer.send(&email).map_err(|e| { + error!("failed to send email {:?}", e); + EmailMessengerError::FailedToSendEmail + })?; + } + MessengerType::Sendgrid => { + let msg = sendgrid::v3::Message::new(Email::new(self.from_address.to_string())) + .set_subject(&self.subject) + .add_content( + Content::new() + .set_content_type("text/html") + .set_value(&self.body), + ) + .add_personalization(Personalization::new(Email::new( + self.recipient.to_string(), + ))); + + let sender = sendgrid::v3::Sender::new(messenger.get_api_key()); + let resp = sender.send(&msg).await?; + match resp.status() { + StatusCode::ACCEPTED => {} + _ => { + error!( + "sendgrid failed to send message with status: {}", + resp.status() + ) + } + } + } + }; + Ok(()) } } + +pub(crate) fn parse_email_template( + template: &str, + validation_id: AddressValidationId, + validation_secret: Option<String>, + validation_code: Option<String>, +) -> Result<String, EmailMessengerError> { + let mut t = template.clone().to_string(); + // We do not allow substutions for a variety of reasons, but mainly security ones. + // The only things we want to share are those which secd allows. In this case, that + // means we only send an email with static content as provided by the filter, except + // for the $$secd:request_id$$ and $$secd:request_code$$, either of which may be + // present in the email. + + t = t.replace("{{secd::validation_id}}", &validation_id.to_string()); + validation_secret.map(|secret| t = t.replace("{{secd::validation_secret}}", &secret)); + validation_code.map(|code| t = t.replace("{{secd::validation_code}}", &code)); + + Ok(t) +} + +#[cfg(test)] +mod test { + use uuid::Uuid; + + use super::*; + + #[test] + fn test_parse_and_substitue() { + let raw = "This is an email validation message. Navigate to https://www.secd.com/auth/{secd::validation_id}?s={secd::validation_secret} or use the code [{secd::validation_code}]"; + + let parsed = parse_email_template( + raw, + Uuid::parse_str("90f42ba9-ed4a-4f56-b371-df05634a1626").unwrap(), + Some("s3cr3t".into()), + Some("102030".into()), + ) + .unwrap(); + + assert_eq!(parsed, "This is an email validation message. Navigate to https://www.secd.com/auth/90f42ba9-ed4a-4f56-b371-df05634a1626?s=s3cr3t or use the code [102030]") + } +} diff --git a/crates/secd/src/client/store/mod.rs b/crates/secd/src/client/store/mod.rs index 8a076c4..7bf01d5 100644 --- a/crates/secd/src/client/store/mod.rs +++ b/crates/secd/src/client/store/mod.rs @@ -1,22 +1,28 @@ pub(crate) mod sql_db; +use async_trait::async_trait; use sqlx::{Postgres, Sqlite}; use std::sync::Arc; use uuid::Uuid; -use crate::{util, Address, AddressType, AddressValidation, Identity, IdentityId, Session}; +use crate::{ + util, Address, AddressType, AddressValidation, Credential, CredentialId, CredentialType, + Identity, IdentityId, Session, +}; use self::sql_db::SqlClient; #[derive(Debug, thiserror::Error, derive_more::Display)] pub enum StoreError { SqlClientError(#[from] sqlx::Error), + SerdeError(#[from] serde_json::Error), + ParseError(#[from] strum::ParseError), StoreValueCannotBeParsedInvariant, IdempotentCheckAlreadyExists, } -#[async_trait::async_trait(?Send)] -pub trait Store { +#[async_trait] +pub trait Store: Send + Sync { fn get_type(&self) -> StoreType; } @@ -25,7 +31,7 @@ pub enum StoreType { Sqlite { c: Arc<SqlClient<Sqlite>> }, } -#[async_trait::async_trait(?Send)] +#[async_trait] pub(crate) trait Storable<'a> { type Item; type Lens; @@ -64,7 +70,15 @@ pub(crate) struct SessionLens<'a> { } impl<'a> Lens for SessionLens<'a> {} -#[async_trait::async_trait(?Send)] +pub(crate) struct CredentialLens<'a> { + pub id: Option<CredentialId>, + pub identity_id: Option<IdentityId>, + pub t: Option<&'a CredentialType>, + pub restrict_by_key: Option<bool>, +} +impl<'a> Lens for CredentialLens<'a> {} + +#[async_trait] impl<'a> Storable<'a> for Address { type Item = Address; type Lens = AddressLens<'a>; @@ -93,7 +107,7 @@ impl<'a> Storable<'a> for Address { } } -#[async_trait::async_trait(?Send)] +#[async_trait] impl<'a> Storable<'a> for AddressValidation { type Item = AddressValidation; type Lens = AddressValidationLens<'a>; @@ -116,7 +130,7 @@ impl<'a> Storable<'a> for AddressValidation { } } -#[async_trait::async_trait(?Send)] +#[async_trait] impl<'a> Storable<'a> for Identity { type Item = Identity; type Lens = IdentityLens<'a>; @@ -158,7 +172,7 @@ impl<'a> Storable<'a> for Identity { } } -#[async_trait::async_trait(?Send)] +#[async_trait] impl<'a> Storable<'a> for Session { type Item = Session; type Lens = SessionLens<'a>; @@ -183,3 +197,51 @@ impl<'a> Storable<'a> for Session { }) } } + +#[async_trait] +impl<'a> Storable<'a> for Credential { + type Item = Credential; + type Lens = CredentialLens<'a>; + + async fn write(&self, store: Arc<dyn Store>) -> Result<(), StoreError> { + match store.get_type() { + StoreType::Postgres { c } => c.write_credential(self).await?, + StoreType::Sqlite { c } => c.write_credential(self).await?, + } + Ok(()) + } + + async fn find( + store: Arc<dyn Store>, + lens: &'a Self::Lens, + ) -> Result<Vec<Self::Item>, StoreError> { + Ok(match store.get_type() { + StoreType::Postgres { c } => { + c.find_credential( + lens.id, + lens.identity_id, + lens.t, + if let Some(true) = lens.restrict_by_key { + true + } else { + false + }, + ) + .await? + } + StoreType::Sqlite { c } => { + c.find_credential( + lens.id, + lens.identity_id, + lens.t, + if let Some(true) = lens.restrict_by_key { + true + } else { + false + }, + ) + .await? + } + }) + } +} diff --git a/crates/secd/src/client/store/sql_db.rs b/crates/secd/src/client/store/sql_db.rs index ecb13be..3e72fe8 100644 --- a/crates/secd/src/client/store/sql_db.rs +++ b/crates/secd/src/client/store/sql_db.rs @@ -1,27 +1,19 @@ -use std::{str::FromStr, sync::Arc}; - -use email_address::EmailAddress; -use serde_json::value::RawValue; -use sqlx::{ - database::HasArguments, types::Json, ColumnIndex, Database, Decode, Encode, Executor, - IntoArguments, Pool, Transaction, Type, -}; -use time::OffsetDateTime; -use uuid::Uuid; - +use super::{Store, StoreError, StoreType}; use crate::{ - Address, AddressType, AddressValidation, AddressValidationMethod, Identity, Session, - SessionToken, + Address, AddressType, AddressValidation, AddressValidationMethod, Credential, CredentialId, + CredentialType, Identity, IdentityId, Session, }; - +use email_address::EmailAddress; use lazy_static::lazy_static; +use sqlx::{ + database::HasArguments, ColumnIndex, Database, Decode, Encode, Executor, IntoArguments, Pool, + Transaction, Type, +}; use sqlx::{Postgres, Sqlite}; use std::collections::HashMap; - -use super::{ - AddressLens, AddressValidationLens, IdentityLens, SessionLens, Storable, Store, StoreError, - StoreType, -}; +use std::{str::FromStr, sync::Arc}; +use time::OffsetDateTime; +use uuid::Uuid; const SQLITE: &str = "sqlite"; const PGSQL: &str = "pgsql"; @@ -30,6 +22,8 @@ const WRITE_ADDRESS: &str = "write_address"; const FIND_ADDRESS: &str = "find_address"; const WRITE_ADDRESS_VALIDATION: &str = "write_address_validation"; const FIND_ADDRESS_VALIDATION: &str = "find_address_validation"; +const WRITE_CREDENTIAL: &str = "write_credential"; +const FIND_CREDENTIAL: &str = "find_credential"; const WRITE_IDENTITY: &str = "write_identity"; const FIND_IDENTITY: &str = "find_identity"; const WRITE_SESSION: &str = "write_session"; @@ -72,6 +66,14 @@ lazy_static! { FIND_SESSION, include_str!("../../../store/sqlite/sql/find_session.sql"), ), + ( + WRITE_CREDENTIAL, + include_str!("../../../store/sqlite/sql/write_credential.sql"), + ), + ( + FIND_CREDENTIAL, + include_str!("../../../store/sqlite/sql/find_credential.sql"), + ), ] .iter() .cloned() @@ -110,6 +112,14 @@ lazy_static! { FIND_SESSION, include_str!("../../../store/pg/sql/find_session.sql"), ), + ( + WRITE_CREDENTIAL, + include_str!("../../../store/pg/sql/write_credential.sql"), + ), + ( + FIND_CREDENTIAL, + include_str!("../../../store/pg/sql/find_credential.sql"), + ), ] .iter() .cloned() @@ -131,7 +141,7 @@ pub trait SqlxResultExt<T> { impl<T> SqlxResultExt<T> for Result<T, sqlx::Error> { fn extend_err(self) -> Result<T, StoreError> { if let Err(sqlx::Error::Database(dbe)) = &self { - if dbe.code() == Some("23505".into()) { + if dbe.code() == Some("23505".into()) || dbe.code() == Some("2067".into()) { return Err(StoreError::IdempotentCheckAlreadyExists); } } @@ -160,7 +170,7 @@ impl Store for PgClient { impl PgClient { pub async fn new(pool: sqlx::Pool<Postgres>) -> Arc<dyn Store + Send + Sync + 'static> { - sqlx::migrate!("store/pg/migrations") + sqlx::migrate!("store/pg/migrations", "secd") .run(&pool) .await .expect(ERR_MSG_MIGRATION_FAILED); @@ -187,7 +197,7 @@ impl Store for SqliteClient { impl SqliteClient { pub async fn new(pool: sqlx::Pool<Sqlite>) -> Arc<dyn Store + Send + Sync + 'static> { - sqlx::migrate!("store/sqlite/migrations") + sqlx::migrate!("store/sqlite/migrations", "secd") .run(&pool) .await .expect(ERR_MSG_MIGRATION_FAILED); @@ -410,8 +420,7 @@ where let sqls = get_sqls(&self.sqls_root, WRITE_IDENTITY); sqlx::query(&sqls[0]) .bind(i.id) - // TODO: validate this is actually Json somewhere way up the chain (when being deserialized) - .bind(i.metadata.clone().unwrap_or("{}".into())) + .bind(i.metadata.clone()) .bind(i.created_at) .bind(OffsetDateTime::now_utc()) .bind(i.deleted_at) @@ -449,7 +458,7 @@ where .extend_err()?; let mut res = vec![]; - for (id, metadata, created_at, updated_at, deleted_at) in rs.into_iter() { + for (id, metadata, created_at, _, deleted_at) in rs.into_iter() { res.push(Identity { id, address_validations: vec![], @@ -509,6 +518,79 @@ where } Ok(res) } + + pub async fn write_credential(&self, c: &Credential) -> Result<(), StoreError> { + let sqls = get_sqls(&self.sqls_root, WRITE_CREDENTIAL); + let partial_key = match &c.t { + crate::CredentialType::Passphrase { key, value: _ } => Some(key.clone()), + _ => None, + }; + + sqlx::query(&sqls[0]) + .bind(c.id) + .bind(c.identity_id) + .bind(partial_key) + .bind(c.t.to_string()) + .bind(serde_json::to_string(&c.t)?) + .bind(c.created_at) + .bind(c.revoked_at) + .bind(c.deleted_at) + .execute(&self.pool) + .await + .extend_err()?; + Ok(()) + } + pub async fn find_credential( + &self, + id: Option<Uuid>, + identity_id: Option<Uuid>, + t: Option<&CredentialType>, + restrict_by_key: bool, + ) -> Result<Vec<Credential>, StoreError> { + let sqls = get_sqls(&self.sqls_root, FIND_CREDENTIAL); + let key = restrict_by_key + .then(|| { + t.map(|i| match i { + CredentialType::Passphrase { key, value: _ } => key.clone(), + _ => todo!(), + }) + }) + .flatten(); + + let rs = sqlx::query_as::< + _, + ( + CredentialId, + IdentityId, + String, + OffsetDateTime, + Option<OffsetDateTime>, + Option<OffsetDateTime>, + ), + >(&sqls[0]) + .bind(id.as_ref()) + .bind(identity_id.as_ref()) + .bind(t.map(|i| i.to_string())) + .bind(key) + .fetch_all(&self.pool) + .await + .extend_err()?; + + let mut res = vec![]; + for (id, identity_id, data, created_at, revoked_at, deleted_at) in rs.into_iter() { + let t: CredentialType = serde_json::from_str(&data)?; + res.push(Credential { + id, + identity_id, + t, + created_at, + revoked_at, + deleted_at, + }) + } + + Ok(res) + } } fn get_sqls(root: &str, file: &str) -> Vec<String> { diff --git a/crates/secd/src/lib.rs b/crates/secd/src/lib.rs index 15615b2..54759a5 100644 --- a/crates/secd/src/lib.rs +++ b/crates/secd/src/lib.rs @@ -2,8 +2,10 @@ pub mod auth; mod client; mod util; +use async_trait::async_trait; +use auth::z::Relationship; use client::{ - email::{EmailMessenger, EmailMessengerError, LocalMailer}, + email::{EmailMessenger, EmailMessengerError, LocalMailer, Sendgrid}, spice::{Spice, SpiceError}, store::{ sql_db::{PgClient, SqliteClient}, @@ -11,28 +13,40 @@ use client::{ }, }; use email_address::EmailAddress; -use log::{error, info}; +use lettre::message::Mailbox; +use log::{error, info, warn}; use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, DisplayFromStr}; -use std::{env::var, str::FromStr, sync::Arc}; +use serde_with::serde_as; +use std::{ + env::{set_var, var}, + str::FromStr, + sync::Arc, +}; use strum_macros::{Display, EnumString, EnumVariantNames}; use time::OffsetDateTime; -use url::Url; +use util::crypter::{Crypter, CrypterError}; use uuid::Uuid; pub const ENV_AUTH_STORE_CONN_STRING: &str = "SECD_AUTH_STORE_CONN_STRING"; +pub const ENV_CRYPTER_SECRET_KEY: &str = "SECD_CRYPTER_SECRET_KEY"; +pub const ENV_EMAIL_ADDRESS_FROM: &str = "SECD_EMAIL_ADDRESS_FROM"; +pub const ENV_EMAIL_ADDRESS_REPLYTO: &str = "SECD_EMAIL_ADDRESS_REPLYTO"; pub const ENV_EMAIL_MESSENGER: &str = "SECD_EMAIL_MESSENGER"; pub const ENV_EMAIL_MESSENGER_CLIENT_ID: &str = "SECD_EMAIL_MESSENGER_CLIENT_ID"; pub const ENV_EMAIL_MESSENGER_CLIENT_SECRET: &str = "SECD_EMAIL_MESSENGER_CLIENT_SECRET"; +pub const ENV_EMAIL_SENDGRID_API_KEY: &str = "SECD_EMAIL_SENDGRID_API_KEY"; +pub const ENV_EMAIL_SIGNIN_MESSAGE: &str = "SECD_EMAIL_SIGNIN_MESSAGE"; +pub const ENV_EMAIL_SIGNUP_MESSAGE: &str = "SECD_EMAIL_SIGNUP_MESSAGE"; pub const ENV_SPICE_SECRET: &str = "SECD_SPICE_SECRET"; pub const ENV_SPICE_SERVER: &str = "SECD_SPICE_SERVER"; -const SESSION_SIZE_BYTES: usize = 32; -const SESSION_DURATION: i64 = 60 /* seconds*/ * 60 /* minutes */ * 24 /* hours */ * 360 /* days */; -const EMAIL_VALIDATION_DURATION: i64 = 60 /* seconds*/ * 15 /* minutes */; const ADDRESSS_VALIDATION_CODE_SIZE: u8 = 6; const ADDRESS_VALIDATION_ALLOWS_ATTEMPTS: u8 = 5; const ADDRESS_VALIDATION_IDENTITY_SURJECTION: bool = true; +const CRYPTER_SECRET_KEY_DEFAULT: &str = "sup3rs3cr3t"; +const EMAIL_VALIDATION_DURATION: i64 = 60 /* seconds*/ * 15 /* minutes */; +const SESSION_DURATION: i64 = 60 /* seconds*/ * 60 /* minutes */ * 24 /* hours */ * 360 /* days */; +const SESSION_SIZE_BYTES: usize = 32; pub type AddressId = Uuid; pub type AddressValidationId = Uuid; @@ -45,10 +59,17 @@ pub type SessionToken = String; #[derive(Debug, derive_more::Display, thiserror::Error)] pub enum SecdError { + // AuthenticationError(AuthnError); + // AuthorizationError(AuthzError); + // // InitializationError(...) AddressValidationFailed, AddressValidationSessionExchangeFailed, AddressValidationExpiredOrConsumed, + CredentialAlreadyExists, + + CrypterError(#[from] CrypterError), + TooManyIdentities, IdentityNotFound, @@ -69,9 +90,62 @@ pub enum SecdError { } pub struct Secd { - store: Arc<dyn Store + Send + Sync + 'static>, + crypter: Crypter, email_messenger: Arc<dyn EmailMessenger + Send + Sync + 'static>, spice: Option<Arc<Spice>>, + store: Arc<dyn Store + Send + Sync + 'static>, + cfg: Cfg, +} + +struct Cfg { + email_address_from: Option<Mailbox>, + email_address_replyto: Option<Mailbox>, + email_signup_message: Option<String>, + email_signin_message: Option<String>, +} + +#[async_trait] +pub trait Authentication { + async fn validate_address( + &self, + address_type: AddressType, + identity_id: Option<IdentityId>, + ) -> Result<AddressValidation, SecdError>; + + async fn complete_address_validation( + &self, + validation_id: &AddressValidationId, + plaintext_token: Option<String>, + plaintext_code: Option<String>, + ) -> Result<AddressValidation, SecdError>; + + async fn create_credential( + &self, + t: &CredentialType, + identity_id: Option<IdentityId>, + ) -> Result<IdentityId, SecdError>; + // async fn update_credential(&self, t: &CredentialType) -> Result<(), SecdError>; + async fn reset_credential( + &self, + t: &CredentialType, + address: &AddressType, + ) -> Result<Credential, SecdError>; + async fn validate_credential(&self, t: &CredentialType) -> Result<Credential, SecdError>; + + // async fn expire_session_chain(&self, t: &SessionToken) -> Result<(), SecdError>; + // async fn expire_sessions(&self, i: &IdentityId) -> Result<(), SecdError>; + + // async fn get_identity(&self, t: &SessionToken) -> Result<Identity, SecdError>; + // async fn get_session(&self, t: &SessionToken) -> Result<Session, SecdError>; +} + +#[async_trait] +pub trait Authorization { + async fn check(&self, r: &Relationship) -> Result<bool, SecdError>; + async fn expand(&self) -> Result<(), SecdError>; + async fn read(&self) -> Result<(), SecdError>; + async fn watch(&self) -> Result<(), SecdError>; + async fn write(&self, relationships: &[Relationship]) -> Result<(), SecdError>; } #[derive(Display, Debug, Serialize, Deserialize, EnumString, EnumVariantNames)] @@ -136,34 +210,53 @@ pub enum AddressType { Sms { phone_number: Option<PhoneNumber> }, } +#[serde_as] #[derive(Debug, Serialize)] pub struct Credential { pub id: CredentialId, pub identity_id: IdentityId, pub t: CredentialType, + #[serde(with = "time::serde::timestamp")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::timestamp::option")] + pub revoked_at: Option<OffsetDateTime>, + #[serde(with = "time::serde::timestamp::option")] + pub deleted_at: Option<OffsetDateTime>, } #[serde_as] -#[derive(Debug, Serialize)] +#[derive(Debug, Display, Serialize, Deserialize, EnumString)] pub enum CredentialType { - Passphrase { - key: String, - value: String, - }, - Oicd { - value: String, - }, - OneTimeCodes { - codes: Vec<String>, - }, - Totp { - #[serde_as(as = "DisplayFromStr")] - url: Url, - code: String, - }, - WebAuthn { - value: String, - }, + Passphrase { key: String, value: String }, + Oidc { value: String }, + OneTimeCodes { codes: Vec<String> }, + // Totp { + // #[serde_as(as = "DisplayFromStr")] + // url: Url, + // code: String, + // }, + WebAuthn { value: String }, +} + +struct SecuredCredential { + pub id: CredentialId, + pub identity_id: IdentityId, + pub t: CredentialType, + pub created_at: OffsetDateTime, + pub revoked_at: Option<OffsetDateTime>, + pub deleted_at: Option<OffsetDateTime>, +} + +enum SecuredCredentialType { + Passphrase { key: String, value: String }, + Oidc { value: String }, + OneTimeCodes { codes: Vec<String> }, + // Totp { + // #[serde_as(as = "DisplayFromStr")] + // url: Url, + // code: String, + // }, + WebAuthn { value: String }, } #[serde_with::skip_serializing_none] @@ -207,12 +300,29 @@ impl Secd { &var(ENV_EMAIL_MESSENGER).unwrap_or(AuthEmailMessenger::Local.to_string()), ) .expect("unreachable f4ad0f48-0812-427f-b477-0f9c67bb69c5"); - let email_messenger_client_id = var(ENV_EMAIL_MESSENGER_CLIENT_ID).ok(); - let email_messenger_client_secret = var(ENV_EMAIL_MESSENGER_CLIENT_SECRET).ok(); + let crypter_secret_key = var(ENV_CRYPTER_SECRET_KEY).unwrap_or_else(|_| { + warn!( + "NO CRYPTER KEY PROVIDED, USING DEFAULT KEY. DO NOT USE THIS KEY IN PRODUCTION. PROVIDE A UNIQUE SECRET KEY BY SETTING THE ENVIORNMENT VARIABLE {}. THE DEFAULT KEY IS: {}", + ENV_CRYPTER_SECRET_KEY, + CRYPTER_SECRET_KEY_DEFAULT, + ); + CRYPTER_SECRET_KEY_DEFAULT.to_string() + }); info!("starting client with auth_store: {:?}", auth_store); info!("starting client with email_messenger: {:?}", auth_store); + let cfg = Cfg { + email_address_from: var(ENV_EMAIL_ADDRESS_FROM) + .ok() + .and_then(|s| s.parse().ok()), + email_address_replyto: var(ENV_EMAIL_ADDRESS_REPLYTO) + .ok() + .and_then(|s| s.parse().ok()), + email_signup_message: var(ENV_EMAIL_SIGNUP_MESSAGE).ok(), + email_signin_message: var(ENV_EMAIL_SIGNIN_MESSAGE).ok(), + }; + let store = match auth_store { AuthStore::Sqlite { conn } => { if z_schema.is_some() { @@ -252,7 +362,10 @@ impl Secd { }; let email_sender = match email_messenger { - AuthEmailMessenger::Local => LocalMailer {}, + AuthEmailMessenger::Local => LocalMailer::new(), + AuthEmailMessenger::Sendgrid => Sendgrid::new( + var(ENV_EMAIL_SENDGRID_API_KEY).expect("No SENDGRID_API_KEY provided"), + ), _ => unimplemented!(), }; @@ -267,10 +380,14 @@ impl Secd { None => None, }; + let crypter = Crypter::new(crypter_secret_key.as_bytes()); + Ok(Secd { - store, - email_messenger: Arc::new(email_sender), + crypter, + email_messenger: email_sender, spice, + store, + cfg, }) } } diff --git a/crates/secd/src/util/crypter.rs b/crates/secd/src/util/crypter.rs new file mode 100644 index 0000000..1717377 --- /dev/null +++ b/crates/secd/src/util/crypter.rs @@ -0,0 +1,87 @@ +use aes_gcm::{ + aead::{Aead, KeyInit, OsRng}, + Aes256Gcm, Nonce, +}; +use argon2::{ + password_hash::{self, SaltString}, + Argon2, PasswordHasher, +}; +use derive_more::Display; +use rand::Rng; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[derive(Debug, Display, Error)] +pub enum CrypterError { + EncryptError(String), + DecryptError(String), + DecodeError(String), + HashError(String), +} + +pub struct Crypter { + pub key: Vec<u8>, +} + +impl Crypter { + pub fn new(key: &[u8]) -> Self { + Self { key: key.to_vec() } + } + + pub fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, CrypterError> { + let mut hasher = Sha256::new(); + hasher.update(&self.key); + + let cipher = Aes256Gcm::new(&hasher.finalize()); + + let rbs = rand::thread_rng().gen::<[u8; 12]>(); + let iv = Nonce::from_slice(&rbs); + let crypt = cipher + .encrypt(&iv, data) + .map_err(|e| CrypterError::EncryptError(e.to_string()))?; + + let mut msg = iv.to_vec(); + msg.extend_from_slice(&crypt); + Ok(msg) + } + + pub fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, CrypterError> { + let mut hasher = Sha256::new(); + hasher.update(&self.key); + + let cipher = Aes256Gcm::new(&hasher.finalize()); + + let iv = Nonce::from_slice(&data[0..=11]); + let data = &data[12..]; + Ok(cipher + .decrypt(&iv, data) + .map_err(|e| CrypterError::DecryptError(e.to_string()))?) + } + + pub fn hash(&self, data: &[u8]) -> Result<String, CrypterError> { + let salt = SaltString::generate(&mut OsRng); + let hasher = Argon2::default(); + Ok(hasher + .hash_password(data, &salt) + .map_err(|e| CrypterError::HashError(e.to_string()))? + .to_string()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn encrypt_data_test() { + let crypter = Crypter { + key: "testkey".to_string().into_bytes(), + }; + + let plaintext = "This is a secret."; + let enc = crypter.encrypt(&plaintext.as_bytes()).unwrap(); + + let res = crypter.decrypt(&enc).unwrap(); + assert_eq!(plaintext, std::str::from_utf8(&res).unwrap()); + } +} diff --git a/crates/secd/src/util/from.rs b/crates/secd/src/util/from.rs index bab8a25..ec5b62d 100644 --- a/crates/secd/src/util/from.rs +++ b/crates/secd/src/util/from.rs @@ -1,5 +1,3 @@ -use std::str::FromStr; - use crate::AuthStore; impl From<Option<String>> for AuthStore { @@ -23,7 +21,6 @@ mod test { #[test] fn auth_store_from_string() { let in_conn = None; - let a = AuthStore::from(in_conn.clone()); match AuthStore::from(in_conn.clone()) { AuthStore::Sqlite { conn } => assert_eq!(conn, "sqlite::memory:"), r @ _ => assert!( @@ -46,14 +43,12 @@ mod test { } let sqlite_conn = Some("sqlite:///path/to/db.sql".into()); - let a = AuthStore::from(sqlite_conn.clone()); match AuthStore::from(sqlite_conn.clone()) { AuthStore::Sqlite { conn } => assert_eq!(conn, sqlite_conn.unwrap()), r @ _ => assert!(false, "should have parsed as sqlite. Found: {:?}", r), } let sqlite_mem_conn = Some("sqlite:memory:".into()); - let a = AuthStore::from(sqlite_mem_conn.clone()); match AuthStore::from(sqlite_mem_conn.clone()) { AuthStore::Sqlite { conn } => assert_eq!(conn, sqlite_mem_conn.unwrap()), r @ _ => assert!( diff --git a/crates/secd/src/util/mod.rs b/crates/secd/src/util/mod.rs index c26986d..8676f26 100644 --- a/crates/secd/src/util/mod.rs +++ b/crates/secd/src/util/mod.rs @@ -1,21 +1,14 @@ +pub(crate) mod crypter; pub(crate) mod from; -use rand::{thread_rng, Rng}; +use self::crypter::{Crypter, CrypterError}; +use crate::{ + AddressType, Credential, CredentialType, IdentityId, SecdError, Session, SESSION_DURATION, + SESSION_SIZE_BYTES, +}; use sha2::{Digest, Sha256}; +use std::str::from_utf8; use time::OffsetDateTime; -use url::Url; - -use crate::{AddressType, IdentityId, SecdError, Session, SESSION_DURATION, SESSION_SIZE_BYTES}; - -pub(crate) fn remove_trailing_slash(url: &mut Url) -> String { - let mut u = url.to_string(); - - if u.ends_with('/') { - u.pop(); - } - - u -} pub(crate) fn hash(i: &[u8]) -> Vec<u8> { let mut hasher = Sha256::new(); @@ -51,3 +44,89 @@ impl Session { }) } } + +impl Credential { + pub(crate) fn encrypt(&mut self, crypter: &Crypter) -> Result<(), SecdError> { + Ok(match self.t { + CredentialType::Passphrase { + key: _, + ref mut value, + } => { + *value = hex::encode(crypter.encrypt(value.as_bytes())?); + } + _ => {} + }) + } + pub(crate) fn decrypt(&mut self, crypter: &Crypter) -> Result<(), SecdError> { + Ok(match self.t { + CredentialType::Passphrase { + key: _, + ref mut value, + } => { + *value = from_utf8( + &crypter.decrypt( + &hex::decode(value.clone()) + .map_err(|e| CrypterError::DecodeError(e.to_string()))?, + )?, + ) + .map_err(|e| CrypterError::DecodeError(e.to_string()))? + .to_string() + } + _ => {} + }) + } + + pub(crate) fn hash(&mut self, crypter: &Crypter) -> Result<(), SecdError> { + Ok(match self.t { + CredentialType::Passphrase { + key: _, + ref mut value, + } => { + *value = crypter.hash(value.as_bytes())?; + } + _ => {} + }) + } +} + +#[cfg(test)] +mod test { + use uuid::Uuid; + + use super::*; + + #[test] + fn test_credential_encrypt() { + let c = Crypter::new("AMAZING_KEY".as_bytes()); + + let plaintext_secret = "super_password".to_string(); + + let mut credential = Credential { + id: Uuid::new_v4(), + identity_id: Uuid::new_v4(), + t: CredentialType::Passphrase { + key: "super_user".into(), + value: plaintext_secret.clone(), + }, + created_at: OffsetDateTime::now_utc(), + revoked_at: None, + deleted_at: None, + }; + + credential.encrypt(&c).unwrap(); + match &credential.t { + CredentialType::Passphrase { key: _, value } => { + assert_ne!(plaintext_secret.clone(), value.clone()) + } + _ => {} + }; + + credential.decrypt(&c).unwrap(); + match &credential.t { + CredentialType::Passphrase { key: _, value } => { + assert_eq!(plaintext_secret.clone(), value.clone()) + } + _ => {} + }; + } +} diff --git a/crates/secd/store/pg/migrations/20221222002434_bootstrap.sql b/crates/secd/store/pg/migrations/20221222002434_bootstrap.sql index 2b89957..0cf3fa0 100644 --- a/crates/secd/store/pg/migrations/20221222002434_bootstrap.sql +++ b/crates/secd/store/pg/migrations/20221222002434_bootstrap.sql @@ -19,7 +19,7 @@ create table if not exists secd.realm_data ( create table if not exists secd.identity ( identity_id bigserial primary key , identity_public_id uuid not null - , data jsonb -- some things are dervied, others are not + , data text -- we do not prescribe JSON or any other serialization format. , created_at timestamptz not null , updated_at timestamptz not null , deleted_at timestamptz @@ -30,14 +30,18 @@ create table if not exists secd.credential ( credential_id bigserial primary key , credential_public_id uuid not null , identity_id bigint not null references secd.identity(identity_id) + , partial_key text , type text not null-- e.g. password, oidc, totop, lookup_secret, webauthn, ... , data jsonb not null - , version integer not null , created_at timestamptz not null , revoked_at timestamptz , deleted_at timestamptz ); +create unique index if not exists credential_passphrase_type_key_ix +on secd.credential (partial_key) +where type = 'Passphrase'; + create table if not exists secd.address ( address_id bigserial primary key , address_public_id uuid not null @@ -83,3 +87,23 @@ create table if not exists secd.message ( , created_at timestamptz not null , sent_at timestamptz ); + +create table if not exists secd.namespace_config ( + namespace text not null + , serialized_config text not null + , created_at xid8 not null + , deleted_at xid8 + -- TODO: indexes and stuff +); + +create table if not exists secd.relation_tuple ( + namespace text not null + , object_id text not null + , relation text not null + , userset_namespace text not null + , userset_object_id text not null + , userset_relation text not null + , created_at xid8 not null + , deleted_at xid8 not null + -- TODO: indexes and stuff +); diff --git a/crates/secd/store/pg/sql/find_credential.sql b/crates/secd/store/pg/sql/find_credential.sql new file mode 100644 index 0000000..e30c0ea --- /dev/null +++ b/crates/secd/store/pg/sql/find_credential.sql @@ -0,0 +1,12 @@ +select c.credential_public_id + , i.identity_public_id + , c.data::text + , c.created_at + , c.revoked_at + , c.deleted_at +from secd.credential c +join secd.identity i using (identity_id) +where (($1::uuid is null) or (c.credential_public_id = $1)) +and (($2::uuid is null) or (i.identity_public_id = $2)) +and (($3::text is null) or (c.type = $3)) +and (($3::text is null or $4::text is null) or (c.data->$3->>'key' = $4)) diff --git a/crates/secd/store/pg/sql/write_credential.sql b/crates/secd/store/pg/sql/write_credential.sql new file mode 100644 index 0000000..17e03a2 --- /dev/null +++ b/crates/secd/store/pg/sql/write_credential.sql @@ -0,0 +1,19 @@ +insert into secd.credential ( + credential_public_id + , identity_id + , partial_key + , type + , data + , created_at + , revoked_at + , deleted_at +) values ( + $1 + , (select identity_id from secd.identity where identity_public_id = $2) + , $3 + , $4 + , $5::jsonb + , $6 + , $7 + , $8 +); diff --git a/crates/secd/store/pg/sql/write_identity.sql b/crates/secd/store/pg/sql/write_identity.sql index 67662a6..4b2745b 100644 --- a/crates/secd/store/pg/sql/write_identity.sql +++ b/crates/secd/store/pg/sql/write_identity.sql @@ -5,7 +5,7 @@ insert into secd.identity ( , updated_at , deleted_at ) values ( - $1, $2::jsonb, $3, $4, $5 + $1, $2, $3, $4, $5 ) on conflict (identity_public_id) do update set data = excluded.data , updated_at = excluded.updated_at diff --git a/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql index 299f282..b2ce45d 100644 --- a/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql +++ b/crates/secd/store/sqlite/migrations/20221125051738_bootstrap.sql @@ -15,7 +15,7 @@ create table if not exists realm_data ( create table if not exists identity ( identity_id integer primary key , identity_public_id uuid not null - , data text -- some things are dervied, others are not + , data text -- we do not prescribe JSON or any other serialization format , created_at integer not null , updated_at integer not null , deleted_at integer @@ -26,14 +26,18 @@ create table if not exists credential ( credential_id integer primary key , credential_public_id uuid not null , identity_id integer not null references identity(identity_id) + , partial_key text , type text not null-- e.g. password, oidc, totop, lookup_secret, webauthn, ... , data text not null - , version integer not null , created_at integer not null , revoked_at integer , deleted_at integer ); +create unique index if not exists credential_passphrase_type_key_ix +on credential (partial_key) +where type = 'Passphrase'; + create table if not exists address ( address_id integer primary key , address_public_id uuid not null diff --git a/crates/secd/store/sqlite/sql/find_credential.sql b/crates/secd/store/sqlite/sql/find_credential.sql new file mode 100644 index 0000000..9062914 --- /dev/null +++ b/crates/secd/store/sqlite/sql/find_credential.sql @@ -0,0 +1,12 @@ +select c.credential_public_id + , i.identity_public_id + , c.data + , c.created_at + , c.revoked_at + , c.deleted_at +from credential c +join identity i using (identity_id) +where (($1 is null) or (c.credential_public_id = $1)) +and (($2 is null) or (i.identity_public_id = $2)) +and (($3 is null) or (c.type = $3)) +and (($3 is null or $4 is null) or (c.data->$3->>'key' = $4)) diff --git a/crates/secd/store/sqlite/sql/write_credential.sql b/crates/secd/store/sqlite/sql/write_credential.sql new file mode 100644 index 0000000..3319226 --- /dev/null +++ b/crates/secd/store/sqlite/sql/write_credential.sql @@ -0,0 +1,19 @@ +insert into credential ( + credential_public_id + , identity_id + , partial_key + , type + , data + , created_at + , revoked_at + , deleted_at +) values ( + $1 + , (select identity_id from identity where identity_public_id = $2) + , $3 + , $4 + , $5 + , $6 + , $7 + , $8 +); |
