From 18abcc14c7faa862d77fffb7cada67b7579c7fcb Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Mon, 2 Nov 2026 20:14:09 +0000 Subject: [PATCH 1/2] feat(sdk): expose recovery credentials and the V2 password reset Add TypeScript and Rust bindings for the recovery-code endpoints and the seed-preserving password reset from #904: - recovery code status, enroll, rotate and disable, each with the current-password step-up; the React context guards them against an account switch mid-request - V2 reset options and completion, which preserve data with a recovery code or require an explicit data-loss acknowledgment, then install the returned token pair - Rust Debug output redacts recovery codes, passwords and reset secrets The legacy confirm endpoint is unchanged; its docs now note it is destructive. Co-Authored-By: Claude Opus 5.5 --- sdk/rust/src/client.rs | 108 ++++++++ sdk/rust/src/types.rs | 232 +++++++++++++++++ sdk/rust/tests/account_management.rs | 135 +++++++++- sdk/src/lib/api.ts | 220 +++++++++++++++++ sdk/src/lib/index.ts | 5 + sdk/src/lib/main.tsx | 101 ++++++++ sdk/src/lib/test/integration/api.test.ts | 89 ++++++- sdk/src/lib/test/recoveryCredentials.test.ts | 247 +++++++++++++++++++ 8 files changed, 1135 insertions(+), 2 deletions(-) create mode 100644 sdk/src/lib/test/recoveryCredentials.test.ts diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index 0b31ee086..83734b604 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -2197,6 +2197,12 @@ impl OpenSecretClient { /// Confirms a password reset with the code from email /// Note: This does not require authentication but still uses encryption + /// + /// This legacy reset is always destructive: the account receives a fresh + /// seed and loses its encrypted data. Use + /// [`get_password_reset_options`](Self::get_password_reset_options) and + /// [`complete_password_reset`](Self::complete_password_reset) to offer a + /// seed-preserving reset when a recovery code is enrolled. pub async fn confirm_password_reset( &self, email: String, @@ -2218,6 +2224,108 @@ impl OpenSecretClient { Ok(()) } + /// Reports which completions a pending password reset supports. + /// + /// The server verifies the emailed code and client secret before revealing + /// whether the account has an enrolled recovery code, and does not consume + /// the reset request. Every proof failure returns the same generic error. + pub async fn get_password_reset_options( + &self, + email: String, + alphanumeric_code: String, + plaintext_secret: String, + client_id: Uuid, + ) -> Result { + let request = PasswordResetOptionsRequest { + proof: PasswordResetProof { + email, + alphanumeric_code, + plaintext_secret, + client_id, + }, + }; + self.encrypted_api_call("/password-reset/v2/options", "POST", Some(request)) + .await + } + + /// Completes a password reset and signs in with the new password. + /// + /// A [`PasswordResetCompletion::Preserve`] completion with a well-formed + /// but wrong recovery code consumes the reset request, so the user must + /// request a new reset email before trying again. A malformed code leaves + /// the request active. The server returns the same generic error for every + /// rejection. + pub async fn complete_password_reset( + &self, + email: String, + alphanumeric_code: String, + plaintext_secret: String, + new_password: String, + client_id: Uuid, + mode: PasswordResetCompletion, + ) -> Result { + let request = CompletePasswordResetRequest { + proof: PasswordResetProof { + email, + alphanumeric_code, + plaintext_secret, + client_id, + }, + new_password, + mode, + }; + let response: CompletePasswordResetResponse = self + .encrypted_api_call("/password-reset/v2/complete", "POST", Some(request)) + .await?; + + self.session_manager.set_tokens( + response.access_token.clone(), + Some(response.refresh_token.clone()), + )?; + + Ok(response) + } + + /// Fetches the recovery-code enrollment status for the signed-in user. + pub async fn get_recovery_code_status(&self) -> Result { + self.authenticated_api_call("/protected/recovery-code", "GET", None::<()>) + .await + } + + /// Enrolls a recovery code that can preserve encrypted data across a + /// password reset. Only email-and-password accounts can enroll, and the + /// server returns the code only once. Fails with status 409 when a code is + /// already enrolled. + pub async fn enroll_recovery_code( + &self, + current_password: String, + ) -> Result { + let request = RecoveryCodePasswordRequest { current_password }; + self.authenticated_api_call("/protected/recovery-code/enroll", "POST", Some(request)) + .await + } + + /// Replaces the enrolled recovery code; the previous code stops working. + /// The server returns the replacement code only once. + pub async fn rotate_recovery_code( + &self, + current_password: String, + ) -> Result { + let request = RecoveryCodePasswordRequest { current_password }; + self.authenticated_api_call("/protected/recovery-code/rotate", "POST", Some(request)) + .await + } + + /// Removes the enrolled recovery code, so later password resets are + /// destructive. Succeeds when no code is enrolled. + pub async fn disable_recovery_code(&self, current_password: String) -> Result<()> { + let request = RecoveryCodePasswordRequest { current_password }; + let _: serde_json::Value = self + .authenticated_api_call("/protected/recovery-code", "DELETE", Some(request)) + .await?; + Ok(()) + } + /// Verifies an email address with the code from the verification email /// Note: This does not require authentication but still uses encryption pub async fn verify_email(&self, code: String) -> Result<()> { diff --git a/sdk/rust/src/types.rs b/sdk/rust/src/types.rs index 90fa06e8f..124d0b062 100644 --- a/sdk/rust/src/types.rs +++ b/sdk/rust/src/types.rs @@ -490,6 +490,131 @@ pub struct ConfirmAccountDeletionRequest { pub plaintext_secret: String, } +/// The emailed reset code and client secret that prove a pending password +/// reset, scoped to one project's account space. +#[derive(Clone, Serialize, Deserialize)] +pub struct PasswordResetProof { + pub email: String, + pub alphanumeric_code: String, + pub plaintext_secret: String, + pub client_id: Uuid, +} + +impl std::fmt::Debug for PasswordResetProof { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PasswordResetProof") + .field("email", &self.email) + .field("alphanumeric_code", &"[REDACTED]") + .field("plaintext_secret", &"[REDACTED]") + .field("client_id", &self.client_id) + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PasswordResetOptionsRequest { + pub proof: PasswordResetProof, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PasswordResetOptions { + pub recovery_enrolled: bool, + pub destructive_reset_available: bool, +} + +/// How a V2 password reset completes. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PasswordResetCompletion { + /// Opens the enrolled seed with the recovery code and keeps all encrypted + /// data. A well-formed but wrong code consumes the reset request. + Preserve { recovery_code: String }, + /// Replaces the seed and loses all encrypted data. The server rejects the + /// request unless `acknowledge_data_loss` is true. + Destructive { acknowledge_data_loss: bool }, +} + +impl std::fmt::Debug for PasswordResetCompletion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Preserve { .. } => f + .debug_struct("Preserve") + .field("recovery_code", &"[REDACTED]") + .finish(), + Self::Destructive { + acknowledge_data_loss, + } => f + .debug_struct("Destructive") + .field("acknowledge_data_loss", acknowledge_data_loss) + .finish(), + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CompletePasswordResetRequest { + pub proof: PasswordResetProof, + pub new_password: String, + pub mode: PasswordResetCompletion, +} + +impl std::fmt::Debug for CompletePasswordResetRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompletePasswordResetRequest") + .field("proof", &self.proof) + .field("new_password", &"[REDACTED]") + .field("mode", &self.mode) + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletePasswordResetResponse { + pub message: String, + pub access_token: String, + pub refresh_token: String, +} + +// Recovery Code Types + +/// The current password, required as a step-up to enroll, rotate, or disable a +/// recovery code. +#[derive(Clone, Serialize, Deserialize)] +pub struct RecoveryCodePasswordRequest { + pub current_password: String, +} + +impl std::fmt::Debug for RecoveryCodePasswordRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecoveryCodePasswordRequest") + .field("current_password", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoveryCodeStatus { + pub enrolled: bool, + pub enrolled_at: Option>, + /// Last seed-preserving password reset that used the enrolled code. + pub last_used_at: Option>, + pub use_count: i32, +} + +/// A newly enrolled or rotated recovery code. The server returns it only once. +#[derive(Clone, Serialize, Deserialize)] +pub struct RecoveryCodeResponse { + pub recovery_code: String, +} + +impl std::fmt::Debug for RecoveryCodeResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecoveryCodeResponse") + .field("recovery_code", &"[REDACTED]") + .finish() + } +} + // API Key Management Types #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiKey { @@ -1461,6 +1586,113 @@ mod tests { use super::*; use serde_json::json; + fn reset_proof() -> PasswordResetProof { + PasswordResetProof { + email: "user@example.test".to_string(), + alphanumeric_code: "ABCD1234".to_string(), + plaintext_secret: "client-reset-secret".to_string(), + client_id: Uuid::nil(), + } + } + + #[test] + fn password_reset_completion_matches_the_server_wire_shape() { + let proof = json!({ + "email": "user@example.test", + "alphanumeric_code": "ABCD1234", + "plaintext_secret": "client-reset-secret", + "client_id": Uuid::nil(), + }); + let preserve = CompletePasswordResetRequest { + proof: reset_proof(), + new_password: "new-password".to_string(), + mode: PasswordResetCompletion::Preserve { + recovery_code: "MPLRC1-CODE".to_string(), + }, + }; + assert_eq!( + serde_json::to_value(&preserve).unwrap(), + json!({ + "proof": proof, + "new_password": "new-password", + "mode": {"preserve": {"recovery_code": "MPLRC1-CODE"}}, + }) + ); + + let destructive = CompletePasswordResetRequest { + mode: PasswordResetCompletion::Destructive { + acknowledge_data_loss: true, + }, + ..preserve + }; + assert_eq!( + serde_json::to_value(&destructive).unwrap()["mode"], + json!({"destructive": {"acknowledge_data_loss": true}}) + ); + + assert_eq!( + serde_json::to_value(PasswordResetOptionsRequest { + proof: reset_proof() + }) + .unwrap(), + json!({ "proof": proof }) + ); + } + + #[test] + fn recovery_status_accepts_unenrolled_and_enrolled_responses() { + let unenrolled: RecoveryCodeStatus = serde_json::from_value(json!({ + "enrolled": false, + "enrolled_at": null, + "last_used_at": null, + "use_count": 0, + })) + .unwrap(); + assert!(!unenrolled.enrolled); + assert!(unenrolled.enrolled_at.is_none()); + + let enrolled: RecoveryCodeStatus = serde_json::from_value(json!({ + "enrolled": true, + "enrolled_at": "2026-11-01T12:00:00Z", + "last_used_at": "2026-11-02T08:30:00.123456Z", + "use_count": 1, + })) + .unwrap(); + assert!(enrolled.enrolled); + assert!(enrolled.last_used_at.is_some()); + assert_eq!(enrolled.use_count, 1); + } + + #[test] + fn recovery_secrets_are_redacted_from_debug_output() { + let secrets = [ + "MPLRC1-SECRET", + "ABCD1234", + "client-reset-secret", + "new-password", + "current-password", + ]; + let debug = format!( + "{:?} {:?} {:?}", + CompletePasswordResetRequest { + proof: reset_proof(), + new_password: "new-password".to_string(), + mode: PasswordResetCompletion::Preserve { + recovery_code: "MPLRC1-SECRET".to_string(), + }, + }, + RecoveryCodePasswordRequest { + current_password: "current-password".to_string(), + }, + RecoveryCodeResponse { + recovery_code: "MPLRC1-SECRET".to_string(), + }, + ); + for secret in secrets { + assert!(!debug.contains(secret), "Debug output leaked {secret}"); + } + } + #[test] fn nullable_field_request_serialization_distinguishes_missing_and_null() { let conversation_update = ConversationUpdateRequest::default(); diff --git a/sdk/rust/tests/account_management.rs b/sdk/rust/tests/account_management.rs index fea233bba..dca2a5c85 100644 --- a/sdk/rust/tests/account_management.rs +++ b/sdk/rust/tests/account_management.rs @@ -1,6 +1,6 @@ mod common; -use maple_sdk::{OpenSecretClient, Result}; +use maple_sdk::{Error, OpenSecretClient, PasswordResetCompletion, Result}; use std::env; use uuid::Uuid; @@ -82,6 +82,139 @@ async fn test_guest_change_password_keeps_authenticated_token_state() -> Result< Ok(()) } +fn assert_api_status(result: Result, expected: u16) { + match result { + Err(Error::Api { status, .. }) => assert_eq!(status, expected), + other => panic!("expected API status {expected}, got {other:?}"), + } +} + +/// Registers a fresh email account so recovery state never touches the shared +/// test account. +async fn register_recovery_test_user(client: &OpenSecretClient, client_id: Uuid) -> Result { + let password = format!("Recovery-test-{}", Uuid::new_v4()); + client + .register( + format!("recovery-{}@local.test", Uuid::new_v4()), + password.clone(), + client_id, + None, + ) + .await?; + Ok(password) +} + +#[tokio::test] +async fn test_recovery_code_lifecycle() -> Result<()> { + let client_id = test_client_id(); + let client = setup_client().await?; + let password = register_recovery_test_user(&client, client_id).await?; + + let status = client.get_recovery_code_status().await?; + assert!(!status.enrolled); + assert!(status.enrolled_at.is_none()); + assert!(status.last_used_at.is_none()); + assert_eq!(status.use_count, 0); + + assert_api_status( + client + .enroll_recovery_code("not-the-password".to_string()) + .await, + 401, + ); + // Rotation needs an existing enrollment. + assert_api_status(client.rotate_recovery_code(password.clone()).await, 400); + + let enrolled = client.enroll_recovery_code(password.clone()).await?; + assert!(enrolled.recovery_code.starts_with("MPLRC1-")); + let status = client.get_recovery_code_status().await?; + assert!(status.enrolled); + assert!(status.enrolled_at.is_some()); + assert_eq!(status.use_count, 0); + + assert_api_status(client.enroll_recovery_code(password.clone()).await, 409); + + let rotated = client.rotate_recovery_code(password.clone()).await?; + assert!(rotated.recovery_code.starts_with("MPLRC1-")); + assert_ne!(rotated.recovery_code, enrolled.recovery_code); + assert!(client.get_recovery_code_status().await?.enrolled); + + assert_api_status( + client + .disable_recovery_code("not-the-password".to_string()) + .await, + 401, + ); + client.disable_recovery_code(password.clone()).await?; + assert!(!client.get_recovery_code_status().await?.enrolled); + // Disabling is idempotent. + client.disable_recovery_code(password).await?; + + Ok(()) +} + +#[tokio::test] +async fn test_guest_cannot_enroll_recovery_code() -> Result<()> { + let client_id = test_client_id(); + let client = setup_client().await?; + let password = format!("Recovery-guest-{}", Uuid::new_v4()); + client.register_guest(password.clone(), client_id).await?; + + assert_api_status(client.enroll_recovery_code(password).await, 400); + assert!(!client.get_recovery_code_status().await?.enrolled); + + Ok(()) +} + +#[tokio::test] +async fn test_v2_password_reset_rejects_invalid_proofs() -> Result<()> { + let client_id = test_client_id(); + let client = setup_client().await?; + let email = format!("recovery-reset-{}@local.test", Uuid::new_v4()); + + assert_api_status( + client + .get_password_reset_options( + email.clone(), + "ABCD1234".to_string(), + "not-a-reset-secret".to_string(), + client_id, + ) + .await, + 400, + ); + + for mode in [ + PasswordResetCompletion::Preserve { + recovery_code: "MPLRC1-0000".to_string(), + }, + PasswordResetCompletion::Destructive { + acknowledge_data_loss: true, + }, + PasswordResetCompletion::Destructive { + acknowledge_data_loss: false, + }, + ] { + assert_api_status( + client + .complete_password_reset( + email.clone(), + "ABCD1234".to_string(), + "not-a-reset-secret".to_string(), + "New-password-1234".to_string(), + client_id, + mode, + ) + .await, + 400, + ); + } + // A rejected completion leaves the client signed out. + assert!(client.get_access_token()?.is_none()); + + Ok(()) +} + #[tokio::test] #[ignore = "Requires email infrastructure and is destructive"] async fn test_password_reset_flow() { diff --git a/sdk/src/lib/api.ts b/sdk/src/lib/api.ts index ac027ff39..117ce2d0e 100644 --- a/sdk/src/lib/api.ts +++ b/sdk/src/lib/api.ts @@ -286,6 +286,14 @@ export async function requestPasswordReset( ); } +/** + * Completes a password reset through the legacy one-shot endpoint. + * + * This reset is always destructive: the account receives a fresh seed and + * loses its encrypted data. Use {@link fetchPasswordResetOptions} and + * {@link completePasswordReset} to offer a seed-preserving reset when the + * account has an enrolled recovery code. + */ export async function confirmPasswordReset( email: string, alphanumericCode: string, @@ -309,6 +317,218 @@ export async function confirmPasswordReset( ); } +export type PasswordResetOptions = { + recovery_enrolled: boolean; + destructive_reset_available: boolean; +}; + +/** + * How a V2 password reset completes. + * + * - `preserve` opens the enrolled seed with the account's recovery code and + * keeps all encrypted data. + * - `destructive` replaces the seed and loses all encrypted data; the caller + * must acknowledge that explicitly. + */ +export type PasswordResetCompletion = + { mode: "preserve"; recoveryCode: string } | { mode: "destructive"; acknowledgeDataLoss: true }; + +export type CompletePasswordResetResponse = { + message: string; + access_token: string; + refresh_token: string; +}; + +type PasswordResetProof = { + email: string; + alphanumeric_code: string; + plaintext_secret: string; + client_id: string; +}; + +type PasswordResetCompletionWire = + { preserve: { recovery_code: string } } | { destructive: { acknowledge_data_loss: boolean } }; + +function passwordResetCompletionWire( + completion: PasswordResetCompletion +): PasswordResetCompletionWire { + switch (completion.mode) { + case "preserve": + return { preserve: { recovery_code: completion.recoveryCode } }; + case "destructive": + return { destructive: { acknowledge_data_loss: completion.acknowledgeDataLoss } }; + } +} + +/** + * Reports which completions a pending password reset supports. + * + * The server verifies the emailed code and client secret before revealing + * whether the account has an enrolled recovery code, and does not consume the + * reset request. Every proof failure returns the same generic error. + */ +export async function fetchPasswordResetOptions( + email: string, + alphanumericCode: string, + plaintextSecret: string, + client_id: string +): Promise { + const optionsData: { proof: PasswordResetProof } = { + proof: { + email, + alphanumeric_code: alphanumericCode, + plaintext_secret: plaintextSecret, + client_id + } + }; + return encryptedApiCall( + `${apiUrl}/password-reset/v2/options`, + "POST", + optionsData, + undefined, + "Failed to fetch password reset options" + ); +} + +/** + * Completes a password reset and signs the user in with the new password. + * + * A preserving completion with a wrong recovery code consumes the reset + * request, so the user must request a new reset email before trying again. + * A malformed code leaves the request active. The server returns the same + * generic error for every rejection. + */ +export async function completePasswordReset( + email: string, + alphanumericCode: string, + plaintextSecret: string, + newPassword: string, + client_id: string, + completion: PasswordResetCompletion +): Promise { + const targetApiUrl = apiUrl; + const expected = snapshotTransportV2Auth(targetApiUrl, "user"); + const completeData: { + proof: PasswordResetProof; + new_password: string; + mode: PasswordResetCompletionWire; + } = { + proof: { + email, + alphanumeric_code: alphanumericCode, + plaintext_secret: plaintextSecret, + client_id + }, + new_password: newPassword, + mode: passwordResetCompletionWire(completion) + }; + const response = await encryptedApiCall( + `${targetApiUrl}/password-reset/v2/complete`, + "POST", + completeData, + undefined, + "Failed to complete password reset" + ); + installTransportV2Credentials( + targetApiUrl, + "user", + response.access_token, + response.refresh_token, + expected + ); + return response; +} + +export type RecoveryCodeStatus = { + enrolled: boolean; + enrolled_at: string | null; + /** Last seed-preserving password reset that used the enrolled code. */ + last_used_at: string | null; + use_count: number; +}; + +export type RecoveryCodeResponse = { + /** Shown once; the server cannot display it again. */ + recovery_code: string; +}; + +/** + * Fetches the recovery-code enrollment status for the signed-in user. + */ +export async function fetchRecoveryCodeStatus( + expectedUserId?: string +): Promise { + return authenticatedApiCall( + `${apiUrl}/protected/recovery-code`, + "GET", + undefined, + "Failed to fetch recovery code status", + expectedUserId + ); +} + +/** + * Enrolls a recovery code that can preserve encrypted data across a password + * reset. Only email-and-password accounts can enroll. + * + * @param currentPassword - The user's current password, required as a step-up + * @returns The new recovery code, which the server returns only once + * @throws An error with status 409 when a recovery code is already enrolled + */ +export async function enrollRecoveryCode( + currentPassword: string, + expectedUserId?: string +): Promise { + const enrollData = { current_password: currentPassword }; + return authenticatedApiCall( + `${apiUrl}/protected/recovery-code/enroll`, + "POST", + enrollData, + "Failed to enroll recovery code", + expectedUserId + ); +} + +/** + * Replaces the enrolled recovery code. The previous code stops working. + * + * @param currentPassword - The user's current password, required as a step-up + * @returns The replacement recovery code, which the server returns only once + */ +export async function rotateRecoveryCode( + currentPassword: string, + expectedUserId?: string +): Promise { + const rotateData = { current_password: currentPassword }; + return authenticatedApiCall( + `${apiUrl}/protected/recovery-code/rotate`, + "POST", + rotateData, + "Failed to rotate recovery code", + expectedUserId + ); +} + +/** + * Removes the enrolled recovery code. Later password resets are destructive. + * Succeeds when no code is enrolled. + * + * @param currentPassword - The user's current password, required as a step-up + */ +export async function disableRecoveryCode( + currentPassword: string, + expectedUserId?: string +): Promise { + const disableData = { current_password: currentPassword }; + await authenticatedApiCall( + `${apiUrl}/protected/recovery-code`, + "DELETE", + disableData, + "Failed to disable recovery code", + expectedUserId + ); +} + export async function changePassword(currentPassword: string, newPassword: string): Promise { const targetApiUrl = apiUrl; const pcrConfig = snapshotPcrConfig(apiPcrConfig); diff --git a/sdk/src/lib/index.ts b/sdk/src/lib/index.ts index 76ca6890d..4551d6e98 100644 --- a/sdk/src/lib/index.ts +++ b/sdk/src/lib/index.ts @@ -2,6 +2,11 @@ export type { LoginResponse, UserResponse, + PasswordResetOptions, + PasswordResetCompletion, + CompletePasswordResetResponse, + RecoveryCodeStatus, + RecoveryCodeResponse, GithubAuthResponse, GoogleAuthResponse, NativeHandoffGrantResponse, diff --git a/sdk/src/lib/main.tsx b/sdk/src/lib/main.tsx index 28edb082d..79af94927 100644 --- a/sdk/src/lib/main.tsx +++ b/sdk/src/lib/main.tsx @@ -142,6 +142,63 @@ export type OpenSecretContextType = { plaintextSecret: string, newPassword: string ) => Promise; + /** + * Reports which completions a pending password reset supports + * @param email - The email address the reset was requested for + * @param alphanumericCode - The code from the password reset email + * @param plaintextSecret - The plaintext secret hashed in requestPasswordReset + * @returns Whether the account has an enrolled recovery code + * @throws {Error} With the same generic message for every invalid proof + * + * The reset request stays active, so this can be called before choosing + * how to complete the reset. + */ + getPasswordResetOptions: ( + email: string, + alphanumericCode: string, + plaintextSecret: string + ) => Promise; + /** + * Completes a password reset and signs the user in with the new password + * @param email - The email address the reset was requested for + * @param alphanumericCode - The code from the password reset email + * @param plaintextSecret - The plaintext secret hashed in requestPasswordReset + * @param newPassword - The new password + * @param completion - Preserve data with the recovery code, or acknowledge data loss + * @throws {Error} With the same generic message for every rejection + * + * A well-formed but wrong recovery code consumes the reset request; the + * user must request a new reset email before trying again. + */ + completePasswordReset: ( + email: string, + alphanumericCode: string, + plaintextSecret: string, + newPassword: string, + completion: api.PasswordResetCompletion + ) => Promise; + /** + * Fetches whether the signed-in user has an enrolled recovery code + */ + getRecoveryCodeStatus: () => Promise; + /** + * Enrolls a recovery code for the signed-in email-and-password user + * @param currentPassword - The user's current password + * @returns The recovery code; the server never returns it again + * @throws {Error} With status 409 when a recovery code is already enrolled + */ + enrollRecoveryCode: (currentPassword: string) => Promise; + /** + * Replaces the enrolled recovery code; the previous code stops working + * @param currentPassword - The user's current password + * @returns The replacement code; the server never returns it again + */ + rotateRecoveryCode: (currentPassword: string) => Promise; + /** + * Removes the enrolled recovery code, so later password resets are destructive + * @param currentPassword - The user's current password + */ + disableRecoveryCode: (currentPassword: string) => Promise; /** * Initiates the account deletion process for logged-in users * @param hashedSecret - Client-side hashed secret for verification @@ -884,6 +941,20 @@ export const OpenSecretContext = createContext({ refreshAccessToken: api.refreshToken, requestPasswordReset: async () => {}, confirmPasswordReset: async () => {}, + getPasswordResetOptions: async () => ({ + recovery_enrolled: false, + destructive_reset_available: false + }), + completePasswordReset: async () => {}, + getRecoveryCodeStatus: async () => ({ + enrolled: false, + enrolled_at: null, + last_used_at: null, + use_count: 0 + }), + enrollRecoveryCode: async () => ({ recovery_code: "" }), + rotateRecoveryCode: async () => ({ recovery_code: "" }), + disableRecoveryCode: async () => {}, requestAccountDeletion: async () => {}, confirmAccountDeletion: async () => {}, initiateGitHubAuth: async () => ({ auth_url: "", csrf_token: "" }), @@ -1137,6 +1208,26 @@ export function OpenSecretProvider({ } } + async function completePasswordReset( + email: string, + alphanumericCode: string, + plaintextSecret: string, + newPassword: string, + completion: api.PasswordResetCompletion + ) { + await api.completePasswordReset( + email, + alphanumericCode, + plaintextSecret, + newPassword, + clientId, + completion + ); + // Clear API key on the new sign-in to ensure user-scoped keys + setApiKey(undefined); + await fetchUser(); + } + async function signUp(email: string, password: string, inviteCode: string, name?: string) { try { await api.fetchSignUp(email, password, inviteCode, clientId, name || null); @@ -1307,6 +1398,16 @@ export function OpenSecretProvider({ plaintextSecret: string, newPassword: string ) => api.confirmPasswordReset(email, alphanumericCode, plaintextSecret, newPassword, clientId), + getPasswordResetOptions: (email, alphanumericCode, plaintextSecret) => + api.fetchPasswordResetOptions(email, alphanumericCode, plaintextSecret, clientId), + completePasswordReset, + getRecoveryCodeStatus: () => api.fetchRecoveryCodeStatus(authenticatedUserId), + enrollRecoveryCode: (currentPassword) => + api.enrollRecoveryCode(currentPassword, authenticatedUserId), + rotateRecoveryCode: (currentPassword) => + api.rotateRecoveryCode(currentPassword, authenticatedUserId), + disableRecoveryCode: (currentPassword) => + api.disableRecoveryCode(currentPassword, authenticatedUserId), requestAccountDeletion: (hashedSecret) => api.requestAccountDeletion(hashedSecret, authenticatedUserId), confirmAccountDeletion: (confirmationCode, plaintextSecret) => diff --git a/sdk/src/lib/test/integration/api.test.ts b/sdk/src/lib/test/integration/api.test.ts index e0e3901fa..a336af944 100644 --- a/sdk/src/lib/test/integration/api.test.ts +++ b/sdk/src/lib/test/integration/api.test.ts @@ -17,7 +17,13 @@ import { getInstruction, updateInstruction, deleteInstruction, - setDefaultInstruction + setDefaultInstruction, + fetchRecoveryCodeStatus, + enrollRecoveryCode, + rotateRecoveryCode, + disableRecoveryCode, + fetchPasswordResetOptions, + completePasswordReset } from "../../api"; import { readTransportV2Credentials } from "../../transportV2/auth"; @@ -432,3 +438,84 @@ test("Instructions API - Error cases", async () => { expect(error.message).toBeDefined(); } }); + +async function expectStatus(promise: Promise, status: number) { + await expect(promise).rejects.toMatchObject({ status }); +} + +test("Recovery code enroll, rotate, and disable", async () => { + // A fresh account keeps recovery state off the shared test user. + const password = `Recovery-test-${crypto.randomUUID()}`; + await fetchSignUp( + `recovery-${crypto.randomUUID()}@local.test`, + password, + "", + TEST_CLIENT_ID!, + null + ); + + expect(await fetchRecoveryCodeStatus()).toEqual({ + enrolled: false, + enrolled_at: null, + last_used_at: null, + use_count: 0 + }); + + await expectStatus(enrollRecoveryCode("not-the-password"), 401); + // Rotation needs an existing enrollment. + await expectStatus(rotateRecoveryCode(password), 400); + + const enrolled = await enrollRecoveryCode(password); + expect(enrolled.recovery_code).toStartWith("MPLRC1-"); + const status = await fetchRecoveryCodeStatus(); + expect(status.enrolled).toBe(true); + expect(status.enrolled_at).toBeString(); + expect(status.use_count).toBe(0); + + await expectStatus(enrollRecoveryCode(password), 409); + + const rotated = await rotateRecoveryCode(password); + expect(rotated.recovery_code).toStartWith("MPLRC1-"); + expect(rotated.recovery_code).not.toBe(enrolled.recovery_code); + + await expectStatus(disableRecoveryCode("not-the-password"), 401); + await disableRecoveryCode(password); + expect((await fetchRecoveryCodeStatus()).enrolled).toBe(false); + // Disabling is idempotent. + await disableRecoveryCode(password); +}); + +test("Guest cannot enroll a recovery code", async () => { + await fetchGuestSignUp(TEST_PASSWORD!, "", TEST_CLIENT_ID!); + + await expectStatus(enrollRecoveryCode(TEST_PASSWORD!), 400); + expect((await fetchRecoveryCodeStatus()).enrolled).toBe(false); +}); + +test("V2 password reset rejects an invalid proof", async () => { + const email = `recovery-reset-${crypto.randomUUID()}@local.test`; + const before = readTransportV2Credentials(API_URL!, "user"); + + await expectStatus( + fetchPasswordResetOptions(email, "ABCD1234", "not-a-reset-secret", TEST_CLIENT_ID!), + 400 + ); + for (const completion of [ + { mode: "preserve", recoveryCode: "MPLRC1-0000" }, + { mode: "destructive", acknowledgeDataLoss: true } + ] as const) { + await expectStatus( + completePasswordReset( + email, + "ABCD1234", + "not-a-reset-secret", + "New-password-1234", + TEST_CLIENT_ID!, + completion + ), + 400 + ); + } + // A rejected completion leaves the stored credentials unchanged. + expect(readTransportV2Credentials(API_URL!, "user")).toEqual(before); +}); diff --git a/sdk/src/lib/test/recoveryCredentials.test.ts b/sdk/src/lib/test/recoveryCredentials.test.ts new file mode 100644 index 000000000..5b6c786fc --- /dev/null +++ b/sdk/src/lib/test/recoveryCredentials.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + completePasswordReset, + confirmPasswordReset, + disableRecoveryCode, + enrollRecoveryCode, + fetchPasswordResetOptions, + fetchRecoveryCodeStatus, + getApiPcrConfig, + getApiUrl, + rotateRecoveryCode, + setApiUrl +} from "../api"; +import type { PcrConfig } from "../pcr"; +import { installTransportV2Credentials, readTransportV2Credentials } from "../transportV2/auth"; +import { transportV2Runtime, type TransportV2RuntimeRequest } from "../transportV2/runtime"; + +const API_URL = "https://recovery.example.test/backend"; +const CLIENT_ID = "00000000-0000-4000-8000-000000000001"; +const EMAIL = "user@example.test"; +const RESET_CODE = "ABCD1234"; +const RESET_SECRET = "client-reset-secret"; +const RECOVERY_CODE = + "MPLRC1-0000-0000-0000-0000-0000-0000-0000-0000-0000-0000-0000-0000-0000-39YZ-VTQZ"; +const PROOF = { + email: EMAIL, + alphanumeric_code: RESET_CODE, + plaintext_secret: RESET_SECRET, + client_id: CLIENT_ID +}; + +type SeenRequest = { + request: TransportV2RuntimeRequest["request"]; + body: unknown; +}; + +let seen: SeenRequest[]; +let respond: () => Response; +let restoreRequest: () => void; +let previousApiUrl: string; +let previousPcrConfig: PcrConfig; + +function base64Url(value: string | Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function userToken(purpose: "access" | "refresh", principalId: string): string { + const audience = + purpose === "access" + ? "urn:opensecret:internal:transport-v2:user:access-token" + : "urn:opensecret:internal:transport-v2:user:refresh-token"; + return [ + base64Url(JSON.stringify({ alg: "ES256K", typ: "JWT" })), + base64Url(JSON.stringify({ aud: audience, sub: principalId, exp: 2_000_000_000, tf: 2 })), + base64Url(new Uint8Array(64).fill(0x5a)) + ].join("."); +} + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { "content-type": "application/json", ...init?.headers } + }); +} + +beforeEach(() => { + globalThis.localStorage.clear(); + previousApiUrl = getApiUrl(); + previousPcrConfig = getApiPcrConfig(); + setApiUrl(API_URL, { environment: "development" }); + seen = []; + respond = () => jsonResponse({}); + const request = spyOn(transportV2Runtime, "request").mockImplementation(async (input) => { + // The encrypted API clears request bytes after the call; keep a copy. + const body = input.request.body ? new Uint8Array(input.request.body) : undefined; + seen.push({ + request: { ...input.request, body }, + body: body ? JSON.parse(new TextDecoder().decode(body)) : undefined + }); + return { response: respond(), rememberOAuthContinuation() {} }; + }); + restoreRequest = () => request.mockRestore(); +}); + +afterEach(() => { + restoreRequest(); + setApiUrl(previousApiUrl, previousPcrConfig); + globalThis.localStorage.clear(); +}); + +describe("V2 password reset", () => { + test("options sends the reset proof anonymously", async () => { + respond = () => jsonResponse({ recovery_enrolled: true, destructive_reset_available: true }); + + const options = await fetchPasswordResetOptions(EMAIL, RESET_CODE, RESET_SECRET, CLIENT_ID); + + expect(options).toEqual({ recovery_enrolled: true, destructive_reset_available: true }); + expect(seen).toHaveLength(1); + expect(seen[0].request).toMatchObject({ method: "POST", target: "/password-reset/v2/options" }); + expect(seen[0].request.credential).toBeUndefined(); + expect(seen[0].body).toEqual({ proof: PROOF }); + }); + + test("preserving completion sends the recovery code and installs the new tokens", async () => { + const accessToken = userToken("access", "user-1"); + const refreshToken = userToken("refresh", "user-1"); + respond = () => + jsonResponse({ + message: "Password reset successful.", + access_token: accessToken, + refresh_token: refreshToken + }); + + await completePasswordReset(EMAIL, RESET_CODE, RESET_SECRET, "new-password", CLIENT_ID, { + mode: "preserve", + recoveryCode: RECOVERY_CODE + }); + + expect(seen[0].request).toMatchObject({ + method: "POST", + target: "/password-reset/v2/complete" + }); + expect(seen[0].request.credential).toBeUndefined(); + expect(seen[0].body).toEqual({ + proof: PROOF, + new_password: "new-password", + mode: { preserve: { recovery_code: RECOVERY_CODE } } + }); + expect(readTransportV2Credentials(API_URL, "user")).toMatchObject({ + principalId: "user-1", + accessToken, + refreshToken + }); + }); + + test("destructive completion sends the data-loss acknowledgment", async () => { + respond = () => + jsonResponse({ + message: "Password reset successful.", + access_token: userToken("access", "user-1"), + refresh_token: userToken("refresh", "user-1") + }); + + await completePasswordReset(EMAIL, RESET_CODE, RESET_SECRET, "new-password", CLIENT_ID, { + mode: "destructive", + acknowledgeDataLoss: true + }); + + expect(seen[0].body).toEqual({ + proof: PROOF, + new_password: "new-password", + mode: { destructive: { acknowledge_data_loss: true } } + }); + }); + + test("a rejected completion keeps its status and installs no credentials", async () => { + respond = () => jsonResponse({ error: "Bad Request" }, { status: 400 }); + + const error = await completePasswordReset( + EMAIL, + RESET_CODE, + RESET_SECRET, + "new-password", + CLIENT_ID, + { mode: "preserve", recoveryCode: RECOVERY_CODE } + ).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ status: 400 }); + expect(readTransportV2Credentials(API_URL, "user")).toBeNull(); + }); + + test("the legacy confirm payload carries no recovery field", async () => { + await confirmPasswordReset(EMAIL, RESET_CODE, RESET_SECRET, "new-password", CLIENT_ID); + + expect(seen[0].request.target).toBe("/password-reset/confirm"); + expect(seen[0].body).not.toHaveProperty("recovery_code"); + }); +}); + +describe("recovery code management", () => { + beforeEach(() => { + installTransportV2Credentials( + API_URL, + "user", + userToken("access", "user-1"), + userToken("refresh", "user-1") + ); + }); + + test("status is an authenticated GET without a body", async () => { + const status = { + enrolled: true, + enrolled_at: "2026-11-01T00:00:00Z", + last_used_at: null, + use_count: 0 + }; + respond = () => jsonResponse(status); + + expect(await fetchRecoveryCodeStatus()).toEqual(status); + expect(seen[0].request).toMatchObject({ + method: "GET", + target: "/protected/recovery-code", + credential: { kind: "bearer" } + }); + expect(seen[0].body).toBeUndefined(); + }); + + test("enroll and rotate send the current password and return the one-time code", async () => { + respond = () => jsonResponse({ recovery_code: RECOVERY_CODE }); + + expect(await enrollRecoveryCode("current-password")).toEqual({ recovery_code: RECOVERY_CODE }); + expect(await rotateRecoveryCode("current-password")).toEqual({ recovery_code: RECOVERY_CODE }); + + expect(seen.map(({ request }) => [request.method, request.target])).toEqual([ + ["POST", "/protected/recovery-code/enroll"], + ["POST", "/protected/recovery-code/rotate"] + ]); + for (const { request, body } of seen) { + expect(request.credential).toMatchObject({ kind: "bearer" }); + expect(body).toEqual({ current_password: "current-password" }); + } + }); + + test("disable sends a DELETE with the current password", async () => { + respond = () => jsonResponse({ message: "Recovery disabled successfully" }); + + await disableRecoveryCode("current-password"); + + expect(seen[0].request).toMatchObject({ + method: "DELETE", + target: "/protected/recovery-code", + credential: { kind: "bearer" } + }); + expect(seen[0].body).toEqual({ current_password: "current-password" }); + }); + + test("an existing enrollment surfaces as a 409", async () => { + respond = () => jsonResponse({ error: "Conflict" }, { status: 409 }); + + await expect(enrollRecoveryCode("current-password")).rejects.toMatchObject({ status: 409 }); + }); + + test("a different signed-in account is rejected before sending", async () => { + await expect(enrollRecoveryCode("current-password", "user-2")).rejects.toThrow(); + expect(seen).toHaveLength(0); + }); +}); From 1dfce1f99f30144b06cff69c669887882ab0beff Mon Sep 17 00:00:00 2001 From: w3irdrobot Date: Mon, 2 Nov 2026 20:14:09 +0000 Subject: [PATCH 2/2] chore(sdk): bump to @mapleai/sdk 4.3.0 and maple-sdk 4.2.0 Update the local-path consumer lockfiles to the new crate version. Published npm pins in the apps are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/Cargo.lock | 2 +- apps/maple-research/frontend/src-tauri/Cargo.lock | 2 +- proxy/Cargo.lock | 2 +- sdk/package.json | 2 +- sdk/rust/Cargo.lock | 2 +- sdk/rust/Cargo.toml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index e47c1f9a6..3fec8f8f0 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "maple-sdk" -version = "4.1.3" +version = "4.2.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/apps/maple-research/frontend/src-tauri/Cargo.lock b/apps/maple-research/frontend/src-tauri/Cargo.lock index c89ea8d04..a33ec5f02 100644 --- a/apps/maple-research/frontend/src-tauri/Cargo.lock +++ b/apps/maple-research/frontend/src-tauri/Cargo.lock @@ -3684,7 +3684,7 @@ dependencies = [ [[package]] name = "maple-sdk" -version = "4.1.3" +version = "4.2.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/proxy/Cargo.lock b/proxy/Cargo.lock index d1e608818..555dbf920 100644 --- a/proxy/Cargo.lock +++ b/proxy/Cargo.lock @@ -1546,7 +1546,7 @@ dependencies = [ [[package]] name = "maple-sdk" -version = "4.1.3" +version = "4.2.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/sdk/package.json b/sdk/package.json index e40f7bb2a..b792e73b8 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mapleai/sdk", - "version": "4.2.3", + "version": "4.3.0", "packageManager": "bun@1.3.5", "license": "MIT", "homepage": "https://github.com/MaplePrivacyLabs/Maple/tree/master/sdk", diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index cea101470..a92bca7b3 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -1227,7 +1227,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "maple-sdk" -version = "4.1.3" +version = "4.2.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 54a269e0e..9aaf1e4e8 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maple-sdk" -version = "4.1.3" +version = "4.2.0" edition = "2021" authors = ["Maple Privacy Labs"] description = "Maple Rust SDK for secure AI APIs, encrypted sessions, and Nitro attestation"