Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2024-11-16 04:12:53 +01:00
Merge branch 'jjlin-api-key' into main
Dieser Commit ist enthalten in:
Commit
19e78e3509
14 geänderte Dateien mit 182 neuen und 17 gelöschten Zeilen
0
migrations/mysql/2022-01-17-234911_add_api_key/down.sql
Normale Datei
0
migrations/mysql/2022-01-17-234911_add_api_key/down.sql
Normale Datei
2
migrations/mysql/2022-01-17-234911_add_api_key/up.sql
Normale Datei
2
migrations/mysql/2022-01-17-234911_add_api_key/up.sql
Normale Datei
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN api_key VARCHAR(255);
|
0
migrations/postgresql/2022-01-17-234911_add_api_key/down.sql
Normale Datei
0
migrations/postgresql/2022-01-17-234911_add_api_key/down.sql
Normale Datei
2
migrations/postgresql/2022-01-17-234911_add_api_key/up.sql
Normale Datei
2
migrations/postgresql/2022-01-17-234911_add_api_key/up.sql
Normale Datei
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN api_key TEXT;
|
0
migrations/sqlite/2022-01-17-234911_add_api_key/down.sql
Normale Datei
0
migrations/sqlite/2022-01-17-234911_add_api_key/down.sql
Normale Datei
2
migrations/sqlite/2022-01-17-234911_add_api_key/up.sql
Normale Datei
2
migrations/sqlite/2022-01-17-234911_add_api_key/up.sql
Normale Datei
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN api_key TEXT;
|
|
@ -34,6 +34,8 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||||
password_hint,
|
password_hint,
|
||||||
prelogin,
|
prelogin,
|
||||||
verify_password,
|
verify_password,
|
||||||
|
api_key,
|
||||||
|
rotate_api_key,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -644,15 +646,17 @@ fn prelogin(data: JsonUpcase<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||||
"KdfIterations": kdf_iter
|
"KdfIterations": kdf_iter
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/bitwarden/server/blob/master/src/Api/Models/Request/Accounts/SecretVerificationRequestModel.cs
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct VerifyPasswordData {
|
struct SecretVerificationRequest {
|
||||||
MasterPasswordHash: String,
|
MasterPasswordHash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/verify-password", data = "<data>")]
|
#[post("/accounts/verify-password", data = "<data>")]
|
||||||
fn verify_password(data: JsonUpcase<VerifyPasswordData>, headers: Headers) -> EmptyResult {
|
fn verify_password(data: JsonUpcase<SecretVerificationRequest>, headers: Headers) -> EmptyResult {
|
||||||
let data: VerifyPasswordData = data.into_inner().data;
|
let data: SecretVerificationRequest = data.into_inner().data;
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
if !user.check_valid_password(&data.MasterPasswordHash) {
|
if !user.check_valid_password(&data.MasterPasswordHash) {
|
||||||
|
@ -661,3 +665,32 @@ fn verify_password(data: JsonUpcase<VerifyPasswordData>, headers: Headers) -> Em
|
||||||
|
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -43,6 +43,13 @@ fn login(data: Form<ConnectData>, conn: DbConn, ip: ClientIp) -> JsonResult {
|
||||||
|
|
||||||
_password_login(data, conn, &ip)
|
_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),
|
t => err!("Invalid type", t),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,13 +61,15 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
|
||||||
// Get device by refresh token
|
// Get device by refresh token
|
||||||
let mut device = Device::find_by_refresh_token(&token, &conn).map_res("Invalid 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 user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
||||||
let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
|
let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
|
||||||
|
let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec);
|
||||||
let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
|
|
||||||
|
|
||||||
device.save(&conn)?;
|
device.save(&conn)?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"expires_in": expires_in,
|
"expires_in": expires_in,
|
||||||
|
@ -72,7 +81,7 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
|
||||||
"Kdf": user.client_kdf_type,
|
"Kdf": user.client_kdf_type,
|
||||||
"KdfIterations": user.client_kdf_iter,
|
"KdfIterations": user.client_kdf_iter,
|
||||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
"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,
|
"unofficialServer": true,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
@ -83,6 +92,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
|
||||||
if scope != "api offline_access" {
|
if scope != "api offline_access" {
|
||||||
err!("Scope not supported")
|
err!("Scope not supported")
|
||||||
}
|
}
|
||||||
|
let scope_vec = vec!["api".into(), "offline_access".into()];
|
||||||
|
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
@ -150,8 +160,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
|
let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn);
|
||||||
|
let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec);
|
||||||
let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
|
|
||||||
device.save(&conn)?;
|
device.save(&conn)?;
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
|
@ -166,7 +175,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
|
||||||
"Kdf": user.client_kdf_type,
|
"Kdf": user.client_kdf_type,
|
||||||
"KdfIterations": user.client_kdf_iter,
|
"KdfIterations": user.client_kdf_iter,
|
||||||
"ResetMasterPassword": false,// TODO: Same as above
|
"ResetMasterPassword": false,// TODO: Same as above
|
||||||
"scope": "api offline_access",
|
"scope": scope,
|
||||||
"unofficialServer": true,
|
"unofficialServer": true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -178,6 +187,76 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
|
||||||
Ok(Json(result))
|
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")
|
||||||
|
}
|
||||||
|
let scope_vec = vec!["api".into()];
|
||||||
|
|
||||||
|
// 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, 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",
|
||||||
|
"Key": user.akey,
|
||||||
|
"PrivateKey": user.private_key,
|
||||||
|
|
||||||
|
"Kdf": user.client_kdf_type,
|
||||||
|
"KdfIterations": user.client_kdf_iter,
|
||||||
|
"ResetMasterPassword": false, // TODO: Same as above
|
||||||
|
"scope": scope,
|
||||||
|
"unofficialServer": true,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
/// Retrieves an existing device or creates a new device from ConnectData and the User
|
/// 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) {
|
fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> (Device, bool) {
|
||||||
// On iOS, device_type sends "iOS", on others it sends a number
|
// On iOS, device_type sends "iOS", on others it sends a number
|
||||||
|
@ -374,17 +453,20 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
|
||||||
Ok(result)
|
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
|
// https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct ConnectData {
|
struct ConnectData {
|
||||||
grant_type: String, // refresh_token, password
|
// refresh_token, password, client_credentials (API key)
|
||||||
|
grant_type: String,
|
||||||
|
|
||||||
// Needed for grant_type="refresh_token"
|
// Needed for grant_type="refresh_token"
|
||||||
refresh_token: Option<String>,
|
refresh_token: Option<String>,
|
||||||
|
|
||||||
// Needed for grant_type="password"
|
// Needed for grant_type = "password" | "client_credentials"
|
||||||
client_id: Option<String>, // web, cli, desktop, browser, mobile
|
client_id: Option<String>, // web, cli, desktop, browser, mobile
|
||||||
|
client_secret: Option<String>, // API key login (cli only)
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
scope: Option<String>,
|
scope: Option<String>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
|
@ -414,6 +496,7 @@ impl<'f> FromForm<'f> for ConnectData {
|
||||||
"granttype" => form.grant_type = value,
|
"granttype" => form.grant_type = value,
|
||||||
"refreshtoken" => form.refresh_token = Some(value),
|
"refreshtoken" => form.refresh_token = Some(value),
|
||||||
"clientid" => form.client_id = Some(value),
|
"clientid" => form.client_id = Some(value),
|
||||||
|
"clientsecret" => form.client_secret = Some(value),
|
||||||
"password" => form.password = Some(value),
|
"password" => form.password = Some(value),
|
||||||
"scope" => form.scope = Some(value),
|
"scope" => form.scope = Some(value),
|
||||||
"username" => form.username = Some(value),
|
"username" => form.username = Some(value),
|
||||||
|
|
|
@ -51,6 +51,28 @@ pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
|
||||||
array
|
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 {
|
pub fn generate_id(num_bytes: usize) -> String {
|
||||||
HEXLOWER.encode(&get_random(vec![0; num_bytes]))
|
HEXLOWER.encode(&get_random(vec![0; num_bytes]))
|
||||||
}
|
}
|
||||||
|
@ -84,6 +106,12 @@ pub fn generate_token(token_size: u32) -> Result<String, Error> {
|
||||||
Ok(token)
|
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
|
// Constant time compare
|
||||||
//
|
//
|
||||||
|
|
|
@ -60,7 +60,12 @@ impl Device {
|
||||||
self.twofactor_remember = None;
|
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 there is no refresh token, we create one
|
||||||
if self.refresh_token.is_empty() {
|
if self.refresh_token.is_empty() {
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
|
@ -98,7 +103,7 @@ impl Device {
|
||||||
|
|
||||||
sstamp: user.security_stamp.to_string(),
|
sstamp: user.security_stamp.to_string(),
|
||||||
device: self.uuid.to_string(),
|
device: self.uuid.to_string(),
|
||||||
scope: vec!["api".into(), "offline_access".into()],
|
scope,
|
||||||
amr: vec!["Application".into()],
|
amr: vec!["Application".into()],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -44,8 +44,9 @@ db_object! {
|
||||||
|
|
||||||
pub client_kdf_type: i32,
|
pub client_kdf_type: i32,
|
||||||
pub client_kdf_iter: i32,
|
pub client_kdf_iter: i32,
|
||||||
}
|
|
||||||
|
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
#[table_name = "invitations"]
|
#[table_name = "invitations"]
|
||||||
|
@ -110,6 +111,8 @@ impl User {
|
||||||
|
|
||||||
client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT,
|
client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT,
|
||||||
client_kdf_iter: Self::CLIENT_KDF_ITER_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
|
/// Set the password hash generated
|
||||||
/// And resets the security_stamp. Based upon the allow_next_route the security_stamp will be different.
|
/// And resets the security_stamp. Based upon the allow_next_route the security_stamp will be different.
|
||||||
///
|
///
|
||||||
|
|
|
@ -178,6 +178,7 @@ table! {
|
||||||
excluded_globals -> Text,
|
excluded_globals -> Text,
|
||||||
client_kdf_type -> Integer,
|
client_kdf_type -> Integer,
|
||||||
client_kdf_iter -> Integer,
|
client_kdf_iter -> Integer,
|
||||||
|
api_key -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -178,6 +178,7 @@ table! {
|
||||||
excluded_globals -> Text,
|
excluded_globals -> Text,
|
||||||
client_kdf_type -> Integer,
|
client_kdf_type -> Integer,
|
||||||
client_kdf_iter -> Integer,
|
client_kdf_iter -> Integer,
|
||||||
|
api_key -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -178,6 +178,7 @@ table! {
|
||||||
excluded_globals -> Text,
|
excluded_globals -> Text,
|
||||||
client_kdf_type -> Integer,
|
client_kdf_type -> Integer,
|
||||||
client_kdf_iter -> Integer,
|
client_kdf_iter -> Integer,
|
||||||
|
api_key -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Laden …
In neuem Issue referenzieren