Skip to content

Commit 42f3bc8

Browse files
committed
fix(cortex-login): handle Windows Credential Manager 2560-byte BLOB size limit
Windows Credential Manager has a hard limit of 2560 bytes per credential BLOB. OAuth JWT tokens can exceed this limit, causing 'Failed to save credentials' errors during OAuth login. This fix implements automatic chunking for large credentials on Windows: - Credentials larger than 2400 bytes are split into multiple keyring entries - Each chunk is stored separately with a unique key (base_key_chunk_N) - A metadata entry stores the chunk count for reconstruction - Loading and deleting also handle chunked credentials seamlessly The chunking is Windows-specific (#[cfg(target_os = "windows")]) and has no impact on macOS or Linux, which don't have this size limitation. Supports credentials up to ~24KB (10 chunks × 2400 bytes) which is more than enough for any OAuth token. Fixes Windows OAuth login failure: 'Failed to save access token to keyring'
1 parent 4717d41 commit 42f3bc8

File tree

1 file changed

+256
-32
lines changed

1 file changed

+256
-32
lines changed

cortex-login/src/lib.rs

Lines changed: 256 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -401,13 +401,233 @@ const KEYRING_KEY_ACCESS: &str = "access_token";
401401
const KEYRING_KEY_REFRESH: &str = "refresh_token";
402402
const KEYRING_KEY_METADATA: &str = "metadata";
403403

404+
/// Windows Credential Manager has a BLOB size limit of 2560 bytes.
405+
/// We use a smaller chunk size to be safe and account for encoding overhead.
406+
/// See: https://learn.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentiala
407+
#[cfg(target_os = "windows")]
408+
const WINDOWS_CREDENTIAL_CHUNK_SIZE: usize = 2400;
409+
410+
/// Maximum number of chunks we support (allows up to ~24KB tokens which is more than enough)
411+
#[cfg(target_os = "windows")]
412+
const MAX_CREDENTIAL_CHUNKS: usize = 10;
413+
404414
fn get_keyring_entry(account: &str) -> Result<keyring::Entry> {
405415
keyring::Entry::new(KEYRING_SERVICE, account).context("Failed to access keyring")
406416
}
407417

418+
/// Windows-specific: Save a potentially large credential by splitting into chunks.
419+
///
420+
/// Windows Credential Manager has a hard limit of 2560 bytes per credential BLOB.
421+
/// JWT tokens from OAuth can exceed this limit. This function splits large
422+
/// credentials into multiple keyring entries and stores the chunk count in
423+
/// a separate entry for reconstruction.
424+
#[cfg(target_os = "windows")]
425+
fn save_credential_chunked(base_key: &str, value: &str) -> Result<()> {
426+
let value_bytes = value.as_bytes();
427+
let total_len = value_bytes.len();
428+
429+
// If it fits in a single chunk, store directly
430+
if total_len <= WINDOWS_CREDENTIAL_CHUNK_SIZE {
431+
tracing::debug!(
432+
key = base_key,
433+
size = total_len,
434+
"Storing credential directly (within size limit)"
435+
);
436+
get_keyring_entry(base_key)?
437+
.set_password(value)
438+
.with_context(|| format!("Failed to save {} to keyring", base_key))?;
439+
440+
// Clean up any old chunk entries that might exist
441+
delete_credential_chunks(base_key)?;
442+
return Ok(());
443+
}
444+
445+
// Calculate number of chunks needed
446+
let num_chunks =
447+
(total_len + WINDOWS_CREDENTIAL_CHUNK_SIZE - 1) / WINDOWS_CREDENTIAL_CHUNK_SIZE;
448+
449+
if num_chunks > MAX_CREDENTIAL_CHUNKS {
450+
return Err(anyhow::anyhow!(
451+
"Credential too large: {} bytes requires {} chunks, max is {}",
452+
total_len,
453+
num_chunks,
454+
MAX_CREDENTIAL_CHUNKS
455+
));
456+
}
457+
458+
tracing::debug!(
459+
key = base_key,
460+
size = total_len,
461+
chunks = num_chunks,
462+
"Splitting large credential into chunks for Windows Credential Manager"
463+
);
464+
465+
// Store each chunk
466+
for (i, chunk) in value_bytes
467+
.chunks(WINDOWS_CREDENTIAL_CHUNK_SIZE)
468+
.enumerate()
469+
{
470+
let chunk_key = format!("{}_chunk_{}", base_key, i);
471+
let chunk_str = String::from_utf8_lossy(chunk);
472+
473+
get_keyring_entry(&chunk_key)?
474+
.set_password(&chunk_str)
475+
.with_context(|| format!("Failed to save chunk {} of {} to keyring", i, base_key))?;
476+
}
477+
478+
// Store chunk metadata (count) in the base key
479+
let chunk_info = format!("__chunked__:{}", num_chunks);
480+
get_keyring_entry(base_key)?
481+
.set_password(&chunk_info)
482+
.with_context(|| format!("Failed to save chunk metadata for {} to keyring", base_key))?;
483+
484+
// Clean up any extra old chunks that might exist from previous saves
485+
for i in num_chunks..MAX_CREDENTIAL_CHUNKS {
486+
let chunk_key = format!("{}_chunk_{}", base_key, i);
487+
if let Ok(entry) = get_keyring_entry(&chunk_key) {
488+
let _ = entry.delete_credential();
489+
}
490+
}
491+
492+
Ok(())
493+
}
494+
495+
/// Windows-specific: Load a potentially chunked credential.
496+
#[cfg(target_os = "windows")]
497+
fn load_credential_chunked(base_key: &str) -> Result<Option<String>> {
498+
let entry = get_keyring_entry(base_key)?;
499+
500+
match entry.get_password() {
501+
Ok(value) => {
502+
// Check if this is chunk metadata
503+
if let Some(chunk_count_str) = value.strip_prefix("__chunked__:") {
504+
let num_chunks: usize = chunk_count_str
505+
.parse()
506+
.with_context(|| format!("Invalid chunk count for {}", base_key))?;
507+
508+
tracing::debug!(
509+
key = base_key,
510+
chunks = num_chunks,
511+
"Loading chunked credential from Windows Credential Manager"
512+
);
513+
514+
// Reconstruct from chunks
515+
let mut full_value = String::new();
516+
for i in 0..num_chunks {
517+
let chunk_key = format!("{}_chunk_{}", base_key, i);
518+
let chunk_entry = get_keyring_entry(&chunk_key)?;
519+
let chunk = chunk_entry
520+
.get_password()
521+
.with_context(|| format!("Failed to load chunk {} of {}", i, base_key))?;
522+
full_value.push_str(&chunk);
523+
}
524+
525+
Ok(Some(full_value))
526+
} else {
527+
// Regular non-chunked value
528+
Ok(Some(value))
529+
}
530+
}
531+
Err(keyring::Error::NoEntry) => Ok(None),
532+
Err(e) => Err(anyhow::anyhow!("Keyring error loading {}: {}", base_key, e)),
533+
}
534+
}
535+
536+
/// Windows-specific: Delete a credential and all its chunks.
537+
#[cfg(target_os = "windows")]
538+
fn delete_credential_chunked(base_key: &str) -> Result<bool> {
539+
let mut deleted = false;
540+
541+
// Delete all possible chunks
542+
deleted |= delete_credential_chunks(base_key)?;
543+
544+
// Delete the base entry
545+
if let Ok(entry) = get_keyring_entry(base_key) {
546+
match entry.delete_credential() {
547+
Ok(()) => deleted = true,
548+
Err(keyring::Error::NoEntry) => {}
549+
Err(e) => {
550+
return Err(anyhow::anyhow!(
551+
"Keyring error deleting {}: {}",
552+
base_key,
553+
e
554+
));
555+
}
556+
}
557+
}
558+
559+
Ok(deleted)
560+
}
561+
562+
/// Windows-specific: Delete all chunk entries for a credential.
563+
#[cfg(target_os = "windows")]
564+
fn delete_credential_chunks(base_key: &str) -> Result<bool> {
565+
let mut deleted = false;
566+
567+
for i in 0..MAX_CREDENTIAL_CHUNKS {
568+
let chunk_key = format!("{}_chunk_{}", base_key, i);
569+
if let Ok(entry) = get_keyring_entry(&chunk_key) {
570+
match entry.delete_credential() {
571+
Ok(()) => deleted = true,
572+
Err(keyring::Error::NoEntry) => {}
573+
Err(e) => {
574+
tracing::debug!(
575+
key = chunk_key,
576+
error = %e,
577+
"Failed to delete chunk (may not exist)"
578+
);
579+
}
580+
}
581+
}
582+
}
583+
584+
Ok(deleted)
585+
}
586+
587+
/// Non-Windows: Save credential directly (no size limit issues).
588+
#[cfg(not(target_os = "windows"))]
589+
fn save_credential_chunked(base_key: &str, value: &str) -> Result<()> {
590+
get_keyring_entry(base_key)?
591+
.set_password(value)
592+
.with_context(|| format!("Failed to save {} to keyring", base_key))
593+
}
594+
595+
/// Non-Windows: Load credential directly.
596+
#[cfg(not(target_os = "windows"))]
597+
fn load_credential_chunked(base_key: &str) -> Result<Option<String>> {
598+
let entry = get_keyring_entry(base_key)?;
599+
match entry.get_password() {
600+
Ok(value) => Ok(Some(value)),
601+
Err(keyring::Error::NoEntry) => Ok(None),
602+
Err(e) => Err(anyhow::anyhow!("Keyring error loading {}: {}", base_key, e)),
603+
}
604+
}
605+
606+
/// Non-Windows: Delete credential directly.
607+
#[cfg(not(target_os = "windows"))]
608+
fn delete_credential_chunked(base_key: &str) -> Result<bool> {
609+
if let Ok(entry) = get_keyring_entry(base_key) {
610+
match entry.delete_credential() {
611+
Ok(()) => return Ok(true),
612+
Err(keyring::Error::NoEntry) => return Ok(false),
613+
Err(e) => {
614+
return Err(anyhow::anyhow!(
615+
"Keyring error deleting {}: {}",
616+
base_key,
617+
e
618+
));
619+
}
620+
}
621+
}
622+
Ok(false)
623+
}
624+
408625
/// Load authentication data from the system keyring.
626+
///
627+
/// On Windows, this function handles credentials that may have been split into
628+
/// multiple chunks due to the 2560-byte BLOB size limit in Windows Credential Manager.
409629
pub fn load_from_keyring() -> Result<Option<SecureAuthData>> {
410-
// Load metadata first
630+
// Load metadata first (metadata is always small enough to fit in a single entry)
411631
let metadata_entry = get_keyring_entry(KEYRING_KEY_METADATA)?;
412632

413633
let metadata: AuthData = match metadata_entry.get_password() {
@@ -416,29 +636,29 @@ pub fn load_from_keyring() -> Result<Option<SecureAuthData>> {
416636
Err(e) => return Err(anyhow::anyhow!("Keyring error: {e}")),
417637
};
418638

419-
// Load actual secrets based on metadata
639+
// Load actual secrets based on metadata (using chunked loading for Windows compatibility)
420640
let api_key = if metadata.has_api_key {
421-
match get_keyring_entry(KEYRING_KEY_API)?.get_password() {
422-
Ok(key) => Some(SecretString::from(key)),
423-
Err(_) => None,
641+
match load_credential_chunked(KEYRING_KEY_API) {
642+
Ok(Some(key)) => Some(SecretString::from(key)),
643+
Ok(None) | Err(_) => None,
424644
}
425645
} else {
426646
None
427647
};
428648

429649
let access_token = if metadata.has_access_token {
430-
match get_keyring_entry(KEYRING_KEY_ACCESS)?.get_password() {
431-
Ok(token) => Some(SecretString::from(token)),
432-
Err(_) => None,
650+
match load_credential_chunked(KEYRING_KEY_ACCESS) {
651+
Ok(Some(token)) => Some(SecretString::from(token)),
652+
Ok(None) | Err(_) => None,
433653
}
434654
} else {
435655
None
436656
};
437657

438658
let refresh_token = if metadata.has_refresh_token {
439-
match get_keyring_entry(KEYRING_KEY_REFRESH)?.get_password() {
440-
Ok(token) => Some(SecretString::from(token)),
441-
Err(_) => None,
659+
match load_credential_chunked(KEYRING_KEY_REFRESH) {
660+
Ok(Some(token)) => Some(SecretString::from(token)),
661+
Ok(None) | Err(_) => None,
442662
}
443663
} else {
444664
None
@@ -454,27 +674,28 @@ pub fn load_from_keyring() -> Result<Option<SecureAuthData>> {
454674
}))
455675
}
456676

677+
/// Save authentication data to the system keyring.
678+
///
679+
/// On Windows, this function automatically splits large credentials (like OAuth JWT tokens)
680+
/// into multiple chunks to work around the 2560-byte BLOB size limit in Windows Credential Manager.
457681
fn save_to_keyring(data: &SecureAuthData) -> Result<()> {
458-
// Save secrets
682+
// Save secrets (using chunked saving for Windows compatibility)
459683
if let Some(ref api_key) = data.api_key {
460-
get_keyring_entry(KEYRING_KEY_API)?
461-
.set_password(api_key.expose_secret())
684+
save_credential_chunked(KEYRING_KEY_API, api_key.expose_secret())
462685
.context("Failed to save API key to keyring")?;
463686
}
464687

465688
if let Some(ref access_token) = data.access_token {
466-
get_keyring_entry(KEYRING_KEY_ACCESS)?
467-
.set_password(access_token.expose_secret())
689+
save_credential_chunked(KEYRING_KEY_ACCESS, access_token.expose_secret())
468690
.context("Failed to save access token to keyring")?;
469691
}
470692

471693
if let Some(ref refresh_token) = data.refresh_token {
472-
get_keyring_entry(KEYRING_KEY_REFRESH)?
473-
.set_password(refresh_token.expose_secret())
694+
save_credential_chunked(KEYRING_KEY_REFRESH, refresh_token.expose_secret())
474695
.context("Failed to save refresh token to keyring")?;
475696
}
476697

477-
// Save metadata
698+
// Save metadata (always small enough to fit in a single entry)
478699
let metadata = data.to_metadata();
479700
let metadata_json = serde_json::to_string(&metadata).context("Failed to serialize metadata")?;
480701

@@ -485,22 +706,25 @@ fn save_to_keyring(data: &SecureAuthData) -> Result<()> {
485706
Ok(())
486707
}
487708

709+
/// Delete authentication data from the system keyring.
710+
///
711+
/// On Windows, this function also cleans up any chunk entries that may have been
712+
/// created for large credentials.
488713
fn delete_from_keyring() -> Result<bool> {
489714
let mut deleted = false;
490715

491-
// Delete all keyring entries
492-
for key in &[
493-
KEYRING_KEY_API,
494-
KEYRING_KEY_ACCESS,
495-
KEYRING_KEY_REFRESH,
496-
KEYRING_KEY_METADATA,
497-
] {
498-
if let Ok(entry) = get_keyring_entry(key) {
499-
match entry.delete_credential() {
500-
Ok(()) => deleted = true,
501-
Err(keyring::Error::NoEntry) => {}
502-
Err(e) => return Err(anyhow::anyhow!("Keyring error: {e}")),
503-
}
716+
// Delete all keyring entries (using chunked deletion for Windows compatibility)
717+
// This ensures any chunk entries are also cleaned up
718+
for key in &[KEYRING_KEY_API, KEYRING_KEY_ACCESS, KEYRING_KEY_REFRESH] {
719+
deleted |= delete_credential_chunked(key)?;
720+
}
721+
722+
// Delete metadata (always a single entry)
723+
if let Ok(entry) = get_keyring_entry(KEYRING_KEY_METADATA) {
724+
match entry.delete_credential() {
725+
Ok(()) => deleted = true,
726+
Err(keyring::Error::NoEntry) => {}
727+
Err(e) => return Err(anyhow::anyhow!("Keyring error: {e}")),
504728
}
505729
}
506730

0 commit comments

Comments
 (0)