From 69ee4a70b453b81b96347fb299bf5c14f1038156 Mon Sep 17 00:00:00 2001
From: Jeremy Lin <jeremy.lin@gmail.com>
Date: Wed, 19 Jan 2022 02:51:26 -0800
Subject: [PATCH 1/2] Add support for API keys

This is mainly useful for CLI-based login automation.
---
 .../2022-01-17-234911_add_api_key/down.sql    |  0
 .../2022-01-17-234911_add_api_key/up.sql      |  2 +
 .../2022-01-17-234911_add_api_key/down.sql    |  0
 .../2022-01-17-234911_add_api_key/up.sql      |  2 +
 .../2022-01-17-234911_add_api_key/down.sql    |  0
 .../2022-01-17-234911_add_api_key/up.sql      |  2 +
 src/api/core/accounts.rs                      | 39 ++++++++-
 src/api/identity.rs                           | 86 ++++++++++++++++++-
 src/crypto.rs                                 | 28 ++++++
 src/db/models/user.rs                         |  9 +-
 src/db/schemas/mysql/schema.rs                |  1 +
 src/db/schemas/postgresql/schema.rs           |  1 +
 src/db/schemas/sqlite/schema.rs               |  1 +
 13 files changed, 164 insertions(+), 7 deletions(-)
 create mode 100644 migrations/mysql/2022-01-17-234911_add_api_key/down.sql
 create mode 100644 migrations/mysql/2022-01-17-234911_add_api_key/up.sql
 create mode 100644 migrations/postgresql/2022-01-17-234911_add_api_key/down.sql
 create mode 100644 migrations/postgresql/2022-01-17-234911_add_api_key/up.sql
 create mode 100644 migrations/sqlite/2022-01-17-234911_add_api_key/down.sql
 create mode 100644 migrations/sqlite/2022-01-17-234911_add_api_key/up.sql

diff --git a/migrations/mysql/2022-01-17-234911_add_api_key/down.sql b/migrations/mysql/2022-01-17-234911_add_api_key/down.sql
new file mode 100644
index 00000000..e69de29b
diff --git a/migrations/mysql/2022-01-17-234911_add_api_key/up.sql b/migrations/mysql/2022-01-17-234911_add_api_key/up.sql
new file mode 100644
index 00000000..85ac377b
--- /dev/null
+++ b/migrations/mysql/2022-01-17-234911_add_api_key/up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ADD COLUMN api_key VARCHAR(255);
diff --git a/migrations/postgresql/2022-01-17-234911_add_api_key/down.sql b/migrations/postgresql/2022-01-17-234911_add_api_key/down.sql
new file mode 100644
index 00000000..e69de29b
diff --git a/migrations/postgresql/2022-01-17-234911_add_api_key/up.sql b/migrations/postgresql/2022-01-17-234911_add_api_key/up.sql
new file mode 100644
index 00000000..b1117556
--- /dev/null
+++ b/migrations/postgresql/2022-01-17-234911_add_api_key/up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ADD COLUMN api_key TEXT;
diff --git a/migrations/sqlite/2022-01-17-234911_add_api_key/down.sql b/migrations/sqlite/2022-01-17-234911_add_api_key/down.sql
new file mode 100644
index 00000000..e69de29b
diff --git a/migrations/sqlite/2022-01-17-234911_add_api_key/up.sql b/migrations/sqlite/2022-01-17-234911_add_api_key/up.sql
new file mode 100644
index 00000000..b1117556
--- /dev/null
+++ b/migrations/sqlite/2022-01-17-234911_add_api_key/up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ADD COLUMN api_key TEXT;
diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs
index e3ebcde0..1b16ae64 100644
--- a/src/api/core/accounts.rs
+++ b/src/api/core/accounts.rs
@@ -34,6 +34,8 @@ pub fn routes() -> Vec<rocket::Route> {
         password_hint,
         prelogin,
         verify_password,
+        api_key,
+        rotate_api_key,
     ]
 }
 
@@ -647,15 +649,17 @@ fn prelogin(data: JsonUpcase<PreloginData>, conn: DbConn) -> Json<Value> {
         "KdfIterations": kdf_iter
     }))
 }
+
+// https://github.com/bitwarden/server/blob/master/src/Api/Models/Request/Accounts/SecretVerificationRequestModel.cs
 #[derive(Deserialize)]
 #[allow(non_snake_case)]
-struct VerifyPasswordData {
+struct SecretVerificationRequest {
     MasterPasswordHash: String,
 }
 
 #[post("/accounts/verify-password", data = "<data>")]
-fn verify_password(data: JsonUpcase<VerifyPasswordData>, headers: Headers) -> EmptyResult {
-    let data: VerifyPasswordData = data.into_inner().data;
+fn verify_password(data: JsonUpcase<SecretVerificationRequest>, headers: Headers) -> EmptyResult {
+    let data: SecretVerificationRequest = data.into_inner().data;
     let user = headers.user;
 
     if !user.check_valid_password(&data.MasterPasswordHash) {
@@ -664,3 +668,32 @@ fn verify_password(data: JsonUpcase<VerifyPasswordData>, headers: Headers) -> Em
 
     Ok(())
 }
+
+fn _api_key(data: JsonUpcase<SecretVerificationRequest>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
+    let data: SecretVerificationRequest = data.into_inner().data;
+    let mut user = headers.user;
+
+    if !user.check_valid_password(&data.MasterPasswordHash) {
+        err!("Invalid password")
+    }
+
+    if rotate || user.api_key.is_none() {
+        user.api_key = Some(crypto::generate_api_key());
+        user.save(&conn).expect("Error saving API key");
+    }
+
+    Ok(Json(json!({
+      "ApiKey": user.api_key,
+      "Object": "apiKey",
+    })))
+}
+
+#[post("/accounts/api-key", data = "<data>")]
+fn api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
+    _api_key(data, false, headers, conn)
+}
+
+#[post("/accounts/rotate-api-key", data = "<data>")]
+fn rotate_api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
+    _api_key(data, true, headers, conn)
+}
diff --git a/src/api/identity.rs b/src/api/identity.rs
index 3cb26ba3..49310930 100644
--- a/src/api/identity.rs
+++ b/src/api/identity.rs
@@ -43,6 +43,13 @@ fn login(data: Form<ConnectData>, conn: DbConn, ip: ClientIp) -> JsonResult {
 
             _password_login(data, conn, &ip)
         }
+        "client_credentials" => {
+            _check_is_some(&data.client_id, "client_id cannot be blank")?;
+            _check_is_some(&data.client_secret, "client_secret cannot be blank")?;
+            _check_is_some(&data.scope, "scope cannot be blank")?;
+
+            _api_key_login(data, conn, &ip)
+        }
         t => err!("Invalid type", t),
     }
 }
@@ -178,6 +185,75 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
     Ok(Json(result))
 }
 
+fn _api_key_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult {
+    // Validate scope
+    let scope = data.scope.as_ref().unwrap();
+    if scope != "api" {
+        err!("Scope not supported")
+    }
+
+    // Ratelimit the login
+    crate::ratelimit::check_limit_login(&ip.ip)?;
+
+    // Get the user via the client_id
+    let client_id = data.client_id.as_ref().unwrap();
+    let user_uuid = match client_id.strip_prefix("user.") {
+        Some(uuid) => uuid,
+        None => err!("Malformed client_id", format!("IP: {}.", ip.ip)),
+    };
+    let user = match User::find_by_uuid(user_uuid, &conn) {
+        Some(user) => user,
+        None => err!("Invalid client_id", format!("IP: {}.", ip.ip)),
+    };
+
+    // Check if the user is disabled
+    if !user.enabled {
+        err!("This user has been disabled (API key login)", format!("IP: {}. Username: {}.", ip.ip, user.email))
+    }
+
+    // Check API key. Note that API key logins bypass 2FA.
+    let client_secret = data.client_secret.as_ref().unwrap();
+    if !user.check_valid_api_key(client_secret) {
+        err!("Incorrect client_secret", format!("IP: {}. Username: {}.", ip.ip, user.email))
+    }
+
+    let (mut device, new_device) = get_device(&data, &conn, &user);
+
+    if CONFIG.mail_enabled() && new_device {
+        let now = Utc::now().naive_utc();
+        if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name) {
+            error!("Error sending new device email: {:#?}", e);
+
+            if CONFIG.require_device_email() {
+                err!("Could not send login notification email. Please contact your administrator.")
+            }
+        }
+    }
+
+    // Common
+    let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
+
+    let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
+    device.save(&conn)?;
+
+    info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
+
+    Ok(Json(json!({
+        "access_token": access_token,
+        "expires_in": expires_in,
+        "token_type": "Bearer",
+        "refresh_token": device.refresh_token,
+        "Key": user.akey,
+        "PrivateKey": user.private_key,
+
+        "Kdf": user.client_kdf_type,
+        "KdfIterations": user.client_kdf_iter,
+        "ResetMasterPassword": false, // TODO: Same as above
+        "scope": "api",
+        "unofficialServer": true,
+    })))
+}
+
 /// Retrieves an existing device or creates a new device from ConnectData and the User
 fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> (Device, bool) {
     // On iOS, device_type sends "iOS", on others it sends a number
@@ -374,17 +450,20 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
     Ok(result)
 }
 
+// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
 // https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs
 #[derive(Debug, Clone, Default)]
 #[allow(non_snake_case)]
 struct ConnectData {
-    grant_type: String, // refresh_token, password
+    // refresh_token, password, client_credentials (API key)
+    grant_type: String,
 
     // Needed for grant_type="refresh_token"
     refresh_token: Option<String>,
 
-    // Needed for grant_type="password"
-    client_id: Option<String>, // web, cli, desktop, browser, mobile
+    // Needed for grant_type = "password" | "client_credentials"
+    client_id: Option<String>,     // web, cli, desktop, browser, mobile
+    client_secret: Option<String>, // API key login (cli only)
     password: Option<String>,
     scope: Option<String>,
     username: Option<String>,
@@ -414,6 +493,7 @@ impl<'f> FromForm<'f> for ConnectData {
                 "granttype" => form.grant_type = value,
                 "refreshtoken" => form.refresh_token = Some(value),
                 "clientid" => form.client_id = Some(value),
+                "clientsecret" => form.client_secret = Some(value),
                 "password" => form.password = Some(value),
                 "scope" => form.scope = Some(value),
                 "username" => form.username = Some(value),
diff --git a/src/crypto.rs b/src/crypto.rs
index 61e55649..e30439fc 100644
--- a/src/crypto.rs
+++ b/src/crypto.rs
@@ -51,6 +51,28 @@ pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
     array
 }
 
+/// Generates a random string over a specified alphabet.
+pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String {
+    // Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
+    use rand::Rng;
+    let mut rng = rand::thread_rng();
+
+    (0..num_chars)
+        .map(|_| {
+            let i = rng.gen_range(0..alphabet.len());
+            alphabet[i] as char
+        })
+        .collect()
+}
+
+/// Generates a random alphanumeric string.
+pub fn get_random_string_alphanum(num_chars: usize) -> String {
+    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
+                              abcdefghijklmnopqrstuvwxyz\
+                              0123456789";
+    get_random_string(ALPHABET, num_chars)
+}
+
 pub fn generate_id(num_bytes: usize) -> String {
     HEXLOWER.encode(&get_random(vec![0; num_bytes]))
 }
@@ -84,6 +106,12 @@ pub fn generate_token(token_size: u32) -> Result<String, Error> {
     Ok(token)
 }
 
+/// Generates a personal API key.
+/// Upstream uses 30 chars, which is ~178 bits of entropy.
+pub fn generate_api_key() -> String {
+    get_random_string_alphanum(30)
+}
+
 //
 // Constant time compare
 //
diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index 0197535b..599661e5 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -44,8 +44,9 @@ db_object! {
 
         pub client_kdf_type: i32,
         pub client_kdf_iter: i32,
-    }
 
+        pub api_key: Option<String>,
+    }
 
     #[derive(Identifiable, Queryable, Insertable)]
     #[table_name = "invitations"]
@@ -110,6 +111,8 @@ impl User {
 
             client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT,
             client_kdf_iter: Self::CLIENT_KDF_ITER_DEFAULT,
+
+            api_key: None,
         }
     }
 
@@ -130,6 +133,10 @@ impl User {
         }
     }
 
+    pub fn check_valid_api_key(&self, key: &str) -> bool {
+        matches!(self.api_key, Some(ref api_key) if crate::crypto::ct_eq(api_key, key))
+    }
+
     /// Set the password hash generated
     /// And resets the security_stamp. Based upon the allow_next_route the security_stamp will be different.
     ///
diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs
index 8bfeae4c..61234a16 100644
--- a/src/db/schemas/mysql/schema.rs
+++ b/src/db/schemas/mysql/schema.rs
@@ -178,6 +178,7 @@ table! {
         excluded_globals -> Text,
         client_kdf_type -> Integer,
         client_kdf_iter -> Integer,
+        api_key -> Nullable<Text>,
     }
 }
 
diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs
index 06939ab6..855b4fbc 100644
--- a/src/db/schemas/postgresql/schema.rs
+++ b/src/db/schemas/postgresql/schema.rs
@@ -178,6 +178,7 @@ table! {
         excluded_globals -> Text,
         client_kdf_type -> Integer,
         client_kdf_iter -> Integer,
+        api_key -> Nullable<Text>,
     }
 }
 
diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs
index 06939ab6..855b4fbc 100644
--- a/src/db/schemas/sqlite/schema.rs
+++ b/src/db/schemas/sqlite/schema.rs
@@ -178,6 +178,7 @@ table! {
         excluded_globals -> Text,
         client_kdf_type -> Integer,
         client_kdf_iter -> Integer,
+        api_key -> Nullable<Text>,
     }
 }
 

From 8f7900759fdd2f2578461ac8aa38bbcd854a6b0f Mon Sep 17 00:00:00 2001
From: Jeremy Lin <jeremy.lin@gmail.com>
Date: Thu, 20 Jan 2022 21:50:58 -0800
Subject: [PATCH 2/2] Fix `scope` and `refresh_token` for API key logins

API key logins use a scope of `api`, not `api offline_access`. Since
`offline_access` is not requested, no `refresh_token` is returned either.
---
 src/api/identity.rs     | 27 +++++++++++++++------------
 src/db/models/device.rs |  9 +++++++--
 2 files changed, 22 insertions(+), 14 deletions(-)

diff --git a/src/api/identity.rs b/src/api/identity.rs
index 49310930..0adc542f 100644
--- a/src/api/identity.rs
+++ b/src/api/identity.rs
@@ -61,13 +61,15 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
     // Get device by refresh token
     let mut device = Device::find_by_refresh_token(&token, &conn).map_res("Invalid refresh token")?;
 
-    // COMMON
+    let scope = "api offline_access";
+    let scope_vec = vec!["api".into(), "offline_access".into()];
+
+    // Common
     let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
     let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
-
-    let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
-
+    let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec);
     device.save(&conn)?;
+
     Ok(Json(json!({
         "access_token": access_token,
         "expires_in": expires_in,
@@ -79,7 +81,7 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
         "Kdf": user.client_kdf_type,
         "KdfIterations": user.client_kdf_iter,
         "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
-        "scope": "api offline_access",
+        "scope": scope,
         "unofficialServer": true,
     })))
 }
@@ -90,6 +92,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
     if scope != "api offline_access" {
         err!("Scope not supported")
     }
+    let scope_vec = vec!["api".into(), "offline_access".into()];
 
     // Ratelimit the login
     crate::ratelimit::check_limit_login(&ip.ip)?;
@@ -157,8 +160,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
 
     // Common
     let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
-
-    let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
+    let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec);
     device.save(&conn)?;
 
     let mut result = json!({
@@ -173,7 +175,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
         "Kdf": user.client_kdf_type,
         "KdfIterations": user.client_kdf_iter,
         "ResetMasterPassword": false,// TODO: Same as above
-        "scope": "api offline_access",
+        "scope": scope,
         "unofficialServer": true,
     });
 
@@ -191,6 +193,7 @@ fn _api_key_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
     if scope != "api" {
         err!("Scope not supported")
     }
+    let scope_vec = vec!["api".into()];
 
     // Ratelimit the login
     crate::ratelimit::check_limit_login(&ip.ip)?;
@@ -232,24 +235,24 @@ fn _api_key_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
 
     // Common
     let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
-
-    let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
+    let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec);
     device.save(&conn)?;
 
     info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
 
+    // Note: No refresh_token is returned. The CLI just repeats the
+    // client_credentials login flow when the existing token expires.
     Ok(Json(json!({
         "access_token": access_token,
         "expires_in": expires_in,
         "token_type": "Bearer",
-        "refresh_token": device.refresh_token,
         "Key": user.akey,
         "PrivateKey": user.private_key,
 
         "Kdf": user.client_kdf_type,
         "KdfIterations": user.client_kdf_iter,
         "ResetMasterPassword": false, // TODO: Same as above
-        "scope": "api",
+        "scope": scope,
         "unofficialServer": true,
     })))
 }
diff --git a/src/db/models/device.rs b/src/db/models/device.rs
index 2fbdea01..05955c04 100644
--- a/src/db/models/device.rs
+++ b/src/db/models/device.rs
@@ -60,7 +60,12 @@ impl Device {
         self.twofactor_remember = None;
     }
 
-    pub fn refresh_tokens(&mut self, user: &super::User, orgs: Vec<super::UserOrganization>) -> (String, i64) {
+    pub fn refresh_tokens(
+        &mut self,
+        user: &super::User,
+        orgs: Vec<super::UserOrganization>,
+        scope: Vec<String>,
+    ) -> (String, i64) {
         // If there is no refresh token, we create one
         if self.refresh_token.is_empty() {
             use crate::crypto;
@@ -98,7 +103,7 @@ impl Device {
 
             sstamp: user.security_stamp.to_string(),
             device: self.uuid.to_string(),
-            scope: vec!["api".into(), "offline_access".into()],
+            scope,
             amr: vec!["Application".into()],
         };