diff options
Diffstat (limited to 'crates/secd')
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 +); |
