1use crate::*;
2use rusqlite::{Connection, OptionalExtension, params as sql};
3use ssh_key::{HashAlg, PublicKey};
4
5pub 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
13pub 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
23pub 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
37pub 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
59pub 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
64pub 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
72pub 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)]
85mod 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}