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
62 changes: 59 additions & 3 deletions capsules/home/browser/shell-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -980,16 +980,28 @@ async function runPasskeySignIn() {

// One plain reason and one next step per failed sign-in; never a status code
// or server text. Stages: start (begin or status), passkey (the browser),
// verify (Home checks the passkey), open (Home opens after sign-in).
// verify (Home checks the passkey), open (Home opens after sign-in). Home
// names its reason (see SignInFailure in auth_gateway.rs); without one, the
// stage and status decide.
function signInFailure(stage, error) {
const status = Number(error && error.status) || 0;
const name = String(error && error.name || "");
const reason = signInFailureReason(error);
let message;
let tone = "error";
if (stage === "open") {
message = "Home could not open after sign-in. Reload this page.";
} else if (status === 429) {
} else if (status === 429 || reason === "busy") {
message = "Sign-in is busy right now. Wait a minute, then try again.";
} else if (reason === "home_error") {
message = "Home could not finish sign-in. Try again in a moment.";
} else if (stage === "verify" && reason === "unknown_passkey") {
message = "Home does not know this passkey. Use the passkey you created for this Home.";
} else if (stage === "verify" && reason === "removed_passkey") {
message = "This passkey was removed from this Home. Use another passkey for this Home.";
} else if (stage === "verify" && reason === "expired") {
message = "Sign-in took too long. Select your account to try again.";
tone = "muted";
} else if (stage === "passkey") {
if (name === "NotAllowedError" || name === "AbortError") {
message = "No passkey was used. Select your account to try again.";
Expand All @@ -1009,6 +1021,15 @@ function signInFailure(stage, error) {
return Object.assign(new Error(message), { signInFailure: true, tone });
}

function signInFailureReason(error) {
try {
const body = JSON.parse(String(error && error.detail || ""));
return body?.schema === "elastos.auth.passkey.sign-in.failure/v1" ? readText(body.reason) : "";
} catch (_error) {
return "";
}
}

async function unlockComplete(response, flow = null) {
setUnlockStatus("Opening Home", "success");
if (unlockCallback) {
Expand All @@ -1033,7 +1054,42 @@ function reportUnlockError(error) {
setUnlockStatus(error.message, error.tone);
return;
}
setUnlockStatus(String(error.message || error), "error");
setUnlockStatus(unlockFailureMessage(error), "error");
}

// Setup, guest passkey and recovery failures are one plain sentence too. The
// flows' own sentences pass through; a browser or server error never does
// (the same rule as System's publicSystemError).
function unlockFailureMessage(error) {
const status = Number(error && error.status) || 0;
const name = String(error && error.name || "");
const message = readText(error && error.message ? error.message : String(error || ""));
if (name === "NotAllowedError" || name === "AbortError") {
return "The passkey prompt was closed. Select the button to try again.";
}
if (name === "InvalidStateError") {
return "This device already has a passkey for this Home. Sign in with it, or use another device.";
}
if (name === "SecurityError") {
return "Passkeys do not work at this address. Open Home at the address where you created your passkey.";
}
if (status === 429) {
return "Home is busy right now. Wait a minute, then try again.";
}
if (status === 401 || status === 403) {
return "Home did not allow this. Ask the owner of this Home, then try again.";
}
if (status) {
return "Home could not finish this step. Try again in a moment.";
}
if (error instanceof TypeError) {
return "Home is not responding. Check that ElastOS is running, then try again.";
}
if (name !== "Error" || !message
|| /\b(request failed|failed to fetch|schema|[45]\d\d)\b/i.test(message)) {
return "Home could not finish this step. Try again in a moment.";
}
return message;
}

function setButtonsDisabled(disabled) {
Expand Down
106 changes: 106 additions & 0 deletions capsules/home/browser/shell-auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,109 @@ test("a refused or cancelled passkey add is one plain sentence", async () => {
message: "Home did not add the passkey. Select Add passkey to try again.",
});
});

const signInRefusal = (status, reason) => () => ({
status,
body: { schema: "elastos.auth.passkey.sign-in.failure/v1", reason },
});

test("Home's own reason picks the sentence when it refuses a passkey", async () => {
routes["/api/auth/passkey/authenticate/begin"] = begin;
credentials = { get: async () => fakeCredential() };
const cases = [
[403, "removed_passkey", "This passkey was removed from this Home. Use another passkey for this Home.", "error"],
[403, "unknown_passkey", "Home does not know this passkey. Use the passkey you created for this Home.", "error"],
[403, "expired", "Sign-in took too long. Select your account to try again.", "muted"],
[403, "refused", "Home did not accept this passkey. Use the passkey you created for this Home.", "error"],
[500, "home_error", "Home could not finish sign-in. Try again in a moment.", "error"],
[429, "busy", "Sign-in is busy right now. Wait a minute, then try again.", "error"],
];
for (const [status, reason, message, tone] of cases) {
routes["/api/auth/passkey/authenticate/complete"] = signInRefusal(status, reason);
assert.equal(await signInOnLockFace(), message, reason);
assert.equal(faceStatus.dataset.tone, tone, reason);
}
// An answer that is not Home's sign-in reason falls back to the stage.
routes["/api/auth/passkey/authenticate/complete"] = () => ({
status: 403,
body: { schema: "elastos.other/v1", reason: "removed_passkey" },
});
assert.equal(
await signInOnLockFace(),
"Home did not accept this passkey. Use the passkey you created for this Home.",
);
});

test("a passkey the browser refuses at this address names the address", async () => {
routes["/api/auth/passkey/authenticate/begin"] = begin;
credentials = {
get: async () => { throw Object.assign(new Error("The relying party ID is not a registrable domain"), { name: "SecurityError" }); },
};
assert.equal(
await signInOnLockFace(),
"Passkeys do not work at this address. Open Home at the address where you created your passkey.",
);
});

test("a sign-in that Home cannot open asks for a reload", async () => {
routes["/api/auth/passkey/authenticate/begin"] = begin;
routes["/api/auth/passkey/authenticate/complete"] = () => ({ body: { schema: "elastos.auth.passkey.verify/v1" } });
credentials = { get: async () => fakeCredential() };
await showHomeUnlock(() => { throw new Error("shell attach failed"); });
assert.equal(await signInOnLockFace(), "Home could not open after sign-in. Reload this page.");
});

test("a lock screen that cannot reach Home says so before any passkey prompt", async () => {
routes["/api/auth/passkey/status"] = () => ({ status: 500, body: "auth state unavailable" });
await showHomeUnlock(() => {});
assert.equal(faceStatus.hidden, false);
assert.equal(faceStatus.textContent, "Home could not start sign-in. Try again in a moment.");
delete routes["/api/auth/passkey/status"];
await showHomeUnlock(() => {});
assert.equal(
faceStatus.textContent,
"Home is not responding. Check that ElastOS is running, then try again.",
);
});

test("guest passkey setup shows plain sentences, never browser or server text", async () => {
routes["/api/auth/passkey/status"] = () => ({ body: { registered: true, guest_registration_enabled: true } });
await showHomeUnlock(() => {});
const status = element("#home-unlock-status");
const createGuest = async () => {
element("#home-unlock-create").click();
element("#home-unlock-primary").click();
await settle();
assert.doesNotMatch(status.textContent, /\d{3}|request failed|registration is disabled|timed out or was not allowed/i);
return status.textContent;
};

// The flow's own sentence passes through.
element("#home-unlock-name").value = "";
assert.equal(await createGuest(), "Enter the display name people will see.");

element("#home-unlock-name").value = "Guest";
routes["/api/auth/passkey/register/begin"] = () => ({ status: 403, body: "guest passkey registration is disabled" });
assert.equal(await createGuest(), "Home did not allow this. Ask the owner of this Home, then try again.");
assert.equal(status.dataset.tone, "error");

routes["/api/auth/passkey/register/begin"] = () => ({ status: 500, body: "auth state write failed" });
assert.equal(await createGuest(), "Home could not finish this step. Try again in a moment.");

routes["/api/auth/passkey/register/begin"] = () => ({
body: {
schema: "elastos.auth.passkey.register.begin/v1",
ceremony_id: "guest-1",
options: { publicKey: { challenge: "AA", user: { id: "AQ", name: "x", displayName: "x" } } },
},
});
credentials = {
create: async () => {
throw Object.assign(new Error("The operation either timed out or was not allowed."), { name: "NotAllowedError" });
},
};
assert.equal(await createGuest(), "The passkey prompt was closed. Select the button to try again.");

delete routes["/api/auth/passkey/register/begin"];
assert.equal(await createGuest(), "Home is not responding. Check that ElastOS is running, then try again.");
});
1 change: 1 addition & 0 deletions capsules/home/browser/shell-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export async function fetchJson(url, init) {
`request failed: ${response.status} ${response.statusText}${detail ? ` ${detail}` : ""}`,
);
error.status = response.status;
error.detail = detail;
throw error;
}
return response.json();
Expand Down
10 changes: 10 additions & 0 deletions elastos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ release's Added, Changed, Fixed, Removed and Security bullets before you update;

## [Unreleased]

### Fixed

- The lock screen names the real reason a sign-in failed: a passkey this Home
removed, a passkey from another Home, a sign-in that took too long, or a Home
error. Add passkey asks the browser for a different device or security key
than the account's current passkeys, and a busy Home keeps the fresh approval
(#338).
- Setup, guest passkey and recovery failures on the lock screen show one plain
sentence instead of browser or server error text (#338).

### Developer detail

- `scripts/release-publish.sh` publishes a final version `X.Y.Z` on `stable`
Expand Down
96 changes: 76 additions & 20 deletions elastos/crates/elastos-identity/src/webauthn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,38 @@ impl std::fmt::Display for CeremonyCapacityExceeded {
}

impl std::error::Error for CeremonyCapacityExceeded {}

/// The sign-in passkey is not in this Home's credential store.
#[derive(Debug)]
pub struct UnknownCredential;

impl std::fmt::Display for UnknownCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Unknown credential")
}
}

impl std::error::Error for UnknownCredential {}

/// The sign-in challenge is gone (used, or lost on restart) or too old; a new
/// sign-in starts a new one.
#[derive(Debug)]
pub enum AuthenticationChallengeUnavailable {
Missing,
Expired,
}

impl std::fmt::Display for AuthenticationChallengeUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Missing => "No pending authentication challenge",
Self::Expired => "Authentication challenge expired",
})
}
}

impl std::error::Error for AuthenticationChallengeUnavailable {}

const FLAG_USER_PRESENT: u8 = 0x01;
const FLAG_USER_VERIFIED: u8 = 0x04;
const FLAG_ATTESTED_CREDENTIAL_DATA: u8 = 0x40;
Expand Down Expand Up @@ -294,12 +326,33 @@ impl IdentityManager {
origin: &str,
binding: [u8; 32],
) -> anyhow::Result<CreationOptions> {
self.begin_bound_principal_registration_excluding(ceremony, rp_id, origin, binding, &[])
}

/// Bound registration that asks the browser to refuse the listed
/// credentials, so an authenticator that already holds one of them
/// cannot create a second passkey for the same account.
pub fn begin_bound_principal_registration_excluding(
&mut self,
ceremony: &str,
rp_id: &str,
origin: &str,
binding: [u8; 32],
exclude_credential_ids: &[String],
) -> anyhow::Result<CreationOptions> {
let exclude = exclude_credential_ids
.iter()
.map(|id| CredentialDescriptor {
type_: "public-key".to_string(),
id: id.clone(),
})
.collect();
self.begin_registration_inner(
ceremony,
rp_id,
origin,
"ElastOS Passkey",
false,
exclude,
Some(binding),
)
}
Expand Down Expand Up @@ -358,7 +411,23 @@ impl IdentityManager {
rp_id: &str,
rp_origin: &str,
) -> anyhow::Result<CreationOptions> {
self.begin_registration_inner(session_token, rp_id, rp_origin, "ElastOS User", true, None)
let exclude = self
.store
.get_credentials()
.iter()
.map(|c| CredentialDescriptor {
type_: "public-key".to_string(),
id: c.credential_id.clone(),
})
.collect();
self.begin_registration_inner(
session_token,
rp_id,
rp_origin,
"ElastOS User",
exclude,
None,
)
}

/// Begin registration for a separate runtime principal.
Expand All @@ -377,7 +446,7 @@ impl IdentityManager {
rp_id,
rp_origin,
"ElastOS Passkey",
false,
Vec::new(),
None,
)
}
Expand All @@ -388,7 +457,7 @@ impl IdentityManager {
rp_id: &str,
rp_origin: &str,
display_name: &str,
exclude_existing: bool,
exclude: Vec<CredentialDescriptor>,
registration_binding: Option<[u8; 32]>,
) -> anyhow::Result<CreationOptions> {
self.admit_challenge(session_token, rp_id, Some(rp_origin))?;
Expand Down Expand Up @@ -416,19 +485,6 @@ impl IdentityManager {
// User ID is random for registration, real ID derived from credential after
let user_id = URL_SAFE_NO_PAD.encode(uuid::Uuid::new_v4().as_bytes());

let exclude = if exclude_existing {
self.store
.get_credentials()
.iter()
.map(|c| CredentialDescriptor {
type_: "public-key".to_string(),
id: c.credential_id.clone(),
})
.collect()
} else {
Vec::new()
};

let options = CreationOptions {
public_key: PublicKeyCredentialCreationOptions {
rp: RelyingParty {
Expand Down Expand Up @@ -703,13 +759,13 @@ impl IdentityManager {
let pending = self
.challenges
.remove(session_token)
.ok_or_else(|| anyhow::anyhow!("No pending authentication challenge"))?;
.ok_or(AuthenticationChallengeUnavailable::Missing)?;

if !matches!(pending.challenge_type, ChallengeType::Authentication) {
anyhow::bail!("Pending challenge is not an authentication");
}
if pending.created.elapsed() > CHALLENGE_EXPIRY {
anyhow::bail!("Authentication challenge expired");
return Err(AuthenticationChallengeUnavailable::Expired.into());
}

// Find the matching credential
Expand All @@ -719,7 +775,7 @@ impl IdentityManager {
.get_credentials()
.into_iter()
.find(|c| c.credential_id == *credential_id)
.ok_or_else(|| anyhow::anyhow!("Unknown credential"))?;
.ok_or(UnknownCredential)?;

// Decode and verify client data
let client_data_bytes = URL_SAFE_NO_PAD.decode(&response.response.client_data_json)?;
Expand Down
Loading
Loading