Compare commits

...
3 Commits
Author SHA1 Message Date
Daniel García f357032ee3 List attachments in sync with a stable URL
Sync and the cipher responses list each attachment with an unsigned URL of
upstream's form, which no longer changes on every sync nor costs a presign per
attachment on S3, and which isn't served without a token. The attachment routes
sign it: the cipher route and its admin variant, and the emergency access view's
route the web vault downloads through, which is added.
2026-11-14 14:24:22 +01:00
Daniel García 67fc665edf Check membership for the organization public key in the handler
The public key routes serve the placeholder SSO organization's key to users
without an organization, so they couldn't require the member guard, but it
still ran for every request: it logged a membership error for each legitimate
v2 JIT password setup and authenticated the request twice. The routes now
authenticate once, return the placeholder key first, and check the membership
themselves, with the same rule as the guard.
2026-11-14 14:24:22 +01:00
Daniel García 85e87dd340 Add the account recovery V2 upgrade routes
GET /organizations/{orgId}/users/keys/pending-v2-upgrades lists, a page at a
time, the memberships whose account recovery key still wraps the member's V1
user key after an upgrade to V2 encryption, with the upgrade token the admin
re-wraps it from. POST .../v2-upgrades stores the re-wrapped keys in one
transaction, or unenrolls the members whose token holds no usable key. Only
Owners and Admins, an Admin never for Owners, and a membership that changed
since the read is skipped. GET /organizations/{orgId}/private-key gives the
organization's private key these need, to everyone with Manage users as
upstream. The SDK's encrypted migrator calls all three.
2026-11-14 14:24:22 +01:00
8 changed files with 387 additions and 37 deletions

No files matched your search

+3 -2
View File
@@ -1244,10 +1244,11 @@ struct CommonUnlockData {
v2_upgrade_token: Option<V2UpgradeTokenData>,
}
/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us.
/// Lets other sessions with the v1 user key unwrap the v2 one after an upgrade. Opaque to us. Stored on the user and
/// the memberships as this serializes, and read back with it (the account recovery V2 upgrade routes).
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct V2UpgradeTokenData {
pub(crate) struct V2UpgradeTokenData {
wrapped_user_key1: String,
wrapped_user_key2: String,
}
+3 -1
View File
@@ -1581,7 +1581,9 @@ async fn get_attachment_data(
}
match Attachment::find_by_id(&attachment_id, &conn).await {
Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)),
Some(attachment) if cipher_id == attachment.cipher_uuid => {
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
}
Some(_) => err!("Attachment doesn't belong to cipher"),
None => err!("Attachment doesn't exist"),
}
+44 -2
View File
@@ -15,8 +15,9 @@ use crate::{
db::{
DbConn, DbPool,
models::{
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
Attachment, AttachmentId, Cipher, CipherId, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess,
EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType,
OrgPolicy, TwoFactor, User, UserId,
},
},
mail,
@@ -42,6 +43,7 @@ pub fn routes() -> Vec<Route> {
takeover_emergency_access,
password_emergency_access,
view_emergency_access,
get_emergency_access_attachment,
policies_emergency_access,
]
}
@@ -616,6 +618,46 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con
})))
}
/// An attachment of the grantor's for a grantee with approved view access, with a `url` to download it from: the view
/// lists the attachments with one that only names the file. Only the grantor's own ciphers, the ones the view lists.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Auth/Controllers/EmergencyAccessController.cs#L219-L226>
#[get("/emergency-access/<emer_id>/<cipher_id>/attachment/<attachment_id>")]
async fn get_emergency_access_attachment(
emer_id: EmergencyAccessId,
cipher_id: CipherId,
attachment_id: AttachmentId,
headers: Headers,
conn: DbConn,
) -> JsonResult {
check_emergency_access_enabled()?;
let Some(emergency_access) =
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await
else {
err!("Emergency access not valid.")
};
if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) {
err!("Emergency access not valid.")
}
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
err!("Cipher doesn't exist")
};
if cipher.user_uuid.as_ref() != Some(&emergency_access.grantor_uuid) || cipher.organization_uuid.is_some() {
err!("Cipher is not accessible")
}
match Attachment::find_by_id(&attachment_id, &conn).await {
Some(attachment) if attachment.cipher_uuid == cipher_id => {
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
}
Some(_) => err!("Attachment doesn't belong to cipher"),
None => err!("Attachment doesn't exist"),
}
}
#[post("/emergency-access/<emer_id>/takeover")]
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
check_emergency_access_enabled()?;
+178 -22
View File
@@ -21,7 +21,7 @@ use crate::{
models::{
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
Invitation, KeyId, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
custom_roles_enabled,
},
@@ -31,7 +31,7 @@ use crate::{
util::{NumberOrString, convert_json_key_lcase_first},
};
use super::accounts::{AuthenticationData, UnlockData};
use super::accounts::{AuthenticationData, UnlockData, V2UpgradeTokenData};
pub fn routes() -> Vec<Route> {
routes![
@@ -94,6 +94,9 @@ pub fn routes() -> Vec<Route> {
get_group_members,
put_reset_password_enrollment,
get_reset_password_details,
get_pending_v2_upgrades,
post_v2_upgrades,
get_organization_private_key,
put_recover_account,
get_org_export,
post_api_key,
@@ -3188,12 +3191,7 @@ struct OrganizationUserRecoverAccountRequest {
// But the clients do not seem to use this at all
// Just add it here in case they will
#[get("/organizations/<org_id>/public-key")]
async fn get_organization_public_key(
org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
async fn get_organization_public_key(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult {
// SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
return Ok(Json(json!({
@@ -3201,14 +3199,16 @@ async fn get_organization_public_key(
"publicKey": fake_sso_org_public_key().await?,
})));
}
let member = match member {
Ok(member) => member,
// The guard already logged it
Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)),
};
if org_id != member.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
// Only for members, as `OrgMemberHeaders` checks it, but here, so that the fake org above neither logs a
// membership error nor authenticates the request twice: a known role, and not revoked (invited members are,
// while joining)
let is_member = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await.is_some_and(|member| {
MembershipType::from_i32(member.atype).is_some()
&& MembershipStatus::from_i32(member.status).is_some_and(|status| status != MembershipStatus::Revoked)
});
if !is_member {
err_code!("You need to be a Member of the Organization to call this endpoint", Status::Unauthorized.code)
}
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
err!("Organization not found")
@@ -3237,13 +3237,8 @@ async fn fake_sso_org_public_key() -> ApiResult<String> {
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
#[get("/organizations/<org_id>/keys")]
async fn get_organization_keys(
org_id: OrganizationId,
headers: Headers,
member: Result<OrgMemberHeaders, &'static str>,
conn: DbConn,
) -> JsonResult {
get_organization_public_key(org_id, headers, member, conn).await
async fn get_organization_keys(org_id: OrganizationId, headers: Headers, conn: DbConn) -> JsonResult {
get_organization_public_key(org_id, headers, conn).await
}
// Will allow to reset 2FA too
@@ -3364,6 +3359,167 @@ async fn recover_account(
Ok(())
}
#[derive(FromForm)]
struct PendingV2UpgradesQuery {
#[field(name = "continuationToken")]
continuation_token: Option<MembershipId>,
}
/// The memberships whose account recovery key an Owner or Admin still has to re-wrap with the member's V2 user key,
/// one page at a time: the client posts each page to `v2-upgrades` and reads the next one with the continuation
/// token, until it is null. An Admin gets every member but the Owners, whose key material only an Owner can read.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L46-L83>
#[get("/organizations/<org_id>/users/keys/pending-v2-upgrades?<query..>")]
async fn get_pending_v2_upgrades(
org_id: OrganizationId,
query: PendingV2UpgradesQuery,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let include_owners = headers.membership_type == MembershipType::Owner;
let pending =
Membership::find_pending_v2_upgrades(&org_id, include_owners, query.continuation_token.as_ref(), &conn).await;
let continuation_token = if i64::try_from(pending.len()).is_ok_and(|len| len == Membership::MAX_V2_UPGRADES) {
pending.last().map(|upgrade| upgrade.member_id.clone())
} else {
None
};
let data: Vec<Value> = pending
.into_iter()
.map(|upgrade| {
json!({
"object": "organizationUserPendingV2Upgrade",
"organizationUserId": upgrade.member_id,
"userKeyId": upgrade.user_key_id,
"accountRecoveryKey": upgrade.account_recovery_key,
// Null when the stored token can't be read: it holds no usable key, so the admin unenrolls the member
"v2UpgradeToken": serde_json::from_str::<V2UpgradeTokenData>(&upgrade.v2_upgrade_token).ok(),
})
})
.collect();
Ok(Json(json!({
"object": "list",
"data": data,
"continuationToken": continuation_token,
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct V2UpgradesData {
upgrades: Vec<V2UpgradeData>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct V2UpgradeData {
organization_user_id: MembershipId,
/// The id of the user key the new account recovery key wraps, which must still be the member's
user_key_id: KeyId,
/// The member's V2 user key wrapped with the organization's public key, or none to unenroll the member (when the
/// upgrade token holds no usable key)
account_recovery_key: Option<String>,
}
/// Stores the account recovery keys an Owner or Admin re-wrapped with members' V2 user keys, or unenrolls the members
/// whose entry has no key. Every member named must be one the caller can recover in this organization; a membership
/// that changed since the read (rotated again, unenrolled, upgraded already, promoted) is skipped and stays pending.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/KeyManagement/Controllers/OrganizationUsersKeysController.cs#L85-L108>
#[post("/organizations/<org_id>/users/keys/v2-upgrades", data = "<data>")]
async fn post_v2_upgrades(
org_id: OrganizationId,
data: Json<V2UpgradesData>,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let upgrades = data.into_inner().upgrades;
if upgrades.is_empty() || i64::try_from(upgrades.len()).map_or(true, |len| len > Membership::MAX_V2_UPGRADES) {
err!(format!("The field Upgrades must contain between 1 and {} entries.", Membership::MAX_V2_UPGRADES))
}
let mut seen = HashSet::new();
if !upgrades.iter().all(|upgrade| seen.insert(&upgrade.organization_user_id)) {
err!("An organization user can only be upgraded once per request.")
}
// A key or its absence: the token the key could be rebuilt from is consumed with the write, so a malformed key is
// refused rather than stored
if upgrades
.iter()
.filter_map(|upgrade| upgrade.account_recovery_key.as_deref())
.any(|key| !crate::util::is_valid_rsa_encrypted_string(key))
{
err!("The field AccountRecoveryKey is not a valid encrypted string.")
}
let include_owners = headers.membership_type == MembershipType::Owner;
let mut members = Vec::with_capacity(upgrades.len());
for upgrade in &upgrades {
match Membership::find_by_uuid_and_org(&upgrade.organization_user_id, &org_id, &conn).await {
Some(member) if include_owners || member.atype != MembershipType::Owner => members.push(member),
_ => err_code!("Resource not found.", Status::NotFound.code),
}
}
// Every write in one transaction, as upstream's single statement, so a failure leaves the whole batch pending
let applied = conn
.transaction(async || {
let mut applied = Vec::with_capacity(members.len());
for (member, upgrade) in members.iter().zip(&upgrades) {
let key = upgrade.account_recovery_key.as_deref();
if member.apply_v2_upgrade(include_owners, &upgrade.user_key_id, key, &conn).await? {
applied.push((member, upgrade));
}
}
Ok(applied)
})
.await?;
for (member, upgrade) in applied {
User::update_uuid_revision(&member.user_uuid, &conn).await;
// A re-wrapped key isn't logged: the member stays enrolled
if upgrade.account_recovery_key.is_none() {
log_event(
EventType::OrganizationUserResetPasswordWithdraw,
&upgrade.organization_user_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
}
}
Ok(())
}
/// The organization's private key, wrapped with the organization key: the account recovery V2 upgrade (the SDK's
/// encrypted migrator) needs it to unwrap the members' V1 user keys. Open to everyone with Manage users, as upstream.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L473-L484>
#[get("/organizations/<org_id>/private-key")]
async fn get_organization_private_key(org_id: OrganizationId, headers: ManageUsersHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
err_code!("Resource not found.", Status::NotFound.code)
};
Ok(Json(json!({
"object": "organizationPrivateKey",
"privateKey": org.private_key,
})))
}
#[get("/organizations/<org_id>/users/<member_id>/reset-password-details")]
async fn get_reset_password_details(
org_id: OrganizationId,
+21 -4
View File
@@ -65,16 +65,33 @@ impl Attachment {
}
}
pub async fn to_json(&self, host: &str) -> Result<Value, crate::Error> {
Ok(json!({
/// The attachment as sync and the cipher responses list it. Its `url` is a leftover from the old download flow.
/// Clients get a signed URL from the attachment routes (`to_json_for_download`) instead, only the web vault and the
/// CLI read this one, as a fallback when those routes answer 404.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Models/Response/AttachmentResponseModel.cs#L24-L29>
pub fn to_json(&self, host: &str) -> Value {
self.json_with_url(&format!("{host}/attachments/{}/{}", self.cipher_uuid, self.id))
}
/// The attachment with a `url` the client can download it from for a short while, for the attachment routes
/// (`GET /ciphers/{id}/attachment/{attachmentId}`, its admin variant, the emergency access one).
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Controllers/CiphersController.cs#L1699-L1717>
pub async fn to_json_for_download(&self, host: &str) -> Result<Value, crate::Error> {
Ok(self.json_with_url(&self.get_url(host).await?))
}
fn json_with_url(&self, url: &str) -> Value {
json!({
"id": self.id,
"url": self.get_url(host).await?,
"url": url,
"fileName": self.file_name,
"size": self.file_size.to_string(),
"sizeName": crate::util::get_display_size(self.file_size),
"key": self.akey,
"object": "attachment"
}))
})
}
}
+2 -2
View File
@@ -270,7 +270,7 @@ impl Cipher {
{
let mut attachments_json_vec = vec![];
for attachment in attachments {
attachments_json_vec.push(attachment.to_json(host).await?);
attachments_json_vec.push(attachment.to_json(host));
}
attachments_json = Value::Array(attachments_json_vec);
}
@@ -279,7 +279,7 @@ impl Cipher {
if !attachments.is_empty() {
let mut attachments_json_vec = vec![];
for attachment in attachments {
attachments_json_vec.push(attachment.to_json(host).await?);
attachments_json_vec.push(attachment.to_json(host));
}
attachments_json = Value::Array(attachments_json_vec);
}
+100 -4
View File
@@ -19,15 +19,27 @@ use crate::{
organizations, users, users_collections, users_organizations,
},
},
error::MapResult,
error::{Error, MapResult},
};
use macros::UuidFromParam;
use super::{
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
TwoFactor, User, UserId,
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, KeyId, OrgPolicy,
OrgPolicyType, TwoFactor, User, UserId,
};
/// A membership whose account recovery key still wraps the member's V1 user key: an upgrade to V2 encryption can't
/// re-wrap it (that needs the member to trust the organization's key), so it left the token the admin re-wraps it from.
#[derive(Queryable)]
pub struct PendingV2Upgrade {
pub member_id: MembershipId,
/// The member's current user key id, which the re-wrapped key must be sent back with
pub user_key_id: KeyId,
/// The V1 user key, wrapped with the organization's public key
pub account_recovery_key: String,
pub v2_upgrade_token: String,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = organizations)]
#[diesel(treat_none_as_null = true)]
@@ -291,7 +303,7 @@ impl CustomPermission {
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
/// permission this version does not enforce is rejected as well. The clients also send permissions
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, crate::Error> {
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, Error> {
let mut granted = HashSet::new();
for permission in CustomPermission::ALL {
match permissions.get(permission.json_key()) {
@@ -1256,6 +1268,90 @@ impl Membership {
.await
}
/// The most account recovery V2 upgrades one request reads or writes, like upstream's
/// `OrganizationUserV2UpgradesRequestModel.MaxUpgrades`.
pub const MAX_V2_UPGRADES: i64 = 100;
/// The next page of memberships of this organization whose account recovery key the admin still has to re-wrap
/// with the member's V2 user key: enrolled, carrying an upgrade token, of a user with a key id (the re-wrapped key
/// is checked against it), and Owners only for an Owner. Ordered by id, after `after`, at most `MAX_V2_UPGRADES`.
pub async fn find_pending_v2_upgrades(
org_uuid: &OrganizationId,
include_owners: bool,
after: Option<&MembershipId>,
conn: &DbConn,
) -> Vec<PendingV2Upgrade> {
conn.run(move |conn| {
let mut query = users_organizations::table
.inner_join(users::table)
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::v2_upgrade_token.is_not_null())
.filter(users_organizations::reset_password_key.is_not_null())
.filter(users::key_id.is_not_null())
.into_boxed();
if !include_owners {
query = query.filter(users_organizations::atype.ne(MembershipType::Owner as i32));
}
if let Some(after) = after {
query = query.filter(users_organizations::uuid.gt(after));
}
query
.order(users_organizations::uuid)
.limit(Self::MAX_V2_UPGRADES)
// The filters above make sure these are set
.select((
users_organizations::uuid,
users::key_id.assume_not_null(),
users_organizations::reset_password_key.assume_not_null(),
users_organizations::v2_upgrade_token.assume_not_null(),
))
.load(conn)
.unwrap_or_default()
})
.await
}
/// Replaces the account recovery key of a member who upgraded to V2 encryption with `account_recovery_key`, or
/// unenrolls them when it is `None`, and consumes the upgrade token, so it can't be replayed. Only while the
/// membership is still enrolled, carrying the token, not an Owner unless `include_owners`, and its user's key id is
/// still `user_key_id` (the key the new recovery key wraps). Returns whether it applied, otherwise the membership
/// stays pending. It runs inside a transaction, so it leaves the user's revision to the caller.
pub async fn apply_v2_upgrade(
&self,
include_owners: bool,
user_key_id: &KeyId,
account_recovery_key: Option<&str>,
conn: &DbConn,
) -> Result<bool, Error> {
// An empty list excludes no one
let excluded = if include_owners {
vec![]
} else {
vec![MembershipType::Owner as i32]
};
let rows: usize = conn
.run(move |conn| {
diesel::update(
users_organizations::table
.filter(users_organizations::uuid.eq(&self.uuid))
.filter(users_organizations::v2_upgrade_token.is_not_null())
.filter(users_organizations::reset_password_key.is_not_null())
.filter(users_organizations::atype.ne_all(excluded))
.filter(diesel::dsl::exists(
users::table.filter(users::uuid.eq(&self.user_uuid)).filter(users::key_id.eq(user_key_id)),
)),
)
.set((
users_organizations::reset_password_key.eq(account_recovery_key),
users_organizations::v2_upgrade_token.eq(None::<String>),
))
.execute(conn)
.map_res("Error saving membership")
})
.await?;
Ok(rows > 0)
}
pub async fn count_confirmed_by_org_and_type(
org_uuid: &OrganizationId,
atype: MembershipType,
+36
View File
@@ -995,6 +995,22 @@ pub async fn save_temp_file(
Ok(())
}
/// Whether `value` has the form of an RSA-encrypted string (types 3 to 6), the form of the account recovery key: the
/// type and its number of `|` pieces like the clients' `UnsignedSharedKey` parser, and each piece canonical, padded
/// base64, which is what the clients send.
///
/// Ref: <https://github.com/bitwarden/sdk-internal/blob/6238472451e4fa7b5c592b4b24a84e950a258957/crates/bitwarden-crypto/src/enc_string/asymmetric.rs#L85-L119>
pub fn is_valid_rsa_encrypted_string(value: &str) -> bool {
let (pieces, rest) = match value.split_once('.') {
Some(("3" | "4", rest)) => (1, rest),
Some(("5" | "6", rest)) => (2, rest),
_ => return false,
};
let parts: Vec<&str> = rest.split('|').collect();
parts.len() == pieces
&& parts.iter().all(|part| !part.is_empty() && data_encoding::BASE64.decode(part.as_bytes()).is_ok())
}
/// These are some tests to check that the implementations match
/// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17
/// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct
@@ -1073,3 +1089,23 @@ mod query_log_tests {
assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}="));
}
}
#[cfg(test)]
mod encrypted_string_tests {
use super::is_valid_rsa_encrypted_string as valid;
#[test]
fn rsa_encrypted_strings_are_validated() {
assert!(valid("3.YWJjZA=="));
assert!(valid("4.YWJjZA=="));
assert!(valid("6.YWJjZA==|YWJjZA=="));
assert!(!valid("2.YWJjZA==|YWJjZA==|YWJjZA=="), "symmetric type");
assert!(!valid("YWJjZA=="), "no type");
assert!(!valid("4.not base64!"));
assert!(!valid("4.YWJjZA==|YWJjZA=="), "one piece too many");
assert!(!valid("5.YWJjZA=="), "one piece too few");
assert!(!valid("4.YWJjZB=="), "padding bits set");
assert!(!valid("4."), "empty piece");
assert!(!valid(""));
}
}