mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
The mobile app can now be built as a public build or an internal build, set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that Builderlab's relay assertions only to the relays in `BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at all; with none set, the app is a Nostr-only development build. The decision is recorded in `docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are defined in `CONTEXT.md`. Before this change, one build supported a public Builderlab and an organization Builderlab at the same time, with two sessions. An organization's members could reach public communities from the organization's app. ### Behavior - **Public build:** QR pairing, email sign-in and multiple communities are unchanged. It has no knowledge of any organization. An organization's relay already rejects clients that can't present its Builderlab's assertion, and the public build relies on that. - **Internal build:** the first screen has a single **Sign in** button that goes straight to the organization's single sign-on and then joins the organization's community. There is no QR pairing, community link or community creation, and the community switcher is hidden. Only the exact relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable. - **Identity (internal):** a member whose account has no Nostr identity yet gets a new key on first sign-in; the first relay assertion binds it, and the app backs it up to the organization automatically. A member whose account is already bound uses that key from this device, or restores it from the organization's backup. If neither is possible, Sign in says it couldn't restore the identity and saves nothing. - **Sign-out (internal):** a destructive **Sign out** row at the bottom of Settings, the build's only way out. It removes the community, closes Settings and returns to Sign in. The Builderlab session is cleared from storage immediately and can never be reused to sign back in. The relay push-stop keeps the retired credential in memory only, for up to 10 minutes, and uses it only to get an assertion for a key that is already bound. It is a best-effort request. - **Upgrade to an internal build:** a saved public Builderlab session is cleared. Communities on other relays are hidden, not deleted, and their keys stay on the device. - `BUZZ_HOSTED_RELAY_AUTH` is unchanged here. ### Screenshots (internal build) | Sign in | Community menu | Settings | | --- | --- | --- | |  |  |  | Gateway-side push revocation, which needs no Builderlab session, is planned as a separate PR. ### Validation Added tests: - An internal-build journey: one Sign in creates and binds a key, and a retry after a cancelled join reuses it. The app uploads that key's backup itself. Sign out from Settings closes it and returns to the landing, which ends the session, and Sign in then restores the same identity from the backup. Communities on other relays stay stored but hidden. - Internal Sign in for a bound account: the key on this device, a key restored from backup, and no backup (clear error, nothing saved). Each guard was shown to fail when removed. - Organization relay assertions: assertions go only to exact listed hosts, not subdomains. After sign-out, only a push stop for a bound key gets an assertion, and nothing can sign back in. Proven to fail without the fix. - Builderlab build setting parsing. CI runs the internal and public journeys in their own lanes with those settings. The full suite passes in the default configuration. The full suite isn't meant to run under the internal configuration, because most fixtures use relays that an internal build hides. Automated full-app run on an iPhone 17 Pro simulator at the current head: - Public build, upgraded in place over a signed-in install: home loads, a channel opens and a message sends. - Internal build: a fresh install shows only Sign in. With a saved organization community, the community menu has no Switch community, and Settings → Sign out → confirm returns to Sign in. Real single sign-on can't run in the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
428 lines
14 KiB
Dart
428 lines
14 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import 'account_identity_store.dart';
|
|
import 'builderlab_account.dart';
|
|
import 'device_identity_source.dart';
|
|
|
|
enum HostedIdentityState {
|
|
ready,
|
|
needsSelection,
|
|
needsSetup,
|
|
needsBinding,
|
|
needsBackup,
|
|
mismatch,
|
|
recoveryBlocked,
|
|
needsAuthoritativeAbsence,
|
|
}
|
|
|
|
class HostedIdentityResult {
|
|
const HostedIdentityResult(
|
|
this.state, {
|
|
this.pubkey,
|
|
this.choices = const [],
|
|
this.canCreate = false,
|
|
});
|
|
final HostedIdentityState state;
|
|
final String? pubkey;
|
|
final bool canCreate;
|
|
final List<DeviceIdentityChoice> choices;
|
|
}
|
|
|
|
/// Resolves identity safely. Generation requires a separately verified
|
|
/// complete absence contract and explicit legacy-key reconciliation; neither a
|
|
/// failed recovery nor an unbound account proves there is no prior identity.
|
|
class HostedIdentityService {
|
|
HostedIdentityService({
|
|
required this.account,
|
|
required this.store,
|
|
DeviceIdentitySource? deviceIdentities,
|
|
}) : deviceIdentities = deviceIdentities ?? DeviceIdentitySource();
|
|
|
|
final DeviceIdentitySource deviceIdentities;
|
|
final BuilderlabAccountClient account;
|
|
final AccountIdentityStore store;
|
|
|
|
Future<HostedIdentityResult> resolve({
|
|
bool Function()? isCurrent,
|
|
String? selectedPubkey,
|
|
bool createNew = false,
|
|
Set<String> offered = const {},
|
|
String? importNsec,
|
|
}) => _resolve(isCurrent, selectedPubkey, createNew, offered, importNsec);
|
|
|
|
Future<HostedIdentityResult> _resolve(
|
|
bool Function()? isCurrent,
|
|
String? selectedPubkey,
|
|
bool createNew,
|
|
Set<String> offered,
|
|
String? importNsec,
|
|
) async {
|
|
void checkCurrent() {
|
|
if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable;
|
|
}
|
|
|
|
checkCurrent();
|
|
final subject = account.account?.subject;
|
|
if (subject == null) throw AccountFailure.signedOut;
|
|
final store = this.store.forSubject(subject);
|
|
final local = await store.read();
|
|
final response = await account.currentIdentity();
|
|
_sameAccount(subject);
|
|
if (response['error'] != null) throw AccountFailure.invalidResponse;
|
|
final identity = response['identity'];
|
|
final String? bound;
|
|
if (identity == null) {
|
|
bound = null; // The current API omits the optional unbound identity.
|
|
} else if (identity is Map<String, dynamic>) {
|
|
bound = _pubkey(identity['pubkey_hex']);
|
|
} else {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
if (local != null && bound != null && importNsec == null) {
|
|
return _result(local, bound, subject);
|
|
}
|
|
|
|
final status = await account.post('/v1/buzz/nsec-backups/status', {});
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
final listed = status['backup_nostr_pubkeys'];
|
|
if (status['error'] != null || listed is! List) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
final statusBound = status['current_nostr_pubkey'] == null
|
|
? null
|
|
: _pubkey(status['current_nostr_pubkey']);
|
|
final statusKeys = listed.map(_pubkey).toSet();
|
|
if (statusBound != bound) throw AccountFailure.invalidResponse;
|
|
if (importNsec != null) {
|
|
final pubkey = AccountIdentityStore.publicKeyForNsec(importNsec);
|
|
// A backup arriving while setup is open must use normal recovery, which
|
|
// records the selected backup receipt before adopting its identity.
|
|
if (bound == null && statusKeys.isNotEmpty) {
|
|
throw AccountFailure.unavailable;
|
|
}
|
|
if (bound != null && bound != pubkey) {
|
|
throw AccountIdentityFailure.mismatch;
|
|
}
|
|
final imported = await store.create(
|
|
importNsec,
|
|
checkCurrent: () {
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
},
|
|
);
|
|
return _result(imported, bound, subject);
|
|
}
|
|
if (local != null) {
|
|
// A previously chosen recovery key may still await binding after a
|
|
// restart. Its account-scoped receipt and current backup listing preserve
|
|
// that choice even when the account also retains older identities.
|
|
final confirmedSelection =
|
|
statusKeys.contains(local.pubkey) &&
|
|
local.hasBackupFor(
|
|
backend: BuilderlabAccountClient.origin.origin,
|
|
subject: subject,
|
|
);
|
|
if (statusKeys.any((key) => key != local.pubkey) && !confirmedSelection) {
|
|
return const HostedIdentityResult(HostedIdentityState.mismatch);
|
|
}
|
|
return _result(local, bound, subject);
|
|
}
|
|
final device = await deviceIdentities.choices();
|
|
final savedAccounts = await store.identities();
|
|
final choices = [
|
|
...device,
|
|
for (final saved in savedAccounts)
|
|
if (!device.any((item) => item.pubkey == saved.pubkey))
|
|
DeviceIdentityChoice(saved.pubkey, 'Saved identity'),
|
|
];
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
// The authoritative bound key already on this device needs no remote
|
|
// decryption, even when the account retains backups of older identities.
|
|
final matchingDevice =
|
|
bound != null && choices.any((item) => item.pubkey == bound);
|
|
// An unclaimed account may keep its saved keys and start a new identity.
|
|
final unclaimed = statusKeys.isEmpty && bound == null;
|
|
if ((unclaimed && !createNew) || matchingDevice) {
|
|
final eligible = choices
|
|
.where((item) => bound == null || item.pubkey == bound)
|
|
.toList();
|
|
if (eligible.isNotEmpty) {
|
|
final chosen =
|
|
selectedPubkey ??
|
|
(bound != null && eligible.length == 1
|
|
? eligible.single.pubkey
|
|
: null);
|
|
if (!eligible.any((item) => item.pubkey == chosen)) {
|
|
return HostedIdentityResult(
|
|
HostedIdentityState.needsSelection,
|
|
choices: eligible,
|
|
canCreate: unclaimed,
|
|
);
|
|
}
|
|
void checkSelection() {
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
}
|
|
|
|
final saved = device.any((item) => item.pubkey == chosen)
|
|
? await deviceIdentities.adopt(
|
|
chosen!,
|
|
store,
|
|
checkCurrent: checkSelection,
|
|
)
|
|
: await store.adoptSaved(chosen!, checkCurrent: checkSelection);
|
|
return _result(saved, bound, subject);
|
|
}
|
|
}
|
|
if (statusKeys.isEmpty && bound != null && !matchingDevice) {
|
|
return HostedIdentityResult(
|
|
HostedIdentityState.needsSetup,
|
|
pubkey: bound,
|
|
);
|
|
}
|
|
if (statusBound == null && statusKeys.isEmpty) {
|
|
if (!createNew) {
|
|
return const HostedIdentityResult(
|
|
HostedIdentityState.needsSetup,
|
|
canCreate: true,
|
|
);
|
|
}
|
|
final generated = await store.generateIfAbsent(
|
|
authorize: () async {
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
// Every device key must have been offered beside creation, so a
|
|
// new identity never silently passes over an unseen prior one.
|
|
final reconciled = (await deviceIdentities.choices()).every(
|
|
(item) => offered.contains(item.pubkey),
|
|
);
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
return reconciled;
|
|
},
|
|
);
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
return generated == null
|
|
? const HostedIdentityResult(
|
|
HostedIdentityState.needsAuthoritativeAbsence,
|
|
)
|
|
: _result(generated, bound, subject);
|
|
}
|
|
|
|
final recovered = await account.post('/v1/buzz/nsec-backups/recovery', {});
|
|
_sameAccount(subject);
|
|
final candidates = openRecoveredBackups(recovered);
|
|
if (candidates == null) {
|
|
return const HostedIdentityResult(HostedIdentityState.recoveryBlocked);
|
|
}
|
|
if (candidates.isEmpty) {
|
|
return HostedIdentityResult(
|
|
bound == null
|
|
? HostedIdentityState.needsAuthoritativeAbsence
|
|
: HostedIdentityState.recoveryBlocked,
|
|
);
|
|
}
|
|
if (bound == null &&
|
|
(statusKeys.length != candidates.length ||
|
|
!statusKeys.every(candidates.containsKey))) {
|
|
return const HostedIdentityResult(HostedIdentityState.recoveryBlocked);
|
|
}
|
|
// An account binding is authoritative even when older backups contain
|
|
// other keys. Without a binding, distinct keys require an explicit choice;
|
|
// never let response ordering choose the user's identity.
|
|
final chosen =
|
|
bound ??
|
|
selectedPubkey ??
|
|
(candidates.length == 1 ? candidates.keys.single : null);
|
|
final candidate = candidates[chosen];
|
|
if (candidate == null) {
|
|
if (bound != null) {
|
|
return const HostedIdentityResult(HostedIdentityState.mismatch);
|
|
}
|
|
return HostedIdentityResult(
|
|
HostedIdentityState.needsSelection,
|
|
choices: [
|
|
for (final pubkey in candidates.keys)
|
|
DeviceIdentityChoice(
|
|
pubkey,
|
|
'Use Builderlab identity',
|
|
fromCloud: true,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
if (device.isNotEmpty &&
|
|
!device.any((item) => item.pubkey == candidate.keys.public) &&
|
|
selectedPubkey != candidate.keys.public) {
|
|
return HostedIdentityResult(
|
|
HostedIdentityState.needsSelection,
|
|
choices: [
|
|
DeviceIdentityChoice(
|
|
candidate.keys.public,
|
|
'Use Builderlab identity',
|
|
fromCloud: true,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
final saved = await store.create(
|
|
candidate.keys.nsec,
|
|
checkCurrent: () {
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
},
|
|
recoveredBackup: BackupReceipt(
|
|
backend: BuilderlabAccountClient.origin.origin,
|
|
subject: subject,
|
|
pubkey: candidate.keys.public,
|
|
backupId: candidate.backupId,
|
|
),
|
|
);
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
return _result(saved, bound, subject);
|
|
}
|
|
|
|
/// After a definitive binding rejection of [pubkey], release this account's
|
|
/// unconfirmed copy so the user can choose again. Rechecks that the account
|
|
/// is still unbound with no backups and that the key survives elsewhere;
|
|
/// returns false and keeps the slot otherwise.
|
|
Future<bool> releaseRejected(
|
|
String pubkey, {
|
|
required String subject,
|
|
bool Function()? isCurrent,
|
|
}) async {
|
|
void checkCurrent() {
|
|
if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable;
|
|
}
|
|
|
|
checkCurrent();
|
|
_sameAccount(subject);
|
|
final store = this.store.forSubject(subject);
|
|
final local = await store.read();
|
|
if (local == null) return true;
|
|
if (local.pubkey != pubkey || local.backup != null) return false;
|
|
final current = await account.currentIdentity();
|
|
_sameAccount(subject);
|
|
if (current['error'] != null) throw AccountFailure.invalidResponse;
|
|
if (current['identity'] != null) return false;
|
|
final status = await account.post('/v1/buzz/nsec-backups/status', {});
|
|
_sameAccount(subject);
|
|
final listed = status['backup_nostr_pubkeys'];
|
|
if (status['error'] != null || listed is! List) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
if (status['current_nostr_pubkey'] != null || listed.isNotEmpty) {
|
|
return false;
|
|
}
|
|
checkCurrent();
|
|
return store.releaseUnconfirmed(
|
|
pubkey,
|
|
heldElsewhere: deviceIdentities.holds,
|
|
checkCurrent: () {
|
|
_sameAccount(subject);
|
|
checkCurrent();
|
|
},
|
|
);
|
|
}
|
|
|
|
HostedIdentityResult _result(
|
|
AccountIdentitySnapshot local,
|
|
String? bound,
|
|
String subject,
|
|
) {
|
|
_sameAccount(subject);
|
|
if (bound != null && bound != local.pubkey) {
|
|
return HostedIdentityResult(
|
|
HostedIdentityState.mismatch,
|
|
pubkey: local.pubkey,
|
|
);
|
|
}
|
|
if (bound == null) {
|
|
return HostedIdentityResult(
|
|
HostedIdentityState.needsBinding,
|
|
pubkey: local.pubkey,
|
|
);
|
|
}
|
|
return HostedIdentityResult(
|
|
local.hasBackupFor(
|
|
backend: BuilderlabAccountClient.origin.origin,
|
|
subject: subject,
|
|
)
|
|
? HostedIdentityState.ready
|
|
: HostedIdentityState.needsBackup,
|
|
pubkey: local.pubkey,
|
|
);
|
|
}
|
|
|
|
void _sameAccount(String subject) {
|
|
if (account.account?.subject != subject) {
|
|
throw AccountFailure.differentAccount;
|
|
}
|
|
}
|
|
|
|
static String _pubkey(dynamic value) {
|
|
if (value is! String || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(value)) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
return value.toLowerCase();
|
|
}
|
|
}
|
|
|
|
/// A recovered backup's key, derived from its secret, and its backup id.
|
|
typedef RecoveredBackup = ({nostr.Keys keys, String backupId});
|
|
|
|
/// Validates an `/v1/buzz/nsec-backups/recovery` response, keyed by the public
|
|
/// key derived from each secret, never by the advertised one. Null when any
|
|
/// backup failed to open; throws [AccountFailure.invalidResponse] when the
|
|
/// response is malformed, so nothing is stored from a partial result.
|
|
Map<String, RecoveredBackup>? openRecoveredBackups(
|
|
Map<String, dynamic> recovered,
|
|
) {
|
|
final backups = recovered['backups'];
|
|
final failed = recovered['failed_backup_ids'];
|
|
if (recovered['error'] != null ||
|
|
backups is! List ||
|
|
failed is! List ||
|
|
failed.any((id) => id is! String)) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
if (failed.isNotEmpty) return null;
|
|
final candidates = <String, RecoveredBackup>{};
|
|
try {
|
|
for (final backup in backups) {
|
|
if (backup is! Map<String, dynamic>) throw const FormatException();
|
|
final secret = backup['secret_key'];
|
|
final backupId = backup['backup_id'];
|
|
if (secret is! String ||
|
|
!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(secret) ||
|
|
backupId is! String ||
|
|
backupId.isEmpty) {
|
|
throw const FormatException();
|
|
}
|
|
final bytes = base64Url.decode(base64Url.normalize(secret));
|
|
if (bytes.length != 32) throw const FormatException();
|
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
final keys = AccountIdentityStore.keysForHex(hex);
|
|
if (keys.public !=
|
|
HostedIdentityService._pubkey(backup['nostr_pubkey'])) {
|
|
throw const FormatException();
|
|
}
|
|
candidates.putIfAbsent(
|
|
keys.public,
|
|
() => (keys: keys, backupId: backupId),
|
|
);
|
|
}
|
|
} catch (_) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
return candidates;
|
|
}
|