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
4 changes: 4 additions & 0 deletions docs/COOKIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ If automatic extraction fails (for example, Chromium App-Bound Encryption is act
4. Copy the `Cookie` header value from **Request Headers**
5. In CodexBar Settings → provider detail → **Browser Cookies**, paste the value

## Kimi local-storage tokens

With the Kimi cookie source set to automatic, CodexBar also reads `access_token` from Chromium browsers' `Local Storage` for the selected Kimi region (`www.kimi.com` or `www.kimi.ai`), after the Kimi Desktop session and browser cookies. Local storage is not App-Bound encrypted, so this can work when cookie decryption is blocked. Only unexpired three-segment JWTs are used; refresh tokens are never read. A manual Cookie header always wins, and Cookie source Off or Manual skips this step. Open Kimi in the browser to renew an expired session. Firefox and Safari local storage are not read, and only the `Default` and `Profile N` profiles are scanned.

## Troubleshooting

- **"Chromium App-Bound Encryption"**: Modern Chrome, Edge, Brave, and other Chromium-based profiles can protect cookies with ABE. Closing the browser does not remove ABE; use a manual Cookie header or Firefox for the same login
Expand Down
48 changes: 19 additions & 29 deletions rust/src/browser/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,36 +206,26 @@ impl BrowserDetector {
}

/// Detect Chromium-based browser profiles
fn detect_chromium_profiles(user_data_dir: &PathBuf) -> Vec<BrowserProfile> {
let mut profiles = Vec::new();

// Default profile
let default_path = user_data_dir.join("Default");
if default_path.exists() {
profiles.push(BrowserProfile {
name: "Default".to_string(),
path: default_path,
is_default: true,
});
}

// Additional profiles (Profile 1, Profile 2, etc.)
if let Ok(entries) = std::fs::read_dir(user_data_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("Profile ") {
let path = entry.path();
if path.is_dir() {
profiles.push(BrowserProfile {
name,
path,
is_default: false,
});
}
}
}
}
pub(super) fn detect_chromium_profiles(user_data_dir: &Path) -> Vec<BrowserProfile> {
let Ok(entries) = std::fs::read_dir(user_data_dir) else {
return Vec::new();
};

let mut profiles: Vec<_> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
let path = entry.path();
let is_profile =
name == "Default" || name.starts_with("Profile ") || name.starts_with("user-");
(is_profile && path.is_dir()).then(|| BrowserProfile {
is_default: name == "Default",
name,
path,
})
})
.collect();
profiles.sort_by(|left, right| left.name.cmp(&right.name));
profiles
}

Expand Down
53 changes: 46 additions & 7 deletions rust/src/browser/leveldb/local_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! the same format byte. Other keys in the database (`VERSION`, `META:<origin>`,
//! `METAACCESS:<origin>`) are bookkeeping and are ignored.

use super::{Entry, LevelDbError, read_entries};
use super::{Entry, LevelDbError, read_entries_with_budget};
use std::path::{Path, PathBuf};

const KEY_PREFIX: u8 = b'_';
Expand All @@ -15,12 +15,22 @@ const FORMAT_UTF16LE: u8 = 0;
const FORMAT_LATIN1: u8 = 1;

/// One decoded `localStorage` item.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub struct LocalStorageEntry {
pub key: String,
pub value: String,
}

impl std::fmt::Debug for LocalStorageEntry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LocalStorageEntry")
.field("key", &self.key)
.field("value", &"[REDACTED]")
.finish()
}
}

/// Directory holding Local Storage for a Chromium profile directory (`Default`, `Profile 1`, ...).
pub fn local_storage_dir(profile_dir: &Path) -> PathBuf {
profile_dir.join("Local Storage").join("leveldb")
Expand All @@ -32,14 +42,35 @@ pub fn read_local_storage_entries(
dir: &Path,
origin: &str,
) -> Result<Vec<LocalStorageEntry>, LevelDbError> {
Ok(decode_origin_entries(&read_entries(dir)?, origin))
read_local_storage_entries_for_origins(dir, &[origin])
}

/// Read `localStorage` items for any of `origins` with one LevelDB scan, sorted by key within
/// each origin. A trailing slash on an origin is ignored.
pub fn read_local_storage_entries_for_origins(
dir: &Path,
origins: &[&str],
) -> Result<Vec<LocalStorageEntry>, LevelDbError> {
let entries = super::read_entries(dir)?;
Ok(origins
.iter()
.flat_map(|origin| decode_origin_entries(&entries, origin))
.collect())
}

pub(crate) fn read_local_storage_entries_with_budget(
dir: &Path,
origin: &str,
max_total_bytes: u64,
) -> Result<(Vec<LocalStorageEntry>, u64), LevelDbError> {
let prefix = origin_key_prefix(origin);
let (entries, scanned_bytes) =
read_entries_with_budget(dir, max_total_bytes, Some(prefix.as_slice()))?;
Ok((decode_origin_entries(&entries, origin), scanned_bytes))
}

pub(super) fn decode_origin_entries(entries: &[Entry], origin: &str) -> Vec<LocalStorageEntry> {
let mut prefix = Vec::with_capacity(origin.len() + 2);
prefix.push(KEY_PREFIX);
prefix.extend_from_slice(origin.trim_end_matches('/').as_bytes());
prefix.push(ORIGIN_TERMINATOR);
let prefix = origin_key_prefix(origin);

entries
.iter()
Expand All @@ -51,6 +82,14 @@ pub(super) fn decode_origin_entries(entries: &[Entry], origin: &str) -> Vec<Loca
.collect()
}

fn origin_key_prefix(origin: &str) -> Vec<u8> {
let mut prefix = Vec::with_capacity(origin.len() + 2);
prefix.push(KEY_PREFIX);
prefix.extend_from_slice(origin.trim_end_matches('/').as_bytes());
prefix.push(ORIGIN_TERMINATOR);
prefix
}

/// Decode a format-byte-prefixed Chromium string; `None` for an unknown format or bad UTF-16.
fn decode_text(bytes: &[u8]) -> Option<String> {
let (&format, data) = bytes.split_first()?;
Expand Down
114 changes: 84 additions & 30 deletions rust/src/browser/leveldb/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
//! malformed tail ends the scan quietly and everything before it is kept. Record checksums are
//! not verified: this is a best-effort read of another program's cache, not a database recovery.

use super::Record;
use super::varint::{read_length_prefixed, read_u32_le, read_u64_le};
use super::{MAX_RECORDS_PER_DIRECTORY, Record};

const BLOCK_SIZE: usize = 32 * 1024;
const HEADER_SIZE: usize = 7;
const BATCH_HEADER_SIZE: usize = 12;
const MAX_LOG_RECORD_BYTES: usize = 16 * 1024 * 1024;

const TYPE_ZERO: u8 = 0;
const TYPE_FULL: u8 = 1;
Expand All @@ -24,9 +25,19 @@ const TYPE_LAST: u8 = 4;

const OP_DELETE: u8 = 0;
const OP_PUT: u8 = 1;
const MAX_SEQUENCE: u64 = (1 << 56) - 1;

/// Feed every put/delete found in `data` to `emit`.
#[cfg(test)]
pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) {
let _ = read_log_until(data, &mut |record| {
emit(record);
true
});
}

/// Read a log until it is malformed or `emit` asks the scan to stop.
pub(super) fn read_log_until(data: &[u8], emit: &mut impl FnMut(Record) -> bool) -> bool {
let mut assembled: Vec<u8> = Vec::new();
let mut in_fragmented = false;

Expand All @@ -38,7 +49,7 @@ pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) {
continue;
}
let Some(header) = data.get(offset..offset + HEADER_SIZE) else {
return;
return false;
};
let length = usize::from(u16::from_le_bytes([header[4], header[5]]));
let kind = header[6];
Expand All @@ -49,74 +60,117 @@ pub(super) fn read_log(data: &[u8], emit: &mut impl FnMut(Record)) {
}
let payload_start = offset + HEADER_SIZE;
if HEADER_SIZE + length > block_remaining {
return;
return false;
}
let Some(payload) = data.get(payload_start..payload_start + length) else {
return;
return false;
};
offset = payload_start + length;

match kind {
TYPE_FULL => {
assembled.clear();
in_fragmented = false;
read_batch(payload, emit);
if !read_batch(payload, emit) {
return false;
}
}
TYPE_FIRST => {
assembled.clear();
assembled.extend_from_slice(payload);
if !append_fragment(&mut assembled, payload) {
return false;
}
in_fragmented = true;
}
TYPE_MIDDLE if in_fragmented => assembled.extend_from_slice(payload),
TYPE_MIDDLE if in_fragmented => {
if !append_fragment(&mut assembled, payload) {
return false;
}
}
TYPE_LAST if in_fragmented => {
assembled.extend_from_slice(payload);
if !append_fragment(&mut assembled, payload) {
return false;
}
in_fragmented = false;
read_batch(&assembled, emit);
if !read_batch(&assembled, emit) {
return false;
}
assembled.clear();
}
_ => return,
_ => return false,
}
}
true
}

fn append_fragment(assembled: &mut Vec<u8>, payload: &[u8]) -> bool {
let Some(new_len) = assembled.len().checked_add(payload.len()) else {
return false;
};
if new_len > MAX_LOG_RECORD_BYTES || assembled.try_reserve(payload.len()).is_err() {
return false;
}
assembled.extend_from_slice(payload);
true
}

fn read_batch(batch: &[u8], emit: &mut impl FnMut(Record) -> bool) -> bool {
if !visit_batch(batch, &mut |_, _, _| true) {
return true;
}
visit_batch(batch, &mut |sequence, key, value| {
emit(Record {
key: key.to_vec(),
sequence,
value: value.map(|value| value.to_vec()),
})
})
}

fn read_batch(batch: &[u8], emit: &mut impl FnMut(Record)) {
/// Validate the whole batch before applying any of its operations. A torn or malformed write
/// batch must not leave a valid-looking prefix in the returned database state.
fn visit_batch<'a>(
batch: &'a [u8],
emit: &mut impl FnMut(u64, &'a [u8], Option<&'a [u8]>) -> bool,
) -> bool {
let (Some(sequence), Some(count)) = (read_u64_le(batch, 0), read_u32_le(batch, 8)) else {
return;
return false;
};
if sequence > MAX_SEQUENCE || u64::from(count) > MAX_RECORDS_PER_DIRECTORY as u64 {
return false;
}
let mut rest = match batch.get(BATCH_HEADER_SIZE..) {
Some(rest) => rest,
None => return false,
};
let mut rest = batch.get(BATCH_HEADER_SIZE..).unwrap_or_default();
let mut records = Vec::new();
for index in 0..u64::from(count) {
let Some((&op, after_op)) = rest.split_first() else {
return;
return false;
};
let Some((key, after_key)) = read_length_prefixed(after_op) else {
return;
return false;
};
let value = match op {
OP_PUT => {
let Some((value, after_value)) = read_length_prefixed(after_key) else {
return;
return false;
};
rest = after_value;
Some(value.to_vec())
Some(value)
}
OP_DELETE => {
rest = after_key;
None
}
_ => return,
_ => return false,
};
let Some(sequence) = sequence.checked_add(index) else {
return;
let Some(record_sequence) = sequence.checked_add(index).filter(|s| *s <= MAX_SEQUENCE)
else {
return false;
};
records.push(Record {
key: key.to_vec(),
sequence,
value,
});
}
if !rest.is_empty() {
return;
if !emit(record_sequence, key, value) {
return false;
}
}
records.into_iter().for_each(emit);
rest.is_empty()
}
Loading