Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/rs-platform-wallet-storage/SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,13 +400,24 @@ unwrapped copy is allocated.
symlink cannot hide an unsafe ancestor behind a safe-looking one. An
ancestor is refused at `open` with `SecretStoreError::InsecureParentDir`
when it is group/other writable (`mode & 0o022`) WITHOUT the sticky bit,
or when it is owned by neither the effective user nor a root identity:
or when it is owned by neither the effective user nor a root identity
(both rules make exceptions for the Android identities trusted below):
directory write access governs rename/replace of the vault, and an
untrusted owner can grant itself that access at will (the A1 guarantee
depends on both). A sticky writable directory such as `/tmp` (`0o1777`)
is accepted — the sticky bit is what stops one user replacing another's
entries. A read-only group-accessible ancestor (`0o750`) is accepted too
— it only leaks filenames, never the 0600-protected vault contents. The
— it only leaks filenames, never the 0600-protected vault contents. On
Android, `system` (`AID_SYSTEM`, 1000) is trusted as well, both as an
Comment thread
coderabbitai[bot] marked this conversation as resolved.
owner and as the group of a group-writable ancestor: Android creates every
app's private storage beneath `system:system` `0771` directories (`/data`,
`/data/user/0`, `/data/data`), and no app can run as or join `system`.
The app's own per-app group (gid equal to its uid) is trusted as the group
of a group-writable ancestor too, because Android creates the app's
`files` and `databases` directories `0771` in that group. Other-writable
ancestors and ancestors writable by any other group are still refused
there. The trusted sets are `PLATFORM_IDS` and `trusted_groups` in
`src/parent_permissions.rs`, empty on every other target. The
walk is Unix-only; Windows ACLs are not inspected (issue #3754).
Each secret is capped at `MAX_SECRET_LEN` (8176 B) at the write
boundary — still ~30× any mnemonic/seed/xpriv — so a single oversized
Expand Down
134 changes: 123 additions & 11 deletions packages/rs-platform-wallet-storage/src/parent_permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,60 @@ pub(crate) fn insecure_ancestor_message(
}
}

/// Platform identities trusted as ancestor owners and as the group of a
/// group-writable ancestor, beyond the current user and root.
///
/// Android creates every app's private storage beneath `/data`, `/data/user`,
/// `/data/user/0` and `/data/data`, all owned `system:system` (`AID_SYSTEM`,
/// 1000) and mostly `0771`. No app-private path can pass the walk without
/// trusting that identity. `AID_SYSTEM` is the platform itself — it already
/// administers every app's data — and no third-party app can run as it or join
/// its group, so a `system`-group-writable ancestor is not replaceable by
/// anything the check defends against.
#[cfg(target_os = "android")]
const PLATFORM_IDS: &[u32] = &[1000];
#[cfg(all(unix, not(target_os = "android")))]
const PLATFORM_IDS: &[u32] = &[];

#[cfg(unix)]
fn trusted_owner(owner: u32, current_uid: u32, root_uid: u32) -> bool {
owner == current_uid || owner == 0 || owner == root_uid
fn trusted_owner(owner: u32, current_uid: u32, root_uid: u32, platform_ids: &[u32]) -> bool {
owner == current_uid || owner == 0 || owner == root_uid || platform_ids.contains(&owner)
}

/// Groups whose write access to an ancestor is not a replacement risk.
///
/// On Android this is `PLATFORM_IDS` plus the app's own per-app group. Android
/// gives every app a gid equal to its uid, and `Context.getFilesDir()`,
/// `getDatabasesDir()` and friends create the app's own directories `0771`
/// in that group, so `<dataDir>/files` is group-writable by design. No other
/// app can be a member of that group. Everywhere else the set is empty: a
/// desktop user's primary group (macOS `staff`, for one) is commonly shared.
///
/// The group is keyed on the uid, not `getegid()`. The two are equal in every
/// app process, but only the uid carries the guarantee: Android reserves the
/// gid numbered like an app's uid for that app alone, while the effective gid
/// is whatever group the process was started with, which outside an app
/// process can be one that other processes share.
#[cfg(target_os = "android")]
fn trusted_groups(current_uid: u32) -> Vec<u32> {
let mut groups = PLATFORM_IDS.to_vec();
groups.push(current_uid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the files directory's group comes from the creating process's egid, so pushing libc::getegid() here (next to the existing geteuid() call) would state that directly instead of relying on Android's gid == uid convention. Same result on the layout we ran: files was 10213:10213 for app uid 10213.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @j0ntz - we will review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for testing this inside the Edge app. That was the check we couldn't run ourselves, and it's now in the PR description.

On getegid(): we're keeping the uid. You're right that the folder's group comes from the creating process's effective gid, and in every app process the two are the same number, which is why your run came out identical. The difference is what each one guarantees. Android reserves the gid numbered like an app's uid for that app alone, so no other process can be a member of it, and that's what makes trusting group-write safe. The effective gid is just whatever group the process was started with. Outside a normal app process it can be a group that other processes share, and trusting it would trust them too. We've added a note to the trusted_groups doc comment saying so.

A physical phone is still the open gap. If you get to one before we do, a result here would help.

groups
}
Comment on lines +101 to +105

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Test the Android trusted-group constructor directly

The Android behavior tests pass hand-written slices such as [1000, 10_150], while the only test that calls the real trusted_groups helper is disabled on Android. As a result, Android-targeted tests would not pin that the production helper includes both PLATFORM_IDS and the current app UID. Add an Android-gated unit test for the actual helper and retain the predicate tests.

Suggested change
fn trusted_groups(current_uid: u32) -> Vec<u32> {
let mut groups = PLATFORM_IDS.to_vec();
groups.push(current_uid);
groups
}
#[cfg(target_os = "android")]
#[test]
fn should_build_android_trusted_groups_from_platform_and_app_uid() {
assert_eq!(super::PLATFORM_IDS, &[1000]);
assert_eq!(super::trusted_groups(10_150), vec![1000, 10_150]);
}

source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality); gpt-6.1-sol (phase2-reviewer: general, architecture-layering, rust-quality, security-auditor)

#[cfg(all(unix, not(target_os = "android")))]
fn trusted_groups(_current_uid: u32) -> Vec<u32> {
PLATFORM_IDS.to_vec()
}

/// Whether an identity outside the trusted set can replace entries in a
/// directory with this mode and group: other-writable, or group-writable for a
/// group outside `trusted_groups`, in both cases without the sticky bit.
#[cfg(unix)]
fn writable_without_sticky(mode: u32, gid: u32, trusted_groups: &[u32]) -> bool {
if mode & 0o1000 != 0 {
return false;
}
mode & 0o002 != 0 || (mode & 0o020 != 0 && !trusted_groups.contains(&gid))
}

#[cfg(unix)]
Expand Down Expand Up @@ -98,7 +149,7 @@ fn reject_ancestor(
) -> Result<(), ParentPermissionsError> {
let reason = if writable_without_sticky {
InsecureAncestor::WritableWithoutSticky { mode }
} else if !trusted_owner(uid, current_uid, root_uid) {
} else if !trusted_owner(uid, current_uid, root_uid, PLATFORM_IDS) {
InsecureAncestor::UntrustedOwner { uid, current_uid }
} else {
return Ok(());
Expand All @@ -114,6 +165,7 @@ pub(crate) fn check_parent_perms(parent: &Path) -> Result<(), ParentPermissionsE
use std::os::unix::fs::MetadataExt;

let current_uid = effective_uid();
let trusted_groups = trusted_groups(current_uid);
let root_uid = std::fs::metadata("/")
.map_err(ParentPermissionsError::Io)?
.uid();
Expand All @@ -129,8 +181,8 @@ pub(crate) fn check_parent_perms(parent: &Path) -> Result<(), ParentPermissionsE
for ancestor in absolute.ancestors() {
let meta = std::fs::symlink_metadata(ancestor).map_err(ParentPermissionsError::Io)?;
let mode = meta.mode() & 0o7777;
let writable_without_sticky =
!meta.file_type().is_symlink() && mode & 0o022 != 0 && mode & 0o1000 == 0;
let writable_without_sticky = !meta.file_type().is_symlink()
&& writable_without_sticky(mode, meta.gid(), &trusted_groups);
Comment on lines 168 to +185

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Avoid shadowing helper names with local bindings

check_parent_perms binds trusted_groups to the result of a function with the same name and binds writable_without_sticky to a Boolean returned by a function with the same name. Rust resolves the initializer correctly, so this is not a behavioral defect, but the shadowing makes the code harder to scan and creates a trap for future edits. Rename the locals to names such as allowed_groups and is_writable_without_sticky, or rename the helpers.

source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality)

reject_ancestor(
ancestor,
meta.uid(),
Expand All @@ -143,7 +195,7 @@ pub(crate) fn check_parent_perms(parent: &Path) -> Result<(), ParentPermissionsE
for ancestor in canonical.ancestors() {
let meta = std::fs::metadata(ancestor).map_err(ParentPermissionsError::Io)?;
let mode = meta.mode() & 0o7777;
let writable_without_sticky = mode & 0o022 != 0 && mode & 0o1000 == 0;
let writable_without_sticky = writable_without_sticky(mode, meta.gid(), &trusted_groups);
reject_ancestor(
ancestor,
meta.uid(),
Expand All @@ -165,13 +217,73 @@ pub(crate) fn check_parent_perms(_parent: &Path) -> Result<(), ParentPermissions

#[cfg(all(test, unix))]
mod tests {
use super::trusted_owner;
use super::{trusted_owner, writable_without_sticky};

const ANDROID_SYSTEM: &[u32] = &[1000];

#[test]
fn trusted_owner_accepts_current_and_root_identities_only() {
assert!(trusted_owner(1000, 1000, 65_534));
assert!(trusted_owner(0, 1000, 65_534));
assert!(trusted_owner(65_534, 1000, 65_534));
assert!(!trusted_owner(2000, 1000, 65_534));
assert!(trusted_owner(1000, 1000, 65_534, &[]));
assert!(trusted_owner(0, 1000, 65_534, &[]));
assert!(trusted_owner(65_534, 1000, 65_534, &[]));
assert!(!trusted_owner(2000, 1000, 65_534, &[]));
}

#[test]
fn should_trust_android_system_owner_only_when_it_is_a_platform_identity() {
// App uid 10123 under /data/user/0 (owner system, 1000).
assert!(!trusted_owner(1000, 10_123, 0, &[]));
assert!(trusted_owner(1000, 10_123, 0, ANDROID_SYSTEM));
// Another app's uid stays untrusted.
assert!(!trusted_owner(10_456, 10_123, 0, ANDROID_SYSTEM));
}

#[test]
fn should_reject_group_write_unless_group_is_a_platform_identity() {
// `/data` on Android: system:system 0771.
assert!(writable_without_sticky(0o771, 1000, &[]));
assert!(!writable_without_sticky(0o771, 1000, ANDROID_SYSTEM));
// Group-writable for any other group is still replaceable.
assert!(writable_without_sticky(0o775, 1015, ANDROID_SYSTEM));
assert!(writable_without_sticky(0o770, 0, ANDROID_SYSTEM));
}

#[test]
fn should_accept_app_files_dir_group_writable_by_the_apps_own_group() {
// `<dataDir>/files` on Android: app uid 10150, group 10150, 0771.
// `trusted_groups(10_150)` on Android is `[1000, 10_150]`.
let android_app: &[u32] = &[1000, 10_150];
assert!(!writable_without_sticky(0o771, 10_150, android_app));
// Another app's group, and the cache gid (20000 + app id), stay refused.
assert!(writable_without_sticky(0o771, 10_456, android_app));
assert!(writable_without_sticky(0o2771, 20_150, android_app));
// Other-write is refused even in the app's own group.
assert!(writable_without_sticky(0o777, 10_150, android_app));
}

#[cfg(not(target_os = "android"))]
#[test]
fn should_trust_no_extra_groups_off_android() {
assert!(super::trusted_groups(501).is_empty());
// A user-owned, group-writable folder (macOS `staff` is gid 20) is
// still refused on desktop.
assert!(writable_without_sticky(
0o775,
20,
&super::trusted_groups(501)
));
}

#[test]
fn should_reject_other_write_regardless_of_group() {
assert!(writable_without_sticky(0o757, 1000, ANDROID_SYSTEM));
assert!(writable_without_sticky(0o777, 1000, ANDROID_SYSTEM));
}

#[test]
fn should_accept_sticky_and_unwritable_directories() {
assert!(!writable_without_sticky(0o1777, 2000, &[]));
assert!(!writable_without_sticky(0o755, 2000, &[]));
assert!(!writable_without_sticky(0o700, 2000, &[]));
}
}
Loading