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
29 changes: 29 additions & 0 deletions lib/core/nostr/nostr_keychain_handle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ final class NostrKeychainHandle {

String get publicKeyHex => _key.getPublic().toXOnlyHex();

/// NIP-19 `npub` display encoding of [publicKeyHex].
String get npub => NostrPublicKeyEncoding.npubFromPublicKeyHex(publicKeyHex);

String signHashHex(String messageHashHex) {
final digest = hex.decode(messageHashHex);
if (digest.length != 32) {
Expand Down Expand Up @@ -98,6 +101,32 @@ void _validateNostrBip85Path(String hardenedPath) {
}
}

/// NIP-19 bech32 display encoding for Nostr public keys.
///
/// A stored 32-byte x-only public key is never shown as hex in the UI: `npub`
/// is the only representation users see, so every surface encodes through here
/// instead of formatting the hex itself. Shares the bech32 encoder and bit
/// converter used for `nsec`, so the two encodings cannot drift apart.
final class NostrPublicKeyEncoding {
const NostrPublicKeyEncoding._();

static final _publicKeyHexPattern = RegExp(r'^[0-9a-fA-F]{64}$');

static String npubFromPublicKeyHex(String publicKeyHex) {
final normalized = publicKeyHex.trim().toLowerCase();
if (!_publicKeyHexPattern.hasMatch(normalized)) {
throw ArgumentError.value(
publicKeyHex,
'publicKeyHex',
'Nostr public key must be 32-byte hex',
);
}
return bech32.encode(
Bech32('npub', _convertBits(hex.decode(normalized), 8, 5, true)),
);
}
}

/// Secret materialization is deliberately a one-shot operation. Callers must
/// not persist the returned value or place it in application state.
final class NostrKeychainSecretMaterializer {
Expand Down
6 changes: 5 additions & 1 deletion lib/core/storage/migrations/schema_19_to_20.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import 'package:bb_mobile/core/storage/sqlite_database.steps.dart';
import 'package:drift/drift.dart';

/// Adds local metadata for materialized Nostr keys.
/// Adds local metadata for materialized Nostr keys: public key, kind, purpose,
/// and the optional user-authored description.
///
/// The table is created with its final version-20 shape, so there is no
/// separate column-add step for `description`.
class Schema19To20 {
static Future<void> migrate(Migrator m, Schema20 schema20) async {
await m.createTable(schema20.keychainManifestNostrKeys);
Expand Down
12 changes: 11 additions & 1 deletion lib/core/storage/schemas/bull_database/drift_schema_v20.json
Original file line number Diff line number Diff line change
Expand Up @@ -2223,6 +2223,16 @@
"default_client_dart": null,
"dsl_features": []
},
{
"name": "description",
"getter_name": "description",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "created_at",
"getter_name": "createdAt",
Expand Down Expand Up @@ -2681,7 +2691,7 @@
"sql": [
{
"dialect": "sqlite",
"sql": "CREATE TABLE IF NOT EXISTS \"keychain_manifest_nostr_keys\" (\"entry_id\" TEXT NOT NULL, \"public_key_hex\" TEXT NOT NULL, \"key_kind\" TEXT NOT NULL, \"purpose\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));"
"sql": "CREATE TABLE IF NOT EXISTS \"keychain_manifest_nostr_keys\" (\"entry_id\" TEXT NOT NULL, \"public_key_hex\" TEXT NOT NULL, \"key_kind\" TEXT NOT NULL, \"purpose\" TEXT NOT NULL, \"description\" TEXT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" INTEGER NOT NULL, PRIMARY KEY (\"entry_id\"));"
}
]
},
Expand Down
Loading