aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js28
2 files changed, 46 insertions, 6 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index a994acb..7046c2f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -728,6 +728,14 @@ async def get_current_user_info(
class UpdateProfileRequest(BaseModel):
email: str | None = None
+ # Required to change the address on file. Changing it is the first step of
+ # an account takeover from a bare access token: the confirmation code goes
+ # to the new (attacker) address, and a verified address then unlocks the
+ # passphrase-reset path. A live access token is not enough for that — the
+ # passphrase is, exactly as for `change_password` and `delete_own_account`.
+ # This matters because a member hands its access token to every node it
+ # connects to (the MNP handshake), so a node operator holds one.
+ auth_key: str | None = None
@field_validator("email")
@classmethod
@@ -768,6 +776,22 @@ async def update_profile(
new_email = body.email.strip()
eh = hash_email_blind(new_email)
+ # Changing the address on file requires the passphrase, not merely a
+ # live token. Same second factor, and the same throttle, as a passphrase
+ # change or an account deletion — the hub still never sees the
+ # passphrase, only the derived auth_key.
+ if not body.auth_key:
+ raise HTTPException(
+ status_code=403,
+ detail="Changing your e-mail requires your passphrase.")
+ await _take_login_attempt(db, current_user.username)
+ if not await verify_password_off_loop(
+ body.auth_key, current_user.pw_hash, current_user.pw_salt,
+ current_user.pw_version):
+ raise HTTPException(status_code=403,
+ detail="Passphrase does not match")
+ await login_throttle.clear(db, current_user.username)
+
# How often one account may point the hub at a *different* address.
# Long, because this is the only path where a signed-in account chooses
# who receives a message, and a short delay alone still allows one
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index ed7a2fa..d3d06d7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -37,6 +37,10 @@ export function ProfilePage({ user, onLogout }) {
const [emailVerifyPending, setEmailVerifyPending] = useState(false);
const [emailCode, setEmailCode] = useState('');
const [emailVerifying, setEmailVerifying] = useState(false);
+ // Changing the address on file now needs the passphrase (the hub verifies the
+ // derived auth_key): a bare access token — which every node this account
+ // connects to is handed — must not be able to start an account takeover.
+ const [emailPass, setEmailPass] = useState('');
const [pinCount, setPinCount] = useState(
() => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
const [delOpen, setDelOpen] = useState(false);
@@ -232,12 +236,18 @@ export function ProfilePage({ user, onLogout }) {
const saveEmail = useCallback(async () => {
const val = emailDraft.trim();
if (!val || val === email) { setEmailEditing(false); return; }
+ if (!emailPass) return; // the Save button is disabled until it is entered
setEmailSaving(true);
setEmailStatus('');
try {
+ // The passphrase is the second factor here, exactly as for a passphrase
+ // change or an account deletion: the hub gets the derived auth_key, never
+ // the passphrase itself.
+ const authKey = await window.MeshBayKeys.deriveAuthKey(emailPass, user.username);
const resp = await hubFetch('/v1/users/me', {
- method: 'PATCH', token: user.token, body: { email: val },
+ method: 'PATCH', token: user.token, body: { email: val, auth_key: authKey },
});
+ setEmailPass('');
if (resp.email_verification_required) {
setEmailVerifyPending(true);
setEmailStatus(t('settings.email_code_sent'));
@@ -248,11 +258,13 @@ export function ProfilePage({ user, onLogout }) {
setTimeout(() => setEmailStatus(''), 3000);
}
} catch (e) {
- setEmailStatus(e.message);
+ const msg = /403|does not match/i.test(e.message)
+ ? t('settings.pw_wrong_current') : e.message;
+ setEmailStatus(msg);
} finally {
setEmailSaving(false);
}
- }, [emailDraft, email, user.token]);
+ }, [emailDraft, email, emailPass, user.token, user.username]);
const verifyEmailChange = useCallback(async () => {
if (!emailCode.trim()) return;
@@ -311,14 +323,18 @@ export function ProfilePage({ user, onLogout }) {
? html`<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="email" value=${emailDraft}
onInput=${e => setEmailDraft(e.target.value)}
- onKeyDown=${e => e.key === 'Enter' && saveEmail()}
disabled=${emailVerifyPending}
style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
${!emailVerifyPending && html`
+ <input type="password" autocomplete="current-password"
+ placeholder=${t('login.password')}
+ value=${emailPass} onInput=${e => setEmailPass(e.target.value)}
+ onKeyDown=${e => e.key === 'Enter' && saveEmail()}
+ style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
<button class="admin-btn" onClick=${saveEmail}
- disabled=${emailSaving}>${t('settings.email_save')}</button>
+ disabled=${emailSaving || !emailPass}>${t('settings.email_save')}</button>
<button class="btn-secondary" onClick=${() => {
- setEmailEditing(false); setEmailDraft(email);
+ setEmailEditing(false); setEmailDraft(email); setEmailPass('');
}}>${t('settings.cancel')}</button>
`}
</span>`