Skip to content
Draft
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
11 changes: 9 additions & 2 deletions lib/features/bullnym/bullnym_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ code must not assume fields beyond this documented subset.
The foundation contract implements these registration and capability fields:

- public `GET /version`, with optional `public_name_policy`;
- `POST /register` with `nym`, `ct_descriptor`, `npub`, `signature`, and
`timestamp`;
- `POST /register` with `nym`, `ct_descriptor`, `verification_npub`, `npub`,
`signature`, and `timestamp`. Registration signs `ct_descriptor` followed by
`verification_npub` as its exact payload field order;
- register response fields `nym`, `lightning_address`, and optional validated
`quota`;
- `DELETE /register` with `nym`, `npub`, `signature`, and `timestamp`;
Expand All @@ -52,6 +53,12 @@ The foundation contract implements these registration and capability fields:
- Bullpay LA v2 signing layout:
`bullpay-la-v2\0action\0npub_hex\0nym\0(payload\0)*timestamp`.

The public verification key is a required canonical lowercase 32-byte x-only
secp256k1 key. It remains distinct from `npub`, which authenticates the request.
This feature does not expose derived Lightning Address behavior beyond returning
server-supplied address fields. `active` remains only the compatibility
Lightning Address online status; names have no active/inactive state.

The automatic-fallback contract uses authenticated `GET` and `PUT`
`/api/v1/recovery-address` calls. Both use an empty nym slot. Lookup signs no
payload fields and returns either an all-null unregistered value or the exact
Expand Down
1 change: 1 addition & 0 deletions lib/features/bullnym/data/bullnym_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class BullnymHttpClient implements BullnymClientPort {
data: {
'nym': request.nym,
'ct_descriptor': request.ctDescriptor,
'verification_npub': request.verificationNpubHex,
'npub': request.npubHex,
'signature': request.signatureHex,
'timestamp': request.timestamp,
Expand Down
2 changes: 2 additions & 0 deletions lib/features/bullnym/domain/bullnym_client_port.dart
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,15 @@ class BullnymBackupDeleteRequest {
class BullnymRegisterRequest {
final String nym;
final String ctDescriptor;
final String verificationNpubHex;
final String npubHex;
final String signatureHex;
final int timestamp;

const BullnymRegisterRequest({
required this.nym,
required this.ctDescriptor,
required this.verificationNpubHex,
required this.npubHex,
required this.signatureHex,
required this.timestamp,
Expand Down
22 changes: 18 additions & 4 deletions lib/features/bullnym/domain/bullpay_signing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/bullnym/domain/bullnym_auth_signer.dart';
import 'package:bb_mobile/features/bullnym/domain/bullnym_failure.dart';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:meta/meta.dart';
Expand All @@ -16,8 +17,6 @@ const String bullpayActionDelete = 'delete';
const String bullpayActionDonationPageSave = 'donation-page-save';
const String bullpayActionDonationPageArchive = 'donation-page-archive';

final _canonicalNpubHexPattern = RegExp(r'^[0-9a-f]{64}$');

// Optional-trailing signed-field rule (server `save_payload_fields` in
// `src/donation_page.rs`): the seven mandatory save fields (header, description,
// display_currency, website, twitter, instagram, enabled) are always present —
Expand Down Expand Up @@ -111,10 +110,25 @@ int currentBullpayTimestampSecs() {

@useResult
Result<void, BullnymFailure> validateBullnymNpubHex(String npubHex) {
if (_canonicalNpubHexPattern.hasMatch(npubHex)) return const Ok(null);
if (npubHex.length != 64 || npubHex != npubHex.toLowerCase()) {
return const Err(
BullnymFailure.invalidInput(
'Bullnym npub must be canonical lowercase 32-byte hex',
),
);
}
try {
final decoded = hex.decode(npubHex);
if (decoded.length == 32) {
ECPublic.fromHex('02$npubHex');
return const Ok(null);
}
} on Exception {
// Return the feature failure below.
}
return const Err(
BullnymFailure.invalidInput(
'Bullnym npub must be a 32-byte lowercase hex value',
'Bullnym npub must be a valid secp256k1 x-only public key',
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,27 @@ class RegisterBullnymUsecase {
required BullnymAuthSigner signer,
required String nym,
required String ctDescriptor,
required String verificationNpubHex,
}) async {
switch (validateBullnymNpubHex(verificationNpubHex)) {
case Err(:final failure):
return Err(failure);
case Ok():
break;
}
if (verificationNpubHex == signer.npubHex) {
return const Err(
BullnymFailure.invalidInput(
'Bullnym authentication and verification keys must be distinct',
),
);
}
final timestamp = _nowSecs();
final signatureResult = await signBullpayAction(
signer: signer,
action: bullpayActionRegister,
nymOrEmpty: nym,
payloadFields: [ctDescriptor],
payloadFields: [ctDescriptor, verificationNpubHex],
timestampSecs: timestamp,
);
final String signatureHex;
Expand All @@ -42,6 +56,7 @@ class RegisterBullnymUsecase {
BullnymRegisterRequest(
nym: nym,
ctDescriptor: ctDescriptor,
verificationNpubHex: verificationNpubHex,
npubHex: signer.npubHex,
signatureHex: signatureHex,
timestamp: timestamp,
Expand Down
2 changes: 2 additions & 0 deletions lib/features/bullnym/public/bullnym_facade.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,13 @@ class BullnymFacade {
required BullnymAuthSigner signer,
required String nym,
required String ctDescriptor,
required String verificationNpubHex,
}) {
return _register.execute(
signer: signer,
nym: nym,
ctDescriptor: ctDescriptor,
verificationNpubHex: verificationNpubHex,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class RegisterLightningAddressUsecase {
signer: signer,
nym: normalizedNym,
ctDescriptor: ctDescriptor,
verificationNpubHex: _nostrIdentity
.deriveBullnymNip05VerificationPublicKeyFromXprv(xprvBase58),
);
return switch (result) {
Ok(:final value) => LightningAddressRegistration(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ It does not import Bullnym internals, Bullnym signing helpers, Bullnym HTTP adap
Registration and delete derive the Bullnym server-auth public key and signing operation through the role-named Nostr Identity facade methods.

Registration and delete receive `xprvBase58` only inside domain composition so Lightning Address can build a one-shot Bullnym auth signer through Nostr Identity.
Registration also derives the distinct NIP-05 public verification key through Nostr Identity's public-only role helper. It binds that public key after the confidential descriptor in the signed registration payload; only the Bullnym authentication role signs.
They pass the confidential descriptor through to Bullnym registration.
Lookup accepts the Bullnym auth public key/npub hex and does not require wallet secret material.
Lightning Address does not persist or own wallet secrets.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import 'package:bb_mobile/features/bip85_registry/public/bip85_registry_facade.d

const _walletBackupReservationId = 'nostr_wallet_backup_key';
const _bullnymServerAuthReservationId = 'nostr_bullnym_server_auth_key';
const _bullnymNip05VerificationReservationId =
'nostr_nip05_public_nym_verification_key';

enum NostrIdentityRole { walletBackup, bullnymServerAuth }
enum NostrIdentityRole {
walletBackup,
bullnymServerAuth,
bullnymNip05Verification,
}

class DeriveNostrIdentityHandleUsecase {
final Bip85RegistryFacade _registry;
Expand All @@ -25,6 +31,8 @@ class DeriveNostrIdentityHandleUsecase {
return switch (role) {
NostrIdentityRole.walletBackup => _walletBackupReservationId,
NostrIdentityRole.bullnymServerAuth => _bullnymServerAuthReservationId,
NostrIdentityRole.bullnymNip05Verification =>
_bullnymNip05VerificationReservationId,
};
}

Expand Down
10 changes: 7 additions & 3 deletions lib/features/nostr_identity/nostr_identity_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ later receive/Bullnym product features. The concrete reservation paths stay in
`features/bip85_registry`; this feature consumes the current wallet-backup and
Bullnym-auth reservations through its public facade.

These roles are reserved for Bullnym backup signing, Bullnym auth, and public
nym verification. This PR exposes only wallet-backup and Bullnym-auth
public-key/signing helpers; it does not implement those protocols or events.
`features/bip85_registry`; this feature consumes the current wallet-backup,
Bullnym-auth, and Bullnym NIP-05 verification reservations through its public
facade.

The Bullnym authentication role alone exposes signing. The NIP-05 verification
role exposes only its public key so registration cannot accidentally use the
public identity as an authentication signer.
8 changes: 8 additions & 0 deletions lib/features/nostr_identity/public/nostr_identity_facade.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class NostrIdentityFacade {
return handle.publicKeyHex;
}

String deriveBullnymNip05VerificationPublicKeyFromXprv(String xprvBase58) {
final handle = _deriveHandle.execute(
xprvBase58: xprvBase58,
role: NostrIdentityRole.bullnymNip05Verification,
);
return handle.publicKeyHex;
}

String signWalletBackupHashFromXprv({
required String xprvBase58,
required String messageHashHex,
Expand Down
10 changes: 9 additions & 1 deletion test/core_test/nostr/nostr_keychain_handle_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ void main() {
facade.deriveBullnymServerAuthPublicKeyFromXprv(xprv),
_expectedBullnymAuthPublicKeyHex,
);
expect(
facade.deriveBullnymNip05VerificationPublicKeyFromXprv(xprv),
NostrKeychainHandle.deriveFromBip85Path(
xprvBase58: xprv,
hardenedPath: "9000'/3'/1'",
).publicKeyHex,
);
});

test('wallet backup facade uses the registry exact path', () {
Expand All @@ -77,9 +84,10 @@ void main() {
final publicKeys = {
facade.deriveWalletBackupPublicKeyFromXprv(xprv),
facade.deriveBullnymServerAuthPublicKeyFromXprv(xprv),
facade.deriveBullnymNip05VerificationPublicKeyFromXprv(xprv),
};

expect(publicKeys.length, 2);
expect(publicKeys.length, 3);
});

test('handle debug output does not expose secret key material', () {
Expand Down
59 changes: 50 additions & 9 deletions test/features/bullnym/bullnym_facade_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class _Captured {

void main() {
const timestamp = 1710000000;
const verificationNpubHex =
'852600604d65fea77a9d23e9623b7a5bab24b5314deb7f79419006363338047f';
late NostrKeychainHandle handle;
late BullnymAuthSigner signer;

Expand Down Expand Up @@ -305,6 +307,7 @@ void main() {
signer: signer,
nym: 'alice',
ctDescriptor: 'ct-desc',
verificationNpubHex: verificationNpubHex,
),
);

Expand All @@ -316,29 +319,62 @@ void main() {
expect(request.data, {
'nym': 'alice',
'ct_descriptor': 'ct-desc',
'verification_npub': verificationNpubHex,
'npub': signer.npubHex,
'signature': isA<String>().having((s) => s.length, 'length', 128),
'timestamp': timestamp,
});
expect(
(request.data as Map<String, dynamic>).containsKey(
'verification'
'_npub',
),
isFalse,
);
_expectSignatureValid(
handle: handle,
signatureHex:
(request.data as Map<String, dynamic>)['signature'] as String,
action: bullpayActionRegister,
nymOrEmpty: 'alice',
payloadFields: const ['ct-desc'],
payloadFields: const ['ct-desc', verificationNpubHex],
timestampSecs: timestamp,
);
},
);

test(
'rejects invalid or reused verification keys before signing or network',
() async {
final stub = _stubDio([
{'nym': 'alice', 'lightning_address': 'alice@bullpay.ca'},
]);
var signed = false;
final guardedSigner = BullnymAuthSigner(
npubHex: signer.npubHex,
signHashHex: (_) {
signed = true;
return '00' * 64;
},
);
final facade = _facadeForClient(
BullnymHttpClient.withDio(stub.dio),
nowSecs: () => timestamp,
);

for (final invalidVerificationNpubHex in [
verificationNpubHex.toUpperCase(),
'ff' * 32,
signer.npubHex,
]) {
final failure = _unwrapFailure(
await facade.register(
signer: guardedSigner,
nym: 'alice',
ctDescriptor: 'ct-desc',
verificationNpubHex: invalidVerificationNpubHex,
),
);
expect(failure.kind, BullnymFailureKind.invalidInput);
}
expect(signed, isFalse);
expect(stub.captured.requests, isEmpty);
},
);

test('deletes registration with a signed delete action', () async {
final stub = _stubDio([
{'ok': true},
Expand Down Expand Up @@ -507,6 +543,7 @@ void main() {
signer: signer,
nym: 'alice',
ctDescriptor: 'ct-desc',
verificationNpubHex: verificationNpubHex,
),
);
expect(
Expand All @@ -533,6 +570,7 @@ void main() {
signer: signer,
nym: 'ali\u0000ce',
ctDescriptor: 'ct-desc',
verificationNpubHex: verificationNpubHex,
),
);
expect(
Expand All @@ -558,6 +596,7 @@ void main() {
signer: signer,
nym: 'alice',
ctDescriptor: 'ct\u0000desc',
verificationNpubHex: verificationNpubHex,
),
);
expect(failure, isA<BullnymFailure>());
Expand All @@ -582,6 +621,7 @@ void main() {
signer: throwingSigner,
nym: 'alice',
ctDescriptor: 'ct-desc',
verificationNpubHex: verificationNpubHex,
),
);
expect(
Expand Down Expand Up @@ -639,6 +679,7 @@ void main() {
addField('npub');
addField('alice');
addField('ct-desc');
addField(verificationNpubHex);
expected.addAll(utf8.encode(timestamp.toString()));

expect(
Expand All @@ -647,7 +688,7 @@ void main() {
action: bullpayActionRegister,
npubHex: 'npub',
nymOrEmpty: 'alice',
payloadFields: const ['ct-desc'],
payloadFields: const ['ct-desc', verificationNpubHex],
timestampSecs: timestamp,
),
),
Expand Down
Loading