| 1 | use crate::*; |
| 2 | use rusqlite::{Connection, OptionalExtension, params as sql}; |
| 3 | use ssh_key::{HashAlg, PublicKey}; |
| 4 | |
| 5 | pub fn initialise(db: &Connection) -> Result<()> { |
| 6 | db.execute_batch("CREATE TABLE IF NOT EXISTS commit_emails (email TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,verified INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(user_id,email)); |
| 7 | CREATE UNIQUE INDEX IF NOT EXISTS confirmed_commit_email ON commit_emails(email) WHERE verified=1; |
| 8 | CREATE TABLE IF NOT EXISTS signing_keys (fingerprint TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,label TEXT NOT NULL,public_key TEXT NOT NULL,created INTEGER NOT NULL,verified INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(user_id,fingerprint)); |
| 9 | CREATE UNIQUE INDEX IF NOT EXISTS confirmed_signing_key ON signing_keys(fingerprint) WHERE verified=1;")?; |
| 10 | Ok(()) |
| 11 | } |
| 12 | |
| 13 | pub fn email(value: &Value) -> Result<String> { |
| 14 | let email = string(value).trim().to_lowercase(); |
| 15 | if email.len() > 254 || email.is_empty() || email.chars().any(char::is_whitespace) |
| 16 | || email.chars().any(char::is_control) || email.split('@').count() != 2 |
| 17 | || email.starts_with('@') || email.ends_with('@') { |
| 18 | return Err(Error::new(400, "Enter a full email address.")); |
| 19 | } |
| 20 | Ok(email) |
| 21 | } |
| 22 | |
| 23 | pub fn add_email(db: &Connection, id: &str, value: &Value, verified: bool) -> Result<()> { |
| 24 | let email = email(value)?; |
| 25 | let primary: Option<String> = db.query_row("SELECT id FROM users WHERE lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1", [&email], |r| r.get(0)).optional()?; |
| 26 | let owner: Option<String> = db.query_row("SELECT user_id FROM commit_emails WHERE email=? AND verified=1", [&email], |r| r.get(0)).optional()?; |
| 27 | let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE email=? AND user_id=?)", sql![email,id], |r| r.get(0))?; |
| 28 | if primary.as_deref().is_some_and(|owner| owner != id) || owner.as_deref().is_some_and(|owner| owner != id) { |
| 29 | return Err(Error::new(409, "This email belongs to another account. Ask an admin to check its owner.")); |
| 30 | } |
| 31 | let count: i64 = db.query_row("SELECT count(*) FROM commit_emails WHERE user_id=?", [id], |r| r.get(0))?; |
| 32 | if !exists && count >= 64 { return Err(Error::new(400, "Remove an email before adding another.")); } |
| 33 | db.execute("INSERT INTO commit_emails VALUES (?,?,?) ON CONFLICT(user_id,email) DO UPDATE SET verified=max(verified,excluded.verified)", sql![email,id,verified])?; |
| 34 | Ok(()) |
| 35 | } |
| 36 | |
| 37 | pub fn add_key(db: &Connection, id: &str, body: &Value, verified: bool) -> Result<()> { |
| 38 | let source = string(&body["publicKey"]).trim(); |
| 39 | let label = string(&body["label"]).trim(); |
| 40 | if source.len() > 16384 || source.contains(['\n','\r']) || label.len() > 128 || label.chars().any(char::is_control) { |
| 41 | return Err(Error::new(400, "Paste one SSH public key and use a label under 128 characters.")); |
| 42 | } |
| 43 | let mut key = PublicKey::from_openssh(source).map_err(|_| Error::new(400, "Paste an SSH public key, such as an ssh-ed25519 key."))?; |
| 44 | if key.algorithm().as_str() == "ssh-dss" { return Err(Error::new(400, "DSA keys are obsolete. Use an Ed25519, RSA, or ECDSA public key.")); } |
| 45 | let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); |
| 46 | let label = if label.is_empty() { key.comment() } else { label }.to_owned(); |
| 47 | if label.len() > 128 || label.chars().any(char::is_control) { return Err(Error::new(400, "Use a key label under 128 characters.")); } |
| 48 | key.set_comment(""); |
| 49 | let public_key = key.to_openssh().map_err(|_| Error::new(400, "Paste an SSH public key."))?; |
| 50 | let owner: Option<String> = db.query_row("SELECT user_id FROM signing_keys WHERE fingerprint=? AND verified=1", [&fingerprint], |r| r.get(0)).optional()?; |
| 51 | if owner.as_deref().is_some_and(|owner| owner != id) { return Err(Error::new(409, "This key belongs to another account. Ask an admin to check its owner.")); } |
| 52 | let count: i64 = db.query_row("SELECT count(*) FROM signing_keys WHERE user_id=?", [id], |r| r.get(0))?; |
| 53 | let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM signing_keys WHERE user_id=? AND fingerprint=?)",sql![id,fingerprint],|r|r.get(0))?; |
| 54 | if !exists && count >= 64 { return Err(Error::new(400, "Remove a signing key before adding another.")); } |
| 55 | db.execute("INSERT INTO signing_keys VALUES (?,?,?,?,?,?) ON CONFLICT(user_id,fingerprint) DO UPDATE SET verified=max(verified,excluded.verified)", sql![fingerprint,id,label,public_key,(now()*1000.0) as i64,verified])?; |
| 56 | Ok(()) |
| 57 | } |
| 58 | |
| 59 | pub fn confirm_key(db: &Connection, id: &str, fingerprint: &str) -> Result<()> { |
| 60 | let body: Option<Value> = db.query_row("SELECT public_key,label FROM signing_keys WHERE user_id=? AND fingerprint=?",sql![id,fingerprint],|r| Ok(json!({"publicKey":r.get::<_,String>(0)?,"label":r.get::<_,String>(1)?}))).optional()?; |
| 61 | add_key(db,id,&body.ok_or_else(|| Error::new(404,"Add this signing key before confirming ownership."))?,true) |
| 62 | } |
| 63 | |
| 64 | pub fn account(db: &Connection, id: &str) -> Result<Value> { |
| 65 | let mut emails = db.prepare("SELECT email,verified FROM commit_emails WHERE user_id=? ORDER BY email")?; |
| 66 | let emails = emails.query_map([id], |r| Ok(json!({"email":r.get::<_,String>(0)?,"verified":r.get::<_,bool>(1)?})))?.collect::<std::result::Result<Vec<_>,_>>()?; |
| 67 | let mut keys = db.prepare("SELECT fingerprint,label,public_key,created,verified FROM signing_keys WHERE user_id=? ORDER BY created,fingerprint")?; |
| 68 | let keys = keys.query_map([id], |r| Ok(json!({"fingerprint":r.get::<_,String>(0)?,"label":r.get::<_,String>(1)?,"publicKey":r.get::<_,String>(2)?,"createdDate":r.get::<_,i64>(3)?,"verified":r.get::<_,bool>(4)?})))?.collect::<std::result::Result<Vec<_>,_>>()?; |
| 69 | Ok(json!({"emails":emails,"signingKeys":keys})) |
| 70 | } |
| 71 | |
| 72 | pub fn import(auth: &auth::Store, input: &Value) -> Result<Value> { |
| 73 | let mut db = auth.db.lock().unwrap(); |
| 74 | let tx = db.transaction()?; |
| 75 | let id: String = tx.query_row("SELECT id FROM users WHERE username=? AND json_extract(profile,'$.enabled')=1", [string(&input["username"])], |r| r.get(0)).optional()?.ok_or_else(|| Error::new(404,"Choose an enabled account."))?; |
| 76 | if !input["emails"].is_array() || !input["signingKeys"].is_array() { return Err(Error::new(400,"Supply email and signing key lists.")); } |
| 77 | for email in array(&input["emails"]) { add_email(&tx,&id,email,true)?; } |
| 78 | for key in array(&input["signingKeys"]) { add_key(&tx,&id,key,true)?; } |
| 79 | let result = account(&tx,&id)?; |
| 80 | tx.commit()?; |
| 81 | Ok(result) |
| 82 | } |
| 83 | |
| 84 | #[cfg(test)] |
| 85 | mod tests { |
| 86 | use super::*; |
| 87 | fn store() -> Connection { |
| 88 | let db = Connection::open_in_memory().unwrap(); |
| 89 | db.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE users(id TEXT PRIMARY KEY,profile TEXT); INSERT INTO users VALUES('one','{\"email\":\"primary@example.test\",\"emailVerified\":true}'),('two','{}');").unwrap(); |
| 90 | initialise(&db).unwrap(); |
| 91 | db |
| 92 | } |
| 93 | #[test] |
| 94 | fn unconfirmed_key_claim_cannot_block_its_owner() { |
| 95 | let db = store(); |
| 96 | let body = json!({"publicKey":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn"}); |
| 97 | add_key(&db,"two",&body,false).unwrap(); |
| 98 | assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false); |
| 99 | add_key(&db,"one",&body,true).unwrap(); |
| 100 | let fingerprint = account(&db,"two").unwrap()["signingKeys"][0]["fingerprint"].as_str().unwrap().to_owned(); |
| 101 | assert!(confirm_key(&db,"two",&fingerprint).is_err()); |
| 102 | assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false); |
| 103 | } |
| 104 | |
| 105 | #[test] |
| 106 | fn confirmation_cannot_steal_an_alias_or_verified_primary() { |
| 107 | let db = store(); |
| 108 | add_email(&db,"one",&json!(" Alias@Example.test "),false).unwrap(); |
| 109 | add_email(&db,"two",&json!("alias@example.test"),false).unwrap(); |
| 110 | add_email(&db,"one",&json!("alias@example.test"),true).unwrap(); |
| 111 | assert!(add_email(&db,"two",&json!("alias@example.test"),true).is_err()); |
| 112 | assert!(add_email(&db,"two",&json!("primary@example.test"),false).is_err()); |
| 113 | assert_eq!(account(&db,"one").unwrap()["emails"][0],json!({"email":"alias@example.test","verified":true})); |
| 114 | for value in ["@host", "name@", "a@b@c", "a b@c", "a\nb@c"] { assert!(email(&json!(value)).is_err()); } |
| 115 | db.execute("DELETE FROM users WHERE id='one'",[]).unwrap(); |
| 116 | add_email(&db,"two",&json!("alias@example.test"),true).unwrap(); |
| 117 | } |
| 118 | #[test] |
| 119 | fn signing_keys_deduplicate_material_and_cannot_change_owner() { |
| 120 | let db = store(); |
| 121 | let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn"; |
| 122 | add_key(&db,"one",&json!({"publicKey":format!("{key} device"),"label":"first"}),true).unwrap(); |
| 123 | add_key(&db,"one",&json!({"publicKey":format!("{key} different comment"),"label":"second"}),true).unwrap(); |
| 124 | assert!(add_key(&db,"two",&json!({"publicKey":key}),true).is_err()); |
| 125 | let keys = account(&db,"one").unwrap()["signingKeys"].clone(); |
| 126 | assert_eq!(keys.as_array().unwrap().len(),1); |
| 127 | assert_eq!(keys[0]["label"],"first"); |
| 128 | assert_eq!(keys[0]["publicKey"],key); |
| 129 | for key in ["garbage", "ssh-ed25519 AAAA", "-----BEGIN OPENSSH PRIVATE KEY-----", "command=\"sh\" ssh-ed25519 AAAA"] { |
| 130 | assert!(add_key(&db,"one",&json!({"publicKey":key}),true).is_err()); |
| 131 | } |
| 132 | } |
| 133 | } |