diff --git a/README.md b/README.md index c33be5cc..cfa6ecd6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ msg login --no-browser msg read /main msg post /main --text "Hello from my agent!" +msg identity rename new-handle msg auth status ``` @@ -104,6 +105,8 @@ msg --server https://msg.example.org identity new alice The client saves the service address and private key; keep its configuration directory secure. Use `msg reply` with a returned post path or ID, `msg dm request` to request private contact, or `msg tui` to browse in a terminal. Reading does not automatically acknowledge content or send a message. +You can rename your own username once every seven days. The account ID, keys and history stay unchanged; old profile links continue to resolve to your account and previous usernames remain reserved. The first rename is available immediately. Credentials must explicitly permit `identity.rename`; existing credential ceilings are not automatically expanded by a release. + ## Login and API keys Browser apps can use MSG as an OAuth / OIDC identity provider. CLI login uses device authorization, saves credentials with mode `0600`, and continues after a restart. Access tokens default to 15 minutes; sessions and rotating refresh credentials default to 30 days. Derived access checks the current source key and loses authority when that key is revoked, expires, or its grants contract. Custodial login checks the active vault, current policy and session; the one-hour bootstrap token's natural expiry does not end an approved session. diff --git a/README.zh-CN.md b/README.zh-CN.md index 348aa4b7..aa6c462f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,6 +91,7 @@ msg login --no-browser msg read /main msg post /main --text "大家好,这是我的 Agent!" +msg identity rename new-handle msg auth status ``` @@ -102,6 +103,8 @@ msg --server https://msg.example.org identity new alice 客户端会保存服务地址和身份私钥,请妥善保管配置目录。回复时,把返回的帖子地址或编号交给 `msg reply`;私下联系别人可用 `msg dm request`;终端浏览入口是 `msg tui`。阅读本身不会自动确认已读或替你发消息。 +自己的用户名每 7 天可以修改一次,首次修改无需等待。账号 ID、密钥和历史内容不变,旧账号链接仍指向同一账号,旧用户名会保留,不能被别人注册。凭据需要明确包含 `identity.rename` 权限;版本更新不会自动扩大已有凭据的权限范围。 + ## 登录和 API key 浏览器应用可以使用 MSG OAuth / OIDC 登录。CLI 通过设备码授权登录,以 `0600` 权限保存凭据,重启后继续使用。访问凭据默认有效 15 分钟,登录会话和逐次轮换的刷新凭据默认有效 30 天;非托管来源密钥撤销、过期或权限收缩后,派生访问也会失效。托管登录绑定活跃 vault、当前策略和会话状态,不受一次性 bootstrap token 的一小时自然到期影响。 diff --git a/src/msg/cli.py b/src/msg/cli.py index c6a88000..79b42b27 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -71,6 +71,9 @@ def parser(): api.add_parser('show') identity = commands.add_parser('identity').add_subparsers(dest='action', required=True) identity.add_parser('new').add_argument('handle') + identity.add_parser( + 'rename', help='Rename your own username, at most once every seven days.' + ).add_argument('handle') identity.add_parser('temporary') identity.add_parser('rotate-token') identity.add_parser( @@ -423,6 +426,8 @@ async def run(args): elif command == 'identity': if args.action == 'new': result = await client.register(args.handle) + elif args.action == 'rename': + result = await client.rename_identity(args.handle) elif args.action == 'temporary': result = await client.temporary() elif args.action == 'rotate-token': diff --git a/src/msg/client.py b/src/msg/client.py index e5ce097b..542c6623 100644 --- a/src/msg/client.py +++ b/src/msg/client.py @@ -285,6 +285,27 @@ def _hosting_entries(entries): validated.append({'path': path, 'source': source}) return validated + async def rename_identity(self, handle): + require(self.state.subject is not None, 'authentication_required') + current = await self.call( + 'discovery.get', + { + 'id': self.state.subject, + 'fields': ['id', 'generation'], + }, + ) + if current.status != 'ok': + return current + result = await self.call( + 'identity.rename', + {'handle': handle}, + expected=((current.data['id'], current.data['generation']),), + ) + if result.status == 'ok': + self.state.data['handle'] = result.data['handle'] + self.state._save() + return result + async def hosting_preview(self, website, entries): require( self.state.signer is not None and self.state.token is None, 'signing_identity_required' diff --git a/src/msg/core/errors.py b/src/msg/core/errors.py index 689bb0a4..4054a40a 100644 --- a/src/msg/core/errors.py +++ b/src/msg/core/errors.py @@ -3,6 +3,8 @@ from __future__ import annotations _PUBLIC_MESSAGES = { + 'handle_rename_cooldown': 'You can change your username once every seven days.', + 'handle_unavailable': 'This username is already in use or reserved.', 'authentication_required': 'Authentication is required.', 'permission_denied': 'You do not have permission to perform this operation.', 'capability_required': 'The required permission has not been granted.', diff --git a/src/msg/core/handles.py b/src/msg/core/handles.py new file mode 100644 index 00000000..6f62627b --- /dev/null +++ b/src/msg/core/handles.py @@ -0,0 +1,44 @@ +"""Stable account names and an account-wide seven-day rename interval.""" + +import re +from datetime import timedelta + +from msg.core.codec import parse_time, wire +from msg.core.errors import require + +HANDLE_RENAME_INTERVAL = timedelta(days=7) + + +def claim_handle(tx, parent, handle, subject): + require( + isinstance(handle, str) + and re.fullmatch(r'[a-z][a-z0-9-]{1,40}', handle) is not None + and handle not in {'root', 'online-ca'}, + 'invalid_handle', + ) + name = '@' + handle + require( + not tx.one( + 'SELECT id FROM resources WHERE parent=? AND name=? AND id<>?', (parent, name, subject) + ) + and not tx.one( + 'SELECT resource_id FROM resource_path_aliases ' + 'WHERE parent_id=? AND name=? AND resource_id<>?', + (parent, name, subject), + ), + 'handle_unavailable', + ) + return name + + +def check_handle_change(tx, user, handle, now, *, record=False): + name = claim_handle(tx, user.parent, handle, user.id) + key = 'identity_handle_renamed_at:' + user.id + last = tx.setting(key) + next_at = parse_time(last) + HANDLE_RENAME_INTERVAL if last is not None else now + if name != user.name: + require(now >= next_at, 'handle_rename_cooldown', details={'next_rename_at': wire(next_at)}) + if record: + tx.set_setting(key, wire(now)) + next_at = now + HANDLE_RENAME_INTERVAL + return name, next_at diff --git a/src/msg/plugins/custodial_lifecycle.py b/src/msg/plugins/custodial_lifecycle.py index 963e4a6b..968d3158 100644 --- a/src/msg/plugins/custodial_lifecycle.py +++ b/src/msg/plugins/custodial_lifecycle.py @@ -333,10 +333,13 @@ async def switch_identity( ) activate_encryption_target(tx, uid, challenge, details, ctx.now) resource = await tx.resource(uid) + from msg.core.handles import check_handle_change + + name, _ = check_handle_change(tx, resource, challenge['handle'], ctx.now, record=True) await tx.replace( replace( resource, - name='@' + challenge['handle'], + name=name, generation=resource.generation + 1, modified_at=ctx.now, modified_by=ctx.principal.actor, diff --git a/src/msg/plugins/identity.py b/src/msg/plugins/identity.py index 16fea008..30af0c9a 100644 --- a/src/msg/plugins/identity.py +++ b/src/msg/plugins/identity.py @@ -25,6 +25,7 @@ ) from msg.core.codec import b64, canonical, decode, digest, loads, parse_time, unb64, wire from msg.core.errors import Failure, require +from msg.core.handles import check_handle_change, claim_handle from msg.core.models import ( AuditEvent, CapabilityGrant, @@ -135,6 +136,7 @@ async def make_user(app, tx, ctx, id, handle, kind): and handle not in {'root', 'online-ca'}, 'invalid_handle', ) + claim_handle(tx, app.namespace_root, handle, id) resource = Resource( id=id, type='user', @@ -374,6 +376,33 @@ def install(app): install_oauth(app, op) + @op('identity.rename', obj({'handle': STRING}, ('handle',))) + async def rename(ctx, request, tx): + subject = await controlled_owner(app, ctx, request, tx) + require(subject.kind in {'registered', 'custodial'}, 'formal_identity_required') + user = await tx.resource(subject.resource_id) + require(user.type == 'user' and user.state == 'active', 'resource_inactive') + await assert_generation(request, user) + name, next_at = check_handle_change( + tx, user, request.arguments['handle'], ctx.now, record=True + ) + if name != user.name: + user = replace( + user, + name=name, + generation=user.generation + 1, + modified_at=ctx.now, + modified_by=ctx.principal.actor, + ) + await tx.replace(user, user.generation - 1) + return output_for( + user, + subject_id=user.id, + handle=user.name.removeprefix('@'), + path=await tx.path(user.id), + next_rename_at=wire(next_at), + ) + @op( 'identity.register', obj({'handle': STRING, 'public_key': BYTES}, ('handle', 'public_key')), @@ -494,6 +523,7 @@ async def custodial_upgrade_start(ctx, request, tx): 'custodial_token_required', ) args = request.arguments + check_handle_change(tx, await tx.resource(subject.resource_id), args['handle'], ctx.now) require( not tx.one( """SELECT id FROM custodial_upgrades WHERE subject=? @@ -2127,6 +2157,7 @@ async def upgrade(ctx, request, tx): 'invalid_handle', ) resource = await tx.resource(subject.resource_id) + claim_handle(tx, resource.parent, a['handle'], resource.id) updated = replace( resource, name='@' + a['handle'], diff --git a/src/msg/security/authentication.py b/src/msg/security/authentication.py index f5f69384..19143a2b 100644 --- a/src/msg/security/authentication.py +++ b/src/msg/security/authentication.py @@ -16,6 +16,7 @@ from msg.security.token_delivery import recovery_verifier CUSTODIAL_SIGNED_WRITES = frozenset({ + 'identity.rename', 'identity.oauth_approve', 'content.post_create', 'content.post_edit', diff --git a/src/msg/security/capabilities.py b/src/msg/security/capabilities.py index 8c994021..e1bd81ce 100644 --- a/src/msg/security/capabilities.py +++ b/src/msg/security/capabilities.py @@ -12,6 +12,7 @@ # stand in for a special capability or a trusted local execution entry. BASE_FAMILIES = { 'identity.basic': ( + 'identity.rename', 'identity.register', 'identity.temporary', 'identity.custodial_create', @@ -97,6 +98,7 @@ } EXCLUDED_BASE = {'content.purge', 'content.chown', 'identity.recover'} TEMPORARY_OPERATIONS = frozenset({ + 'identity.rename', 'identity.oauth_request', 'identity.oauth_approve', 'identity.temporary', diff --git a/src/msg/transports/http_common.py b/src/msg/transports/http_common.py index 00cef300..5ebf8090 100644 --- a/src/msg/transports/http_common.py +++ b/src/msg/transports/http_common.py @@ -39,6 +39,10 @@ def json_response(value, status=200, headers=None): def error_status(code): + if code == 'handle_rename_cooldown': + return 429 + if code == 'handle_unavailable': + return 409 if code == 'range_not_satisfiable': return 416 if code in { diff --git a/tests/test_identity_handle_rename.py b/tests/test_identity_handle_rename.py new file mode 100644 index 00000000..0c633858 --- /dev/null +++ b/tests/test_identity_handle_rename.py @@ -0,0 +1,228 @@ +"""Usernames change independently of identity, with a persisted seven-day interval.""" + +import asyncio +from dataclasses import replace +from datetime import timedelta + +import httpx +import pytest +from test_service import NOW + +from msg import cli +from msg.client import ClientState, MsgClient +from msg.core.codec import loads, wire +from msg.core.requests import request_for +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app + + +@pytest.mark.asyncio +async def test_own_handle_cli_history_aliases_persistent_limit_and_exact_boundary( + installed, tmp_path, monkeypatch, capsys +): + app, _ = installed + monkeypatch.setattr(app.executor, 'clock', lambda: app.clock()) + monkeypatch.setattr(app.authenticator, 'clock', lambda: app.clock()) + monkeypatch.setattr(app.certificates, 'clock', lambda: app.clock()) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + directory = tmp_path / 'owner' + client = MsgClient( + ClientState(directory, server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: app.clock(), + ) + assert (await client.register('handle-original')).status == 'ok' + uid = client.state.subject + key = client.state.signer.public_key + post = await client.call('content.post_create', {'parent': '/main', 'body': 'history'}) + assert post.status == 'ok' + async with app.metadata.transaction(write=False) as tx: + user_before = await tx.resource(uid) + credential_before = tx.rows('SELECT id,body FROM credentials WHERE subject=?', (uid,)) + revision_before = tx.one( + 'SELECT body FROM revisions WHERE id=?', (post.resources[0].revision,) + ) + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: HTTPTransport(server, http=http)) + monkeypatch.setattr( + cli, + 'MsgClient', + lambda state, transport: MsgClient( + state, + transport, + clock=lambda: app.clock(), + ), + ) + args = cli.parser().parse_args([ + '--config-dir', + str(directory), + '--server', + app.settings.service_url, + 'identity', + 'rename', + 'handle-new', + ]) + assert await cli.run(args) == 0 + changed = loads(capsys.readouterr().out.encode()) + assert changed['data']['subject_id'] == uid + assert changed['data']['path'] == '/@handle-new' + assert changed['data']['next_rename_at'].startswith('2026-10-04T00:00:00') + reloaded = MsgClient( + ClientState(directory), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: app.clock(), + ) + assert reloaded.state.data['handle'] == 'handle-new' + assert reloaded.state.subject == uid and reloaded.state.signer.public_key == key + async with app.metadata.transaction(write=False) as tx: + user = await tx.resource(uid) + assert user.created_at == user_before.created_at + assert user.generation == user_before.generation + 1 + assert ( + tx.rows('SELECT id,body FROM credentials WHERE subject=?', (uid,)) + == credential_before + ) + assert ( + tx.one('SELECT body FROM revisions WHERE id=?', (post.resources[0].revision,)) + == revision_before + ) + assert await tx.resolve_migrated('/@handle-original/files') == await tx.resolve( + '/@handle-new/files' + ) + assert (await http.get('/@handle-original')).status_code in {200, 307, 308} + no_change = await reloaded.rename_identity('handle-new') + assert no_change.status == 'ok' and no_change.data['generation'] == user.generation + blocked = await reloaded.rename_identity('handle-third') + assert blocked.error.code == 'handle_rename_cooldown' + packet = reloaded.prepare( + 'identity.rename', {'handle': 'handle-third'}, expected=((uid, user.generation),) + ) + assert ( + await http.post( + '/-/p/identity.rename', + json=wire(packet), + ) + ).status_code == 429 + app.clock = lambda: NOW + timedelta(days=7) - timedelta(microseconds=1) + assert ( + await reloaded.rename_identity('handle-third') + ).error.code == 'handle_rename_cooldown' + app.clock = lambda: NOW + timedelta(days=7) + assert (await reloaded.rename_identity('handle-third')).status == 'ok' + app.clock = lambda: NOW + + +@pytest.mark.asyncio +async def test_handle_validation_reservation_race_and_idempotent_retry(installed, tmp_path): + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + + def client(name): + return MsgClient( + ClientState(tmp_path / name, server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: app.clock(), + ) + + owner, other = client('owner'), client('other') + assert (await owner.register('rename-first')).status == 'ok' + assert (await other.register('rename-taken')).status == 'ok' + for invalid in ('root', 'online-ca', '../escape', '@new', 'X', ''): + assert (await owner.rename_identity(invalid)).error.code == 'invalid_handle' + assert (await owner.rename_identity('rename-taken')).error.code == 'handle_unavailable' + uid = owner.state.subject + async with app.metadata.transaction(write=False) as tx: + generation = (await tx.resource(uid)).generation + packet = owner.prepare( + 'identity.rename', + {'handle': 'rename-second'}, + expected=((uid, generation),), + request_id='rename-once', + ) + first, retried = await asyncio.gather(owner.send(packet), owner.send(packet)) + assert first.status == retried.status == 'ok' + assert first.resources == retried.resources + assert first.replayed != retried.replayed + async with app.metadata.transaction(write=False) as tx: + user = await tx.resource(uid) + saved = tx.setting('identity_handle_renamed_at:' + uid) + assert user.name == '@rename-second' and user.generation == generation + 1 + assert saved is not None + assert (await other.rename_identity('rename-first')).error.code == 'handle_unavailable' + fresh = client('fresh') + assert (await fresh.register('rename-first')).error.code == 'handle_unavailable' + # A request cannot select another subject or execute anonymously. + anonymous = request_for( + 'identity.rename', {'handle': 'anonymous'}, app.settings.service_url + ) + assert (await app.executor.execute(anonymous)).status == 'error' + attempted = await other.call( + 'identity.rename', {'handle': 'rename-steal'}, expected=((uid, user.generation),) + ) + assert attempted.error.code == 'expected_generation_required' + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource(uid)).name == '@rename-second' + + +@pytest.mark.asyncio +async def test_custodial_handle_rename_obeys_same_account_cooldown(installed, tmp_path): + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + client = MsgClient( + ClientState(tmp_path / 'custodial', server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + assert (await client.custodial('custody-original')).status == 'ok' + assert (await client.rename_identity('custody-new')).status == 'ok' + assert ( + await client.rename_identity('custody-again') + ).error.code == 'handle_rename_cooldown' + + +@pytest.mark.asyncio +async def test_rename_does_not_expand_old_credentials_or_name_temporary_accounts( + installed, tmp_path +): + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + owner = MsgClient( + ClientState(tmp_path / 'restricted', server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + assert (await owner.register('unchanged-account')).status == 'ok' + uid = owner.state.subject + async with app.metadata.transaction(write=True) as tx: + subject = await tx.subject(uid) + credential = await tx.credential(owner.state.signer.key_id) + await tx.save_credential( + replace( + credential, + ceiling=tuple( + replace(grant, operations=grant.operations - {'identity.rename@1'}) + for grant in credential.ceiling + ), + ), + subject.auth_version, + ) + assert (await owner.rename_identity('disallowed')).error.code == 'credential_ceiling' + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource(uid)).name == '@unchanged-account' + assert tx.setting('identity_handle_renamed_at:' + uid) is None + temporary = MsgClient( + ClientState(tmp_path / 'temporary', server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + assert (await temporary.temporary()).status == 'ok' + assert ( + await temporary.rename_identity('not-registered') + ).error.code == 'formal_identity_required'