Skip to content
Merged
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
105 changes: 102 additions & 3 deletions crates/core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,15 @@ impl Chat {
self.inner.import_keys(&secret)?;
Ok(())
}
// Invalid PIN carries the remaining-attempt count from Juicebox so
// callers can warn the user before the guess budget runs out.
RecoverResult::Failure {
reason,
guesses_remaining: _,
} => Err(SdkError::Juicebox(reason.into())),
reason: crate::keys::juicebox::RecoverFailureReason::InvalidPin,
guesses_remaining,
} => Err(SdkError::Juicebox(JuiceboxError::InvalidPin {
guesses_remaining,
})),
RecoverResult::Failure { reason, .. } => Err(SdkError::Juicebox(reason.into())),
RecoverResult::KeyReconstructionFailed => Err(SdkError::Key(
KeyError::ReconstructionFailed("Failed to reconstruct keys".into()),
)),
Expand Down Expand Up @@ -567,6 +572,100 @@ mod tests {
assert!(!keys_after.identity.is_empty());
}

/// A Juicebox stub whose recover always fails with an invalid PIN,
/// reporting a fixed remaining-guess count.
struct InvalidPinJuicebox {
guesses_remaining: Option<u16>,
}

#[async_trait::async_trait]
impl crate::keys::juicebox::JuiceboxApi for InvalidPinJuicebox {
async fn register_private_key(
&self,
_pin: &[u8],
_config: &JuiceboxConfig,
_secret: &[u8],
) -> RegisterResult {
RegisterResult::Success
}

async fn recover_private_key(
&self,
_pin: &[u8],
_config: &JuiceboxConfig,
) -> crate::keys::juicebox::RecoverResult {
crate::keys::juicebox::RecoverResult::Failure {
reason: crate::keys::juicebox::RecoverFailureReason::InvalidPin,
guesses_remaining: self.guesses_remaining,
}
}

async fn delete_keys(
&self,
_config: &JuiceboxConfig,
) -> crate::keys::juicebox::DeleteResult {
crate::keys::juicebox::DeleteResult::Success
}
}

#[tokio::test]
async fn test_unlock_invalid_pin_carries_guesses_remaining() {
let chat = Chat::with_juicebox(
test_config(),
Arc::new(InvalidPinJuicebox {
guesses_remaining: Some(3),
}),
);
let err = chat.unlock(b"0000").await.unwrap_err();
match &err {
SdkError::Juicebox(JuiceboxError::InvalidPin { guesses_remaining }) => {
assert_eq!(*guesses_remaining, Some(3));
}
other => panic!("expected InvalidPin, got {other:?}"),
}
assert_eq!(
err.to_string(),
"Juicebox error: Invalid PIN: guesses_remaining=3"
);
}

#[tokio::test]
async fn test_unlock_invalid_pin_without_count() {
let chat = Chat::with_juicebox(
test_config(),
Arc::new(InvalidPinJuicebox {
guesses_remaining: None,
}),
);
let err = chat.unlock(b"0000").await.unwrap_err();
assert!(matches!(
err,
SdkError::Juicebox(JuiceboxError::InvalidPin {
guesses_remaining: None
})
));
assert_eq!(err.to_string(), "Juicebox error: Invalid PIN");
}

/// `change_pin` unlocks with the old PIN first, so a wrong old PIN
/// surfaces the same count-carrying error as `unlock`.
#[tokio::test]
async fn test_change_pin_invalid_old_pin_carries_guesses_remaining() {
let chat = Chat::with_juicebox(
test_config(),
Arc::new(InvalidPinJuicebox {
guesses_remaining: Some(0),
}),
);
let err = chat.change_pin(b"0000", b"2580").await.unwrap_err();
assert!(matches!(
err,
SdkError::Juicebox(JuiceboxError::InvalidPin {
guesses_remaining: Some(0)
})
));
}

#[tokio::test]
async fn test_encrypt_decrypt_roundtrip() {
let chat = Chat::with_juicebox(test_config(), Arc::new(MockJuiceboxApi::new()));
Expand Down
74 changes: 59 additions & 15 deletions crates/core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,16 @@ pub enum KeyError {
/// Errors from Juicebox SDK integration.
#[derive(Debug, Error)]
pub enum JuiceboxError {
/// Wrong PIN provided.
#[error("Invalid PIN")]
InvalidPin,
/// Wrong PIN provided. `guesses_remaining` is the attempt budget Juicebox
/// reports after the failure (0 = exhausted, keys locked); `None` when the
/// count is unavailable. The `guesses_remaining=N` message token is stable
/// — bindings parse it out of the error string, so the format must not
/// change (the bare `Invalid PIN` prefix likewise stays as-is).
#[error("Invalid PIN{}", .guesses_remaining.map(|n| format!(": guesses_remaining={n}")).unwrap_or_default())]
InvalidPin {
/// Remaining PIN attempts reported by Juicebox, if known.
guesses_remaining: Option<u16>,
},

/// Keys not registered yet.
#[error("Keys not registered")]
Expand Down Expand Up @@ -141,15 +148,17 @@ pub enum JuiceboxError {
impl JuiceboxError {
/// Returns true if this error is retryable.
pub fn is_retryable(&self) -> bool {
matches!(
self,
JuiceboxError::InvalidPin
| JuiceboxError::NotRegistered
| JuiceboxError::InvalidAuth
| JuiceboxError::Transient
| JuiceboxError::RateLimitExceeded
| JuiceboxError::StorageFailed
)
match self {
// A zero remaining budget means the stored keys are locked;
// another attempt cannot succeed.
JuiceboxError::InvalidPin { guesses_remaining } => *guesses_remaining != Some(0),
JuiceboxError::NotRegistered
| JuiceboxError::InvalidAuth
| JuiceboxError::Transient
| JuiceboxError::RateLimitExceeded
| JuiceboxError::StorageFailed => true,
_ => false,
}
}
}

Expand Down Expand Up @@ -213,7 +222,9 @@ mod tests {

#[test]
fn test_sdk_error_from_juicebox() {
let jb = JuiceboxError::InvalidPin;
let jb = JuiceboxError::InvalidPin {
guesses_remaining: None,
};
let sdk: SdkError = jb.into();
assert!(matches!(sdk, SdkError::Juicebox(_)));
}
Expand Down Expand Up @@ -264,7 +275,20 @@ mod tests {

#[test]
fn test_juicebox_error_retryable() {
assert!(JuiceboxError::InvalidPin.is_retryable());
assert!(JuiceboxError::InvalidPin {
guesses_remaining: None
}
.is_retryable());
assert!(JuiceboxError::InvalidPin {
guesses_remaining: Some(1)
}
.is_retryable());
// An exhausted guess budget locks the stored keys; retrying a PIN
// cannot succeed.
assert!(!JuiceboxError::InvalidPin {
guesses_remaining: Some(0)
}
.is_retryable());
assert!(JuiceboxError::NotRegistered.is_retryable());
assert!(JuiceboxError::InvalidAuth.is_retryable());
assert!(JuiceboxError::Transient.is_retryable());
Expand All @@ -279,7 +303,27 @@ mod tests {

#[test]
fn test_juicebox_error_display() {
assert!(JuiceboxError::InvalidPin.to_string().contains("PIN"));
assert_eq!(
JuiceboxError::InvalidPin {
guesses_remaining: None
}
.to_string(),
"Invalid PIN"
);
assert_eq!(
JuiceboxError::InvalidPin {
guesses_remaining: Some(3)
}
.to_string(),
"Invalid PIN: guesses_remaining=3"
);
assert_eq!(
JuiceboxError::InvalidPin {
guesses_remaining: Some(0)
}
.to_string(),
"Invalid PIN: guesses_remaining=0"
);
assert!(JuiceboxError::NoTokens.to_string().contains("token"));
assert!(JuiceboxError::Other("custom".into())
.to_string()
Expand Down
6 changes: 5 additions & 1 deletion crates/core/src/keys/juicebox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,11 @@ impl JuiceboxApi for JuiceboxClient {
impl From<RecoverFailureReason> for JuiceboxError {
fn from(reason: RecoverFailureReason) -> Self {
match reason {
RecoverFailureReason::InvalidPin => JuiceboxError::InvalidPin,
// Conversion from the bare reason has no count; `Chat::unlock`
// builds the variant directly to carry `guesses_remaining`.
RecoverFailureReason::InvalidPin => JuiceboxError::InvalidPin {
guesses_remaining: None,
},
RecoverFailureReason::NotRegistered => JuiceboxError::NotRegistered,
RecoverFailureReason::InvalidAuth => JuiceboxError::InvalidAuth,
RecoverFailureReason::UpgradeRequired => JuiceboxError::UpgradeRequired,
Expand Down
23 changes: 23 additions & 0 deletions crates/dotnet/dotnet/ChatXdk.Tests/ChatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,29 @@ public void UpdateConfig_RejectsMalformedKeyStoreTokenMapJson()
Assert.Contains("Invalid key_store_token_map_json", ex.Message);
}

[Fact]
public void GuessesRemaining_ParsedFromInvalidPinMessage()
{
// The core's invalid-PIN unlock error carries the stable
// "guesses_remaining=N" token in the message; 0 means exhausted.
Assert.Equal(3,
new ChatXdkException("Juicebox error: Invalid PIN: guesses_remaining=3").GuessesRemaining);
Assert.Equal(0,
new ChatXdkException("Juicebox error: Invalid PIN: guesses_remaining=0").GuessesRemaining);
Assert.Null(new ChatXdkException("Juicebox error: Invalid PIN").GuessesRemaining);
// The count is read only from the invalid-PIN form, not from
// unrelated messages that happen to contain the token.
Assert.Null(new ChatXdkException("Delete failed: guesses_remaining=7").GuessesRemaining);
}

[Fact]
public void GuessesRemaining_NullOnNonPinErrors()
{
using var chat = new Chat();
var ex = Assert.Throws<ChatXdkException>(() => chat.UpdateConfig("not json"));
Assert.Null(ex.GuessesRemaining);
}

// Key generation

[Fact]
Expand Down
25 changes: 24 additions & 1 deletion crates/dotnet/dotnet/ChatXdk/Chat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,29 @@ private static object[][] SerialiseEntities(IReadOnlyList<EntityDescriptor> enti
/// </summary>
public sealed class ChatXdkException : Exception
{
public ChatXdkException(string message) : base(message) { }
// Stable invalid-PIN message form the core emits
// ("Invalid PIN: guesses_remaining=N"). Anchored on the full form so a
// count embedded in an unrelated pass-through message is not misread.
private static readonly System.Text.RegularExpressions.Regex GuessesRemainingPattern =
new(@"\bInvalid PIN: guesses_remaining=(\d+)", System.Text.RegularExpressions.RegexOptions.Compiled);

public ChatXdkException(string message) : base(message)
{
var match = GuessesRemainingPattern.Match(message ?? "");
if (match.Success && int.TryParse(match.Groups[1].Value, out var n))
{
GuessesRemaining = n;
}
}

/// <summary>
/// Remaining PIN attempts reported by Juicebox, or <see langword="null"/> when the
/// message carries no count.
///
/// <para>Present only on invalid-PIN <see cref="Chat.Unlock"/> /
/// <see cref="Chat.ChangePin"/> failures. <c>0</c> means the guess budget is
/// exhausted and the stored keys are locked.</para>
/// </summary>
public int? GuessesRemaining { get; }
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
package com.x.chatxdk;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Thrown when the native chat-xdk library returns an error string. */
public class ChatXdkException extends RuntimeException {

/**
* Stable invalid-PIN message form the core emits ("Invalid PIN: guesses_remaining=N").
* Anchored on the full form so a count embedded in an unrelated pass-through message is
* not misread.
*/
private static final Pattern GUESSES_REMAINING =
Pattern.compile("\\bInvalid PIN: guesses_remaining=(\\d+)");

public ChatXdkException(String message) {
super(message);
}

/**
* Remaining PIN attempts reported by Juicebox, or {@code null} when the message carries no
* count.
*
* <p>Present only on invalid-PIN {@link Chat#unlock} / {@link Chat#changePin} failures.
* {@code 0} means the guess budget is exhausted and the stored keys are locked.
*/
public Integer getGuessesRemaining() {
String message = getMessage();
if (message == null) {
return null;
}
Matcher m = GUESSES_REMAINING.matcher(message);
if (!m.find()) {
return null;
}
try {
return Integer.valueOf(m.group(1));
} catch (NumberFormatException e) {
// Digits too large for an int — treat as no usable count.
return null;
}
}
}
30 changes: 30 additions & 0 deletions crates/jvm/java/chatxdk/src/test/java/com/x/chatxdk/ChatTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,36 @@ void updateConfigRejectsMalformedKeyStoreTokenMapJson() {
}
}

@Test
void guessesRemainingParsedFromInvalidPinMessage() {
// The core's invalid-PIN unlock error carries the stable
// "guesses_remaining=N" token in the message; 0 means exhausted.
assertEquals(
3,
new ChatXdkException("Juicebox error: Invalid PIN: guesses_remaining=3")
.getGuessesRemaining());
assertEquals(
0,
new ChatXdkException("Juicebox error: Invalid PIN: guesses_remaining=0")
.getGuessesRemaining());
assertNull(
new ChatXdkException("Juicebox error: Invalid PIN").getGuessesRemaining());
// The count is read only from the invalid-PIN form, not from
// unrelated messages that happen to contain the token.
assertNull(
new ChatXdkException("Delete failed: guesses_remaining=7")
.getGuessesRemaining());
}

@Test
void guessesRemainingNullOnNonPinErrors() {
try (Chat chat = new Chat()) {
ChatXdkException ex =
assertThrows(ChatXdkException.class, () -> chat.updateConfig("not json"));
assertNull(ex.getGuessesRemaining());
}
}

@Test
void generateKeypairsReturnsValidPayload() throws Exception {
try (Chat chat = new Chat()) {
Expand Down
Loading
Loading