What changed, and why it matters
This commit is a feature draft that upgrades the wallet's OpenAlias support from version 1 to also support the newer OpenAlias v2 standard. It adds safer parsing, DNSSEC-over-Tor resolution, and displays a sanitized recipient name on the confirmation screen. There is no indication in the commit that it fixes a known security bug; it reads as a defensive-by-design feature implementation.
No immediate security action is required. Treat as a normal feature/quality review: verify the new OA2 parser behavior against the OpenAlias v2 spec, ensure the Rust FFI JSON escaping handles all edge cases, and confirm the UI display-name sanitization covers the intended Unicode ranges. Continue monitoring for a follow-up commit that marks the draft as final.
Security signals we found
OpenAlias resolution routed exclusively through Tor SOCKS proxy with no clearnet fallback
Rust UDP bind explicitly refused to prevent DNS query leakage outside Tor
DNSSEC validation required (Proof::Secure) before records are accepted
Recipient name sanitized: control characters, bidi overrides, zero-width marks removed, whitespace collapsed, length capped at 64 chars with surrogate-pair-safe truncation
Resolved address validated against wallet's Monero address validator before downstream use
OpenAlias v2 preferred over v1; v1 fallback disabled when v2 records exist but are incompatible with wallet's network/asset
Repeated keys in TXT records rejected rather than silently overwritten
Debounced typing delay prevents a Tor lookup per keystroke
Parsing and selection logic unit-tested against hostile/malformed inputs
Evidence from the diff
The patch refactors the OpenAlias FFI plugin and Dart consumers to support both OA1 (TXT on FQDN) and OA2 (_openalias-payment/_openalias-metadata subdomains) records. Key changes: Rust side now exposes openalias_secure_txt returning JSON-encoded TXT records instead of parsing OA1 inline; Dart side adds openalias_records.dart with normalization, key-value parsing, OA1/OA2 grammar parsing, priority selection, and v2-preferred fallback logic; UI adds a debounced alias resolver and a sanitized recipient-name display. Defensive measures visible in the diff include: no clearnet fallback, UDP binding refused in Rust to prevent leaks, DNSSEC Proof::Secure required, control/bidi/zero-width characters stripped from recipient names, length capping, repeated-key rejection, v1 fallback disabled when usable v2 records exist but none match the wallet’s network/asset, and extensive unit tests for hostile/malformed records.
Changed components
lib/models/wallet_model.dartlib/screens/confirm_send.dartlib/screens/send.dartplugins/openalias_ffi/lib/openalias_ffi.dartplugins/openalias_ffi/lib/src/openalias_records.dartplugins/openalias_ffi/rust/src/lib.rsplugins/openalias_ffi/test/openalias_records_test.dartInspect captured patch +1276 / −142
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
index b962ac8..268e554 100644
--- a/lib/models/wallet_model.dart
+++ b/lib/models/wallet_model.dart
@@ -41,6 +41,21 @@ String generateHexString(int length) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
+/// A validated OpenAlias resolution: the Monero address to pay, plus what the
+/// recipient published about themselves, for the confirm screen.
+class ResolvedOpenAlias {
+ ResolvedOpenAlias({required this.address, required this.version, this.recipientName});
+
+ final String address;
+
+ /// 1 if this came from an `oa1:xmr` record, 2 from `_openalias-payment`.
+ final int version;
+
+ /// The recipient's display name, if they published one. Display only — it has
+ /// no bearing on [address].
+ final String? recipientName;
+}
+
class TxDetails {
final int? index;
final int direction;
@@ -1467,42 +1482,86 @@ class WalletModel with ChangeNotifier {
notifyListeners();
}
- /// Resolves an OpenAlias domain to a Monero address with end-to-end DNSSEC
- /// validation, over Tor (via the openalias_ffi Rust/hickory resolver). Returns
- /// the validated address, or '' on any failure (incl. Tor unavailable) so the
- /// send flow surfaces a resolve error. OpenAlias never leaves Tor.
- Future<String> resolveOpenAlias(String address) async {
- log(LogLevel.info, 'Resolving OpenAlias over Tor: $address');
-
- final proxy = await TorSettingsService.sharedInstance.getProxy();
- if (proxy == null) {
- log(LogLevel.warn, 'OpenAlias: Tor proxy unavailable; cannot resolve.');
- return '';
- }
+ /// Resolves an OpenAlias (an FQDN or an email-style `name@domain`) to a
+ /// Monero address with end-to-end DNSSEC validation, over Tor (via the
+ /// openalias_ffi Rust/hickory resolver).
+ ///
+ /// OpenAlias v2 records are preferred and v1 is the fallback, per the spec's
+ /// compatibility rule. Returns null on any failure (incl. Tor unavailable) so
+ /// the send flow surfaces a resolve error. OpenAlias never leaves Tor.
+ Future<ResolvedOpenAlias?> resolveOpenAlias(String alias) async {
+ log(LogLevel.info, 'Resolving OpenAlias over Tor: $alias');
try {
- final resolved = await OpenAliasFfi.resolve(
- domain: address,
+ // No proxy, no lookup. With Tor disabled, still bootstrapping, or
+ // misconfigured, resolution fails here — there is no clearnet fallback.
+ final proxy = await TorSettingsService.sharedInstance.getProxy();
+ if (proxy == null) {
+ log(LogLevel.warn, 'OpenAlias: Tor proxy unavailable; cannot resolve.');
+ return null;
+ }
+
+ final result = await OpenAliasFfi.resolve(
+ alias: alias,
+ // Monero mainnet is `network=xmr`, whose native asset is `xmr` per the
+ // OA2 network list — so a v2 record that omits `asset` is XMR too.
+ network: 'xmr',
asset: 'xmr',
+ nativeAsset: 'xmr',
socksPort: proxy.port,
);
- if (resolved == null || resolved.isEmpty) return '';
+ final resolved = result.payment.address;
- // Only use it if it's a valid Monero address.
- if (_w2Wallet != null && !_w2Wallet!.addressValid(resolved, 0)) {
+ // A record can publish any string at all, so nothing downstream — the tx
+ // builder, the confirm screen — ever sees one that isn't a valid Monero
+ // address for this network. No wallet to check against means no answer.
+ final wallet = _w2Wallet;
+ if (wallet == null || !wallet.addressValid(resolved, 0)) {
log(LogLevel.warn, 'OpenAlias: resolved address failed validation.');
- return '';
+ return null;
}
- log(LogLevel.info, 'OpenAlias resolved successfully.');
- return resolved;
+ log(LogLevel.info, 'OpenAlias resolved successfully (v${result.version}).');
+ return ResolvedOpenAlias(
+ address: resolved,
+ version: result.version,
+ recipientName: _openAliasDisplayName(result.recipientName),
+ );
} catch (e) {
log(LogLevel.warn, 'OpenAlias resolution failed: $e');
- return '';
+ return null;
+ }
+ }
+
+ /// A recipient-published name is only ever shown, never trusted. Strip
+ /// control characters and the Unicode formatting characters that can reorder
+ /// or hide what is drawn (bidi overrides/isolates, zero-width marks),
+ /// collapse whitespace runs, and cap the length, so a crafted name can't
+ /// misrepresent or disrupt the confirm screen.
+ static String? _openAliasDisplayName(String? name) {
+ if (name == null) return null;
+
+ final cleaned = name
+ .replaceAll(_unsafeDisplayChars, ' ')
+ .replaceAll(RegExp(r'\s+'), ' ')
+ .trim();
+ if (cleaned.isEmpty) return null;
+ if (cleaned.length <= 64) return cleaned;
+
+ var clipped = cleaned.substring(0, 63);
+ final last = clipped.codeUnitAt(clipped.length - 1);
+ // Don't leave half a surrogate pair behind when cutting.
+ if (last >= 0xd800 && last <= 0xdbff) {
+ clipped = clipped.substring(0, clipped.length - 1);
}
+ return '$clipped…';
}
+ static final RegExp _unsafeDisplayChars = RegExp(
+ r'[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]',
+ );
+
List<TxDetails> _getTxHistory() {
final txCount = _w2TxHistory!.count();
final List<TxDetails> txs = [];
diff --git a/lib/screens/confirm_send.dart b/lib/screens/confirm_send.dart
index f6be2b3..d1e6e74 100644
--- a/lib/screens/confirm_send.dart
+++ b/lib/screens/confirm_send.dart
@@ -13,12 +13,16 @@ class ConfirmSendScreenArgs {
MoneroPendingTransaction tx;
String destinationAddress;
String? destinationOpenAlias;
+
+ /// The recipient name published alongside the OpenAlias record, if any.
+ String? destinationOpenAliasName;
String? destinationContactName;
ConfirmSendScreenArgs({
required this.tx,
required this.destinationAddress,
this.destinationOpenAlias,
+ this.destinationOpenAliasName,
this.destinationContactName,
});
}
@@ -36,6 +40,7 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
double _amount = 0.0;
double _fee = 0.0;
String? _destinationOpenAlias;
+ String? _destinationOpenAliasName;
String _destinationAddress = '';
List<String> _destinationAddressSliced = [];
String? _destinationContactName;
@@ -64,6 +69,7 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
_amount = doubleAmountFromInt(args.tx.amount());
_fee = doubleAmountFromInt(args.tx.fee());
_destinationOpenAlias = args.destinationOpenAlias;
+ _destinationOpenAliasName = args.destinationOpenAliasName;
_destinationAddress = args.destinationAddress;
_destinationAddressSliced = _sliceAddress(args.destinationAddress);
_destinationContactName = args.destinationContactName;
@@ -194,9 +200,30 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
if (_destinationOpenAlias is String)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('OpenAlias', style: TextStyle(fontWeight: FontWeight.bold)),
- Text(_destinationOpenAlias!),
+ Flexible(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(_destinationOpenAlias!, textAlign: TextAlign.end),
+ // The name the recipient publishes with the record,
+ // so the user can sanity-check who they resolved to.
+ // Recipient-supplied text: sanitized and capped by
+ // the model, and bounded here so it cannot push the
+ // address off the screen.
+ if (_destinationOpenAliasName is String)
+ Text(
+ _destinationOpenAliasName!,
+ textAlign: TextAlign.end,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ ),
],
),
Row(
diff --git a/lib/screens/send.dart b/lib/screens/send.dart
index 50342f4..d2babaf 100644
--- a/lib/screens/send.dart
+++ b/lib/screens/send.dart
@@ -31,7 +31,19 @@ class SendScreen extends StatefulWidget {
State<SendScreen> createState() => _SendScreenState();
}
-final domainRegex = RegExp(r'^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z]{2,})+$');
+/// Matches an OpenAlias: an FQDN, optionally written email-style
+/// (`donate@example.org`, which resolves as `donate.example.org`). Monero
+/// addresses are base58 and contain neither a dot nor an '@', so they are never
+/// mistaken for one. Internationalized names must be entered as A-labels
+/// (`xn--mnchen-3ya.example`).
+final domainRegex = RegExp(
+ r'^(?:[A-Za-z0-9._%+-]+@)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)'
+ r'(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*'
+ r'\.(?:[A-Za-z]{2,}|xn--[A-Za-z0-9-]{2,})$',
+);
+
+/// How long the address field must sit still before an alias is resolved.
+const _openAliasTypingDelay = Duration(milliseconds: 600);
class _SendScreenState extends State<SendScreen> {
bool _isLoading = false;
@@ -55,8 +67,8 @@ class _SendScreenState extends State<SendScreen> {
// Caches the last OpenAlias resolution + dedupes concurrent lookups so
// re-validation (amount changes, revalidations) doesn't re-hit Tor.
String? _resolveCacheInput;
- String _resolveCacheOutput = '';
- Future<String>? _resolveInFlight;
+ ResolvedOpenAlias? _resolveCacheOutput;
+ Future<ResolvedOpenAlias?>? _resolveInFlight;
String _resolveInFlightInput = '';
@override
@@ -173,7 +185,7 @@ class _SendScreenState extends State<SendScreen> {
final unresolvedDestinationAddress = _destinationAddressController.text;
if (domainRegex.hasMatch(unresolvedDestinationAddress)) {
- return _resolveDomain(unresolvedDestinationAddress);
+ return (await _resolveDomain(unresolvedDestinationAddress))?.address ?? '';
}
return unresolvedDestinationAddress;
}
@@ -181,7 +193,7 @@ class _SendScreenState extends State<SendScreen> {
/// Resolves an OpenAlias [domain] once, caching the result and joining any
/// in-flight lookup of the same domain so amount changes / revalidations
/// don't re-hit the (slow, over-Tor) resolver. Drives `_openAliasResolving`.
- Future<String> _resolveDomain(String domain) async {
+ Future<ResolvedOpenAlias?> _resolveDomain(String domain) async {
if (domain == _resolveCacheInput) return _resolveCacheOutput;
if (_resolveInFlight != null && domain == _resolveInFlightInput) {
return _resolveInFlight!;
@@ -190,20 +202,39 @@ class _SendScreenState extends State<SendScreen> {
final wallet = Provider.of<WalletModel>(context, listen: false);
if (mounted) setState(() => _openAliasResolving++);
- final future = wallet.resolveOpenAlias(domain);
- _resolveInFlight = future;
- _resolveInFlightInput = domain;
-
try {
- final resolved = await future;
- _resolveCacheInput = domain;
- _resolveCacheOutput = resolved;
- return resolved;
- } finally {
- if (identical(_resolveInFlight, future)) {
- _resolveInFlight = null;
- _resolveInFlightInput = '';
+ // Wait for the field to settle first. It revalidates on every keystroke
+ // and a half-typed domain ("privacyguides.magicgra") matches the alias
+ // pattern too, so without this one alias would cost a round of Tor
+ // lookups per character. If the user typed on, the newer text has its own
+ // call and this one is abandoned.
+ await Future.delayed(_openAliasTypingDelay);
+ if (!mounted || _destinationAddressController.text != domain) return null;
+
+ // Someone may have resolved this exact alias while we were settling.
+ if (domain == _resolveCacheInput) return _resolveCacheOutput;
+ if (_resolveInFlight != null && domain == _resolveInFlightInput) {
+ return _resolveInFlight!;
}
+
+ // Only a real lookup is published as in-flight, so joining one always
+ // yields a real answer rather than an abandoned attempt.
+ final future = wallet.resolveOpenAlias(domain);
+ _resolveInFlight = future;
+ _resolveInFlightInput = domain;
+
+ try {
+ final resolved = await future;
+ _resolveCacheInput = domain;
+ _resolveCacheOutput = resolved;
+ return resolved;
+ } finally {
+ if (identical(_resolveInFlight, future)) {
+ _resolveInFlight = null;
+ _resolveInFlightInput = '';
+ }
+ }
+ } finally {
if (mounted) setState(() => _openAliasResolving--);
}
}
@@ -211,7 +242,6 @@ class _SendScreenState extends State<SendScreen> {
Future<bool> _validateForm({bool setErrors = true}) async {
final amount = double.tryParse(_amountController.text) ?? 0;
final unresolvedDestinationAddress = _destinationAddressController.text;
- String destinationAddress = '';
if (amount == 0) {
return false;
@@ -223,9 +253,7 @@ class _SendScreenState extends State<SendScreen> {
if (domainRegex.hasMatch(unresolvedDestinationAddress)) {
// OpenAlias: cached + deduped; the counter gates the send button while
// a lookup is in flight.
- destinationAddress = await _resolveDomain(unresolvedDestinationAddress);
-
- if (destinationAddress == '') {
+ if (await _resolveDomain(unresolvedDestinationAddress) == null) {
if (setErrors) {
setState(() {
_destinationAddressError = i18n.sendOpenAliasResolveError;
@@ -233,10 +261,7 @@ class _SendScreenState extends State<SendScreen> {
}
return false;
}
- } else if (wallet.w2Wallet!.addressValid(unresolvedDestinationAddress, 0)) {
- // check for address
- destinationAddress = unresolvedDestinationAddress;
- } else {
+ } else if (!wallet.w2Wallet!.addressValid(unresolvedDestinationAddress, 0)) {
if (setErrors) {
setState(() {
_destinationAddressError = i18n.sendInvalidAddressError;
@@ -376,11 +401,24 @@ class _SendScreenState extends State<SendScreen> {
final amount = double.parse(_amountController.text);
String destinationAddress = '';
String? destinationOpenAlias;
+ String? destinationOpenAliasName;
- // Resolve openalias if it is a domain
+ // Resolve openalias if it is a domain. Validation above already resolved
+ // it, so this comes back from the cache rather than hitting Tor again.
if (domainRegex.hasMatch(destinationAddressUnresolved)) {
- destinationAddress = await wallet.resolveOpenAlias(destinationAddressUnresolved);
+ final resolved = await _resolveDomain(destinationAddressUnresolved);
+
+ if (resolved == null) {
+ setState(() {
+ _isLoading = false;
+ _destinationAddressError = i18n.sendOpenAliasResolveError;
+ });
+ return;
+ }
+
+ destinationAddress = resolved.address;
destinationOpenAlias = destinationAddressUnresolved;
+ destinationOpenAliasName = resolved.recipientName;
} else {
destinationAddress = destinationAddressUnresolved;
}
@@ -419,6 +457,7 @@ class _SendScreenState extends State<SendScreen> {
tx: tx,
destinationAddress: destinationAddress,
destinationOpenAlias: destinationOpenAlias,
+ destinationOpenAliasName: destinationOpenAliasName,
destinationContactName: _selectedContact?.name,
),
);
diff --git a/plugins/openalias_ffi/lib/openalias_ffi.dart b/plugins/openalias_ffi/lib/openalias_ffi.dart
index 5dc45f4..91879c7 100644
--- a/plugins/openalias_ffi/lib/openalias_ffi.dart
+++ b/plugins/openalias_ffi/lib/openalias_ffi.dart
@@ -1,9 +1,14 @@
+import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
+import 'src/openalias_records.dart';
+
+export 'src/openalias_records.dart';
+
const _libName = 'openalias_ffi';
DynamicLibrary _load() {
@@ -17,63 +22,139 @@ DynamicLibrary _load() {
throw UnsupportedError('${Platform.operatingSystem} is not supported');
}
-typedef _ResolveNative = Pointer<Utf8> Function(Pointer<Utf8>, Pointer<Utf8>, Uint16);
-typedef _ResolveDart = Pointer<Utf8> Function(Pointer<Utf8>, Pointer<Utf8>, int);
+typedef _SecureTxtNative = Pointer<Utf8> Function(Pointer<Utf8>, Uint16);
+typedef _SecureTxtDart = Pointer<Utf8> Function(Pointer<Utf8>, int);
typedef _FreeNative = Void Function(Pointer<Utf8>);
typedef _FreeDart = void Function(Pointer<Utf8>);
typedef _LastErrorNative = Pointer<Utf8> Function();
typedef _LastErrorDart = Pointer<Utf8> Function();
-class OpenAliasException implements Exception {
- OpenAliasException(this.message);
- final String message;
- @override
- String toString() => message;
-}
-
-/// Resolves OpenAlias domains to addresses with end-to-end DNSSEC validation,
+/// Resolves OpenAlias aliases to addresses with end-to-end DNSSEC validation,
/// over Tor. Backed by a Rust (hickory) native library.
class OpenAliasFfi {
- /// Resolves [domain]'s `oa1:<asset>` record to a recipient address, routing
- /// DNS through the Tor SOCKS proxy at [socksPort] and requiring the answer to
- /// be DNSSEC-secure. Returns null if no matching record; throws
- /// [OpenAliasException] on failure (no DNSSEC, network, parse, ...).
+ /// Resolves [alias] — an FQDN or an email-style `name@domain` — to a payment
+ /// record, requiring a DNSSEC-secure answer and routing every query through
+ /// the Tor SOCKS proxy at [socksPort].
+ ///
+ /// OpenAlias v2 is preferred and v1 is the fallback, as the spec's
+ /// compatibility rule requires: when the recipient publishes any usable
+ /// `_openalias-payment` record, only those are considered, and the `oa1:`
+ /// record on the FQDN is used only when they publish none.
///
- /// Runs in a background isolate (the native call blocks while it queries Tor).
- static Future<String?> resolve({
- required String domain,
+ /// [network] and [asset] say what the caller can pay (`xmr` and `xmr` for
+ /// Monero). [nativeAsset] is that network's native asset per the OA2 network
+ /// list, which is what a v2 record that omits `asset` denotes. [asset] also
+ /// selects the v1 prefix to look for (`oa1:xmr`).
+ ///
+ /// Throws [NotAnAliasException] if [alias] is a raw address rather than an
+ /// alias, and [OpenAliasException] on anything else that leaves us without an
+ /// address: no record, an answer that is not DNSSEC-secure, a network
+ /// failure, or a recipient who publishes v2 records but none this wallet can
+ /// pay.
+ ///
+ /// The native calls block while they query Tor, so each one runs in a
+ /// background isolate.
+ static Future<OpenAliasResult> resolve({
+ required String alias,
+ required String network,
required String asset,
required int socksPort,
- }) {
- return Isolate.run(() => _resolveSync(domain, asset, socksPort));
+ String? nativeAsset,
+ bool fetchMetadata = true,
+ }) async {
+ final fqdn = normalizeAlias(alias);
+
+ // The three names are independent, and each lookup is a slow round trip
+ // over Tor, so they run concurrently rather than one after another;
+ // resolveFromLookups then decides which answer gets used. Metadata is
+ // fetched for its display name only, and never blocks a payment.
+ final lookups = await Future.wait([
+ _lookup('$oa2PaymentPrefix.$fqdn', socksPort),
+ fetchMetadata
+ ? _lookup('$oa2MetadataPrefix.$fqdn', socksPort)
+ : Future.value(const _TxtLookup.skipped()),
+ _lookup(fqdn, socksPort),
+ ]);
+ final oa2Payment = lookups[0];
+ final oa2Metadata = lookups[1];
+ final oa1 = lookups[2];
+
+ return resolveFromLookups(
+ OpenAliasLookups(
+ paymentRecords: oa2Payment.records,
+ metadataRecords: oa2Metadata.records,
+ oa1Records: oa1.records,
+ paymentProblem: oa2Payment.problem,
+ oa1Problem: oa1.problem,
+ ),
+ alias: alias,
+ network: network,
+ asset: asset,
+ nativeAsset: nativeAsset,
+ );
+ }
+}
+
+/// One name's TXT records, or why there are none. A failed lookup is not fatal
+/// on its own — the caller decides, since v1 can still answer when v2 does not
+/// (and the reason is kept for the error message if nothing answers).
+class _TxtLookup {
+ const _TxtLookup(this.records) : problem = null;
+ const _TxtLookup.failed(this.problem) : records = const [];
+ const _TxtLookup.skipped() : records = const [], problem = 'not looked up';
+
+ final List<String> records;
+ final String? problem;
+}
+
+Future<_TxtLookup> _lookup(String name, int socksPort) async {
+ try {
+ return _TxtLookup(await Isolate.run(() => _secureTxtSync(name, socksPort)));
+ } on OpenAliasException catch (e) {
+ return _TxtLookup.failed(e.message);
+ } catch (e) {
+ return _TxtLookup.failed(e.toString());
}
}
-String? _resolveSync(String domain, String asset, int socksPort) {
+/// Fetches the DNSSEC-validated TXT records at [name] through the native
+/// resolver. Each record comes back with its DNS character-strings already
+/// concatenated. Throws [OpenAliasException] when the answer could not be
+/// validated, the name has no TXT records, or the lookup failed.
+List<String> _secureTxtSync(String name, int socksPort) {
final lib = _load();
- final resolve = lib.lookupFunction<_ResolveNative, _ResolveDart>('openalias_resolve');
+ final secureTxt = lib.lookupFunction<_SecureTxtNative, _SecureTxtDart>('openalias_secure_txt');
final freeStr = lib.lookupFunction<_FreeNative, _FreeDart>('openalias_string_free');
final lastError = lib.lookupFunction<_LastErrorNative, _LastErrorDart>(
'openalias_last_error_message',
);
- final domainPtr = domain.toNativeUtf8();
- final assetPtr = asset.toNativeUtf8();
+ final namePtr = name.toNativeUtf8();
try {
- final result = resolve(domainPtr, assetPtr, socksPort);
+ final result = secureTxt(namePtr, socksPort);
if (result == nullptr) {
final errPtr = lastError();
final message = errPtr == nullptr || errPtr.toDartString().isEmpty
- ? 'OpenAlias resolution failed'
+ ? 'OpenAlias lookup failed'
: errPtr.toDartString();
if (errPtr != nullptr) freeStr(errPtr);
throw OpenAliasException(message);
}
- final address = result.toDartString();
- freeStr(result);
- return address.isEmpty ? null : address;
+
+ final String encoded;
+ try {
+ encoded = result.toDartString();
+ } finally {
+ // The buffer was allocated by Rust; hand it back even if decoding throws.
+ freeStr(result);
+ }
+
+ final decoded = jsonDecode(encoded);
+ if (decoded is! List) {
+ throw OpenAliasException('resolver returned malformed output for $name');
+ }
+ return [for (final record in decoded) record as String];
} finally {
- malloc.free(domainPtr);
- malloc.free(assetPtr);
+ malloc.free(namePtr);
}
}
diff --git a/plugins/openalias_ffi/lib/src/openalias_records.dart b/plugins/openalias_ffi/lib/src/openalias_records.dart
new file mode 100644
index 0000000..277f37c
--- /dev/null
+++ b/plugins/openalias_ffi/lib/src/openalias_records.dart
@@ -0,0 +1,381 @@
+/// Pure-Dart OpenAlias record handling: alias normalization, OpenAlias v1 and
+/// v2 record parsing, and v2 record selection.
+///
+/// Deliberately free of `dart:ffi` so the grammar can be unit-tested without a
+/// native build (see `test/openalias_records_test.dart`). The DNSSEC-validated
+/// lookup that feeds it lives in `openalias_ffi.dart`.
+library;
+
+/// OA2 records live under these prefixes on the alias FQDN, while OA1 records
+/// live on the FQDN itself — which is how both can coexist for one recipient.
+const String oa2PaymentPrefix = '_openalias-payment';
+const String oa2MetadataPrefix = '_openalias-metadata';
+
+/// A record with no `priority` is the lowest priority there is.
+const int _noPriority = 1 << 30;
+
+class OpenAliasException implements Exception {
+ OpenAliasException(this.message);
+
+ final String message;
+
+ @override
+ String toString() => message;
+}
+
+/// The input is a raw address rather than an alias, so nothing was looked up.
+class NotAnAliasException extends OpenAliasException {
+ NotAnAliasException(super.message);
+}
+
+/// A payment destination parsed from an OpenAlias record.
+class OpenAliasPayment {
+ const OpenAliasPayment({
+ required this.version,
+ required this.network,
+ required this.address,
+ this.asset,
+ this.addressType,
+ this.priority,
+ this.amount,
+ this.memo,
+ this.recipientName,
+ this.description,
+ this.fields = const {},
+ });
+
+ /// 1 for an `oa1:` record, 2 for an `_openalias-payment` record.
+ final int version;
+
+ /// OA2 `network`. OA1 has no network field, so its asset prefix stands in
+ /// (`oa1:xmr` → `xmr`), which is what that prefix means in practice.
+ final String network;
+
+ final String address;
+
+ /// OA2 `asset`; null when the record omits it, which denotes the network's
+ /// native asset.
+ final String? asset;
+
+ /// OA2 `address_type`, e.g. `bip352`. Informational.
+ final String? addressType;
+
+ /// OA2 `priority`; lower is preferred. Null when the record omits it.
+ final int? priority;
+
+ /// A requested amount in the asset's standard units, as published. A request
+ /// to prefill, never a constraint on what the sender sends.
+ final String? amount;
+
+ /// A network-specific identifier (destination tag, Stellar memo, legacy
+ /// Monero payment ID). OA1 publishes this as `tx_payment_id`.
+ final String? memo;
+
+ /// OA1 `recipient_name`. OA2 publishes the name in its metadata record
+ /// instead, so this is null for v2 records.
+ final String? recipientName;
+
+ /// OA1 `tx_description`.
+ final String? description;
+
+ /// Every parsed pair, including keys this client does not recognize.
+ final Map<String, String> fields;
+
+ /// The asset this record pays. An omitted `asset` denotes [nativeAsset] — the
+ /// network's native asset per the OA2 network list — which is why the caller
+ /// has to supply it: on some networks the native asset is not the network
+ /// code (the native asset on `base` is `eth`).
+ String? effectiveAsset(String? nativeAsset) => asset ?? nativeAsset;
+}
+
+/// A resolved alias: the record to pay, plus what else the recipient published
+/// for the user to check before sending.
+class OpenAliasResult {
+ const OpenAliasResult({required this.payment, this.alternatives = const [], this.metadata});
+
+ /// The record this wallet should pay.
+ final OpenAliasPayment payment;
+
+ /// The other records this wallet could have paid, next-preferred first.
+ final List<OpenAliasPayment> alternatives;
+
+ /// The recipient's `_openalias-metadata` record, when they publish one (v2
+ /// only).
+ final Map<String, String>? metadata;
+
+ /// 1 if this came from an `oa1:` record, 2 from `_openalias-payment`.
+ int get version => payment.version;
+
+ /// A display name for the recipient: the v2 metadata `name`, or the v1
+ /// `recipient_name`. Recipient-supplied text, for display only — it never
+ /// affects which address is paid.
+ String? get recipientName => metadata?['name'] ?? payment.recipientName;
+}
+
+/// The raw TXT records found at an alias's three names, plus why a lookup came
+/// back empty. Separating this from the lookups themselves keeps the choice
+/// below testable without DNS.
+class OpenAliasLookups {
+ const OpenAliasLookups({
+ this.paymentRecords = const [],
+ this.metadataRecords = const [],
+ this.oa1Records = const [],
+ this.paymentProblem,
+ this.oa1Problem,
+ });
+
+ /// TXT records at `_openalias-payment.<fqdn>`.
+ final List<String> paymentRecords;
+
+ /// TXT records at `_openalias-metadata.<fqdn>`.
+ final List<String> metadataRecords;
+
+ /// TXT records at the alias FQDN itself, where OA1 records live.
+ final List<String> oa1Records;
+
+ /// Why the v2 payment lookup returned nothing, if it failed.
+ final String? paymentProblem;
+
+ /// Why the v1 lookup returned nothing, if it failed.
+ final String? oa1Problem;
+}
+
+/// Decides what to pay from already-fetched records.
+///
+/// OpenAlias v2 is preferred and v1 is the fallback, as the spec's
+/// compatibility rule requires: when the recipient publishes any usable
+/// `_openalias-payment` record, only those are considered, and the `oa1:`
+/// record on the FQDN is used only when they publish none.
+///
+/// [network], [asset] and [nativeAsset] describe what the caller can pay; see
+/// [selectPayments]. [alias] is used only in error messages. Throws
+/// [OpenAliasException] when nothing payable was published.
+OpenAliasResult resolveFromLookups(
+ OpenAliasLookups lookups, {
+ required String alias,
+ required String network,
+ required String asset,
+ String? nativeAsset,
+}) {
+ // A record that fails to parse is skipped rather than fatal: only records
+ // that are actually usable v2 payments count as "the recipient publishes v2",
+ // so one malformed record can't strand a working v1 alias.
+ final v2 = <OpenAliasPayment>[];
+ for (final text in lookups.paymentRecords) {
+ final fields = parseKeyValueRecord(text);
+ if (fields == null) continue;
+ final record = parseOa2Payment(fields);
+ if (record != null) v2.add(record);
+ }
+
+ if (v2.isNotEmpty) {
+ final payable = selectPayments(v2, network: network, asset: asset, nativeAsset: nativeAsset);
+ if (payable.isEmpty) {
+ // Falling back to v1 here would pay an address the recipient has since
+ // superseded, so this fails instead. Record content is publisher-chosen
+ // and this message is logged, so only a bounded summary of it is quoted.
+ final offered = v2.map((record) => _clip(record.network)).toSet().take(4).join(', ');
+ throw OpenAliasException(
+ '$alias publishes ${v2.length} OpenAlias v2 payment record(s) '
+ '($offered) but none for $asset on $network',
+ );
+ }
+ return OpenAliasResult(
+ payment: payable.first,
+ alternatives: payable.skip(1).toList(),
+ metadata: parseMetadata(lookups.metadataRecords),
+ );
+ }
+
+ for (final text in lookups.oa1Records) {
+ final record = parseOa1Payment(text, asset);
+ if (record != null) return OpenAliasResult(payment: record);
+ }
+
+ throw OpenAliasException(
+ 'no OpenAlias record for $asset at $alias '
+ '(v2: ${lookups.paymentProblem ?? 'no usable payment record'}; '
+ 'v1: ${lookups.oa1Problem ?? 'no oa1:$asset record'})',
+ );
+}
+
+/// Normalizes user input into the alias FQDN to query (OA2 "Resolving
+/// OpenAlias Records", step 1).
+///
+/// `donate@openalias.org` becomes `donate.openalias.org`, and a trailing root
+/// dot is dropped. Throws [NotAnAliasException] when the input contains no `.`
+/// — per the spec that is a raw address, not an alias — and
+/// [OpenAliasException] when it is malformed. Internationalized names are
+/// passed through as typed: the native resolver converts non-ASCII labels to
+/// their A-label (Punycode) form when it parses the name.
+String normalizeAlias(String input) {
+ var alias = input.trim();
+ while (alias.endsWith('.')) {
+ alias = alias.substring(0, alias.length - 1);
+ }
+ if (alias.isEmpty) {
+ throw NotAnAliasException('empty input');
+ }
+
+ final ats = '@'.allMatches(alias).length;
+ if (ats > 1) {
+ throw OpenAliasException("malformed alias '$input': more than one '@'");
+ }
+ if (ats == 1) {
+ alias = alias.replaceFirst('@', '.');
+ }
+ if (!alias.contains('.')) {
+ throw NotAnAliasException("'$input' looks like a raw address, not an alias");
+ }
+
+ final labels = alias.split('.');
+ if (labels.any((label) => label.isEmpty)) {
+ throw OpenAliasException("malformed alias '$input': contains an empty label");
+ }
+ if (alias.length > 253 || labels.any((label) => label.length > 63)) {
+ throw OpenAliasException("malformed alias '$input': name is too long for DNS");
+ }
+
+ // DNS names are case-insensitive; lower-casing keeps the caller's resolve
+ // cache from missing on the same alias typed differently.
+ return alias.toLowerCase();
+}
+
+/// Parses one TXT record into `{key: value}` pairs per the OA2 Key-Value
+/// Encoding rules.
+///
+/// Returns null when the record is not key-value data at all, uses a key
+/// outside the allowed characters, or repeats a key — the spec requires
+/// rejecting a repeated key rather than guessing which value was meant.
+Map<String, String>? parseKeyValueRecord(String text) {
+ final fields = <String, String>{};
+
+ // Pairs are separated by ';', with an optional space after it and an optional
+ // trailing ';'. A value may itself contain '=' (only the first is
+ // significant) but never ';'.
+ for (final pair in text.split(';')) {
+ final trimmed = pair.trim();
+ if (trimmed.isEmpty) continue;
+
+ final eq = trimmed.indexOf('=');
+ if (eq < 0) return null;
+
+ final key = trimmed.substring(0, eq).trim().toLowerCase();
+ final value = trimmed.substring(eq + 1).trim();
+ if (!_keyPattern.hasMatch(key)) return null;
+ if (fields.containsKey(key)) return null;
+
+ fields[key] = value;
+ }
+
+ return fields;
+}
+
+/// Keys are ASCII letters, digits and underscores, matched case-insensitively.
+final RegExp _keyPattern = RegExp(r'^[a-z0-9_]+$');
+
+/// Builds a payment record from the fields of an `_openalias-payment` record,
+/// or null when it is not a usable v2 payment: a version other than 2, or a
+/// missing required field, must be rejected rather than interpreted.
+///
+/// Unrecognized keys are kept in [OpenAliasPayment.fields] and otherwise
+/// ignored, so records can gain keys without breaking this client.
+OpenAliasPayment? parseOa2Payment(Map<String, String> fields) {
+ if (fields['oa_version'] != '2') return null;
+
+ final network = _nonEmpty(fields['network']);
+ final address = _nonEmpty(fields['address']);
+ if (network == null || address == null) return null;
+
+ final priority = fields['priority'];
+
+ return OpenAliasPayment(
+ version: 2,
+ network: network,
+ address: address,
+ asset: _nonEmpty(fields['asset']),
+ addressType: _nonEmpty(fields['address_type']),
+ priority: priority == null ? null : int.tryParse(priority),
+ amount: _nonEmpty(fields['amount']),
+ memo: _nonEmpty(fields['memo']),
+ fields: Map.unmodifiable(fields),
+ );
+}
+
+/// Parses an OA1 record (`oa1:<asset> recipient_address=...;`) for [asset].
+/// Returns null when the record is for a different asset, is not an OA1 record,
+/// or has no recipient address.
+OpenAliasPayment? parseOa1Payment(String text, String asset) {
+ final prefix = 'oa1:${asset.toLowerCase()}';
+ final trimmed = text.trimLeft();
+ if (!trimmed.toLowerCase().startsWith(prefix)) return null;
+
+ // The prefix is its own token: `oa1:xmrfoo` is not an `oa1:xmr` record.
+ final rest = trimmed.substring(prefix.length);
+ if (rest.isNotEmpty && !rest.startsWith(' ')) return null;
+
+ final fields = parseKeyValueRecord(rest);
+ if (fields == null) return null;
+
+ final address = _nonEmpty(fields['recipient_address']);
+ if (address == null) return null;
+
+ return OpenAliasPayment(
+ version: 1,
+ network: asset.toLowerCase(),
+ asset: asset.toLowerCase(),
+ address: address,
+ amount: _nonEmpty(fields['tx_amount']),
+ memo: _nonEmpty(fields['tx_payment_id']),
+ recipientName: _nonEmpty(fields['recipient_name']),
+ description: _nonEmpty(fields['tx_description']),
+ fields: Map.unmodifiable(fields),
+ );
+}
+
+/// The subset of [records] this wallet can pay, most-preferred first.
+///
+/// Filters to [network] and [asset] — where a record that omits `asset` denotes
+/// [nativeAsset] — and then orders by `priority`, lower first, with records
+/// that publish no priority last (OA2 "Choosing Priorities": prefer the
+/// recipient's stated priority among the records the sender supports).
+List<OpenAliasPayment> selectPayments(
+ List<OpenAliasPayment> records, {
+ required String network,
+ required String asset,
+ String? nativeAsset,
+}) {
+ final payable = <OpenAliasPayment>[];
+ for (final record in records) {
+ if (!_sameToken(record.network, network)) continue;
+ final effective = record.effectiveAsset(nativeAsset);
+ if (effective != null && _sameToken(effective, asset)) payable.add(record);
+ }
+
+ // Sorted on (priority, published order): List.sort is not stable, and records
+ // sharing a priority should stay in the order the zone published them.
+ final ordered = payable.asMap().entries.toList();
+ ordered.sort((a, b) {
+ final byPriority = (a.value.priority ?? _noPriority).compareTo(b.value.priority ?? _noPriority);
+ return byPriority != 0 ? byPriority : a.key.compareTo(b.key);
+ });
+
+ return [for (final entry in ordered) entry.value];
+}
+
+/// The first `_openalias-metadata` record among [texts], or null. Metadata is
+/// optional and must never block a payment.
+Map<String, String>? parseMetadata(List<String> texts) {
+ for (final text in texts) {
+ final fields = parseKeyValueRecord(text);
+ if (fields != null && fields['oa_version'] == '2') return fields;
+ }
+ return null;
+}
+
+bool _sameToken(String a, String b) => a.toLowerCase() == b.toLowerCase();
+
+/// Bounds a publisher-supplied value quoted in an error message.
+String _clip(String value) => value.length <= 16 ? value : '${value.substring(0, 16)}…';
+
+String? _nonEmpty(String? value) => (value == null || value.isEmpty) ? null : value;
diff --git a/plugins/openalias_ffi/rust/src/lib.rs b/plugins/openalias_ffi/rust/src/lib.rs
index e846123..5ca5b94 100644
--- a/plugins/openalias_ffi/rust/src/lib.rs
+++ b/plugins/openalias_ffi/rust/src/lib.rs
@@ -2,6 +2,13 @@
//! SOCKS proxy. DNS is fetched via TCP through the proxy (Tor has no UDP) and
//! validated locally by hickory (RRSIG → DS → root trust anchor), so no
//! resolver is trusted and no DNS leaks outside Tor.
+//!
+//! The FFI surface is deliberately thin: it returns the DNSSEC-validated TXT
+//! records at a name, and Dart parses the OpenAlias v1 and v2 grammars on top
+//! (see `lib/src/openalias_records.dart`). That split keeps the record parsing
+//! and selection unit-testable without a native build, while the part that has
+//! to be trustworthy — "these bytes really are what the signed zone published"
+//! — stays here.
mod error;
@@ -81,34 +88,44 @@ impl RuntimeProvider for SocksRuntimeProvider {
fn bind_udp(
&self,
- local_addr: SocketAddr,
+ _local_addr: SocketAddr,
_server_addr: SocketAddr,
) -> Pin<Box<dyn Send + Future<Output = io::Result<Self::Udp>>>> {
- // Unused: the resolver is TCP-only (Tor has no UDP). Provided to satisfy
- // the trait.
- Box::pin(async move { TokioUdpSocket::bind(local_addr).await })
+ // Refused, never bound. Tor carries no UDP, so a UDP query could only
+ // leave over clearnet. The resolver is configured with DoH name servers
+ // exclusively and never asks for UDP; failing here means that if that
+ // ever changed, the lookup would fail closed instead of leaking which
+ // alias the user is resolving.
+ Box::pin(async move {
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "UDP is unavailable: OpenAlias DNS must go through the Tor SOCKS proxy",
+ ))
+ })
}
}
-/// Resolve an OpenAlias `domain` for `asset` (e.g. "btc") over Tor with DNSSEC
-/// validation. Returns a newly-allocated address string, or NULL on failure
-/// (see `openalias_last_error_message`). Caller frees with `openalias_string_free`.
+/// Fetches the TXT records at `name` over Tor, requiring a DNSSEC-secure answer.
+///
+/// Returns a JSON array of strings — one entry per TXT record, each already
+/// concatenated from its DNS character-strings (RFC 7208 §3.3) — or NULL on any
+/// failure, which includes "no such name" and "name has no TXT records" (see
+/// `openalias_last_error_message`). Callers treat NULL as "nothing usable
+/// here": the OpenAlias v2 → v1 fallback is driven by which names returned
+/// records, and an answer that could not be validated never returns records.
+///
+/// Caller frees the returned string with `openalias_string_free`.
///
/// # Safety
-/// `domain` and `asset` must be valid NUL-terminated C strings.
+/// `name` must be a valid NUL-terminated C string.
#[no_mangle]
-pub unsafe extern "C" fn openalias_resolve(
- domain: *const c_char,
- asset: *const c_char,
+pub unsafe extern "C" fn openalias_secure_txt(
+ name: *const c_char,
socks_port: u16,
) -> *mut c_char {
- let domain = match cstr(domain) {
+ let name = match cstr(name) {
Some(s) => s,
- None => return ret_err("invalid domain"),
- };
- let asset = match cstr(asset) {
- Some(s) => s,
- None => return ret_err("invalid asset"),
+ None => return ret_err("invalid name"),
};
let runtime = match RUNTIME.as_ref() {
@@ -116,10 +133,10 @@ pub unsafe extern "C" fn openalias_resolve(
Err(e) => return ret_err(format!("tokio runtime: {e}")),
};
- match runtime.block_on(resolve(&domain, &asset, socks_port)) {
- Ok(addr) => match CString::new(addr) {
+ match runtime.block_on(secure_txt(&name, socks_port)) {
+ Ok(records) => match CString::new(json_string_array(&records)) {
Ok(c) => c.into_raw(),
- Err(_) => ret_err("address contained NUL"),
+ Err(_) => ret_err("record contained NUL"),
},
Err(msg) => ret_err(msg),
}
@@ -136,7 +153,7 @@ pub unsafe extern "C" fn openalias_string_free(ptr: *mut c_char) {
}
}
-async fn resolve(domain: &str, asset: &str, socks_port: u16) -> Result<String, String> {
+async fn secure_txt(name: &str, socks_port: u16) -> Result<Vec<String>, String> {
let proxy = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), socks_port);
// DNS-over-HTTPS (default /dns-query on 443). The TLS cert is verified
@@ -157,11 +174,9 @@ async fn resolve(domain: &str, asset: &str, socks_port: u16) -> Result<String, S
.build()
.map_err(|e| format!("resolver build failed: {e}"))?;
- let fqdn = if domain.ends_with('.') {
- domain.to_string()
- } else {
- format!("{domain}.")
- };
+ // Non-ASCII labels are converted to their A-label (Punycode) form by
+ // hickory when it parses the name, as OpenAlias requires (RFC 5890).
+ let fqdn = if name.ends_with('.') { name.to_string() } else { format!("{name}.") };
let lookup = resolver
.lookup(fqdn, RecordType::TXT)
@@ -169,8 +184,11 @@ async fn resolve(domain: &str, asset: &str, socks_port: u16) -> Result<String, S
.map_err(|e| format!("lookup failed: {e}"))?;
// Require DNSSEC-secure: reject unsigned (insecure) and bogus answers.
+ // hickory's `validate` only rejects *bogus* answers on its own — an unsigned
+ // zone still yields records proven Insecure — so this check is what makes
+ // the lookup fail closed.
let proofs: Vec<Proof> = lookup.answers().iter().map(|r| r.proof).collect();
- if !proofs.iter().all(|p| *p == Proof::Secure) {
+ if proofs.is_empty() || !proofs.iter().all(|p| *p == Proof::Secure) {
return Err(format!(
"answer is not DNSSEC-secure (records={}, proofs={:?})",
proofs.len(),
@@ -178,40 +196,55 @@ async fn resolve(domain: &str, asset: &str, socks_port: u16) -> Result<String, S
));
}
- let prefix = format!("oa1:{}", asset.to_lowercase());
- let mut txt_seen = 0usize;
+ let mut records = Vec::new();
for record in lookup.answers() {
if let RData::TXT(txt) = &record.data {
- txt_seen += 1;
- let joined: String = txt
- .txt_data
- .iter()
- .map(|b| String::from_utf8_lossy(b).into_owned())
- .collect();
- if let Some(addr) = parse_oa1(&joined, &prefix) {
- return Ok(addr);
- }
+ // A TXT record is one or more character-strings; concatenate them in
+ // order, with no separator, before the record is parsed.
+ records.push(
+ txt.txt_data
+ .iter()
+ .map(|b| String::from_utf8_lossy(b).into_owned())
+ .collect::<String>(),
+ );
+ }
+ }
+
+ if records.is_empty() {
+ return Err(format!("no TXT records at {name}"));
+ }
+ Ok(records)
+}
+
+/// Encodes `items` as a JSON array of strings. TXT records are attacker-chosen
+/// bytes, so they are escaped rather than framed with a separator that a record
+/// could contain.
+fn json_string_array(items: &[String]) -> String {
+ let mut out = String::from("[");
+ for (i, item) in items.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
}
+ json_escape_into(item, &mut out);
}
- Err(format!("no {prefix} record found ({txt_seen} TXT record(s) present)"))
+ out.push(']');
+ out
}
-/// Parses a concatenated TXT string for an OpenAlias entry matching `prefix`,
-/// returning its `recipient_address`.
-fn parse_oa1(txt: &str, prefix: &str) -> Option<String> {
- // OpenAlias: `oa1:<asset> recipient_address=ADDR; recipient_name=NAME; ...`
- // The prefix and first key share the first (space-separated) segment, so
- // strip the prefix before splitting the key=value fields on ';'.
- let rest = txt.trim_start().strip_prefix(prefix)?;
- for field in rest.split(';') {
- if let Some(value) = field.trim().strip_prefix("recipient_address=") {
- let value = value.trim();
- if !value.is_empty() {
- return Some(value.to_string());
- }
+fn json_escape_into(s: &str, out: &mut String) {
+ out.push('"');
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
+ c => out.push(c),
}
}
- None
+ out.push('"');
}
unsafe fn cstr(ptr: *const c_char) -> Option<String> {
@@ -228,25 +261,29 @@ fn ret_err(msg: impl Into<String>) -> *mut c_char {
#[cfg(test)]
mod tests {
- use super::parse_oa1;
+ use super::json_string_array;
#[test]
- fn parses_btc_recipient() {
- let txt = "oa1:btc recipient_address=1BoatSLRHtKNngkdXEeobR76b53LETtpyT; recipient_name=Donate;";
+ fn encodes_records() {
+ let records = vec![
+ "oa_version=2; network=xmr; address=888tNk;".to_string(),
+ "oa1:xmr recipient_address=888tNk;".to_string(),
+ ];
assert_eq!(
- parse_oa1(txt, "oa1:btc").as_deref(),
- Some("1BoatSLRHtKNngkdXEeobR76b53LETtpyT")
+ json_string_array(&records),
+ r#"["oa_version=2; network=xmr; address=888tNk;","oa1:xmr recipient_address=888tNk;"]"#
);
}
#[test]
- fn ignores_other_assets() {
- let txt = "oa1:xmr recipient_address=4xxx; recipient_name=x;";
- assert_eq!(parse_oa1(txt, "oa1:btc"), None);
+ fn escapes_quotes_backslashes_and_controls() {
+ let records = vec!["a\"b\\c\nd\te\u{1}f".to_string()];
+ assert_eq!(json_string_array(&records), r#"["a\"b\\c\nd\te\u0001f"]"#);
}
#[test]
- fn ignores_non_openalias() {
- assert_eq!(parse_oa1("v=spf1 include:_spf.example.com ~all", "oa1:btc"), None);
+ fn encodes_empty_and_unicode() {
+ assert_eq!(json_string_array(&[]), "[]");
+ assert_eq!(json_string_array(&["münchen".to_string()]), "[\"münchen\"]");
}
}
diff --git a/plugins/openalias_ffi/test/openalias_records_test.dart b/plugins/openalias_ffi/test/openalias_records_test.dart
new file mode 100644
index 0000000..b02c66b
--- /dev/null
+++ b/plugins/openalias_ffi/test/openalias_records_test.dart
@@ -0,0 +1,510 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:openalias_ffi/openalias_ffi.dart';
+
+/// The OA2 test vectors from the spec, plus the OA1 records this wallet has to
+/// keep resolving. Everything here is the pure record layer: no DNS, no FFI.
+void main() {
+ group('normalizeAlias', () {
+ test('turns an email-style alias into an FQDN', () {
+ expect(normalizeAlias('donate@openalias.org'), 'donate.openalias.org');
+ });
+
+ test('passes an FQDN through and drops the root dot', () {
+ expect(normalizeAlias('donate.openalias.org'), 'donate.openalias.org');
+ expect(normalizeAlias('donate.openalias.org.'), 'donate.openalias.org');
+ });
+
+ test('trims and lower-cases', () {
+ expect(normalizeAlias(' Donate@OpenAlias.ORG '), 'donate.openalias.org');
+ });
+
+ test('rejects input with no dot as a raw address', () {
+ expect(
+ () => normalizeAlias('888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjy'),
+ throwsA(isA<NotAnAliasException>()),
+ );
+ expect(() => normalizeAlias('openalias'), throwsA(isA<NotAnAliasException>()));
+ expect(() => normalizeAlias(' '), throwsA(isA<NotAnAliasException>()));
+ });
+
+ test('replaces the @ before deciding whether there is a dot', () {
+ // Per the spec the substitution comes first, so this is a (two-label)
+ // alias, not a raw address.
+ expect(normalizeAlias('donate@openalias'), 'donate.openalias');
+ });
+
+ test('rejects malformed aliases', () {
+ expect(() => normalizeAlias('a@b@openalias.org'), throwsA(isA<OpenAliasException>()));
+ expect(() => normalizeAlias('donate..openalias.org'), throwsA(isA<OpenAliasException>()));
+ expect(() => normalizeAlias('${'a' * 64}.openalias.org'), throwsA(isA<OpenAliasException>()));
+ });
+
+ test('leaves an internationalized name for the resolver to punycode', () {
+ // The native resolver converts non-ASCII labels to their A-label form;
+ // normalization must not mangle them first.
+ expect(normalizeAlias('bob@münchen.example'), 'bob.münchen.example');
+ });
+ });
+
+ group('parseKeyValueRecord', () {
+ test('parses pairs, tolerating spacing and a trailing semicolon', () {
+ final fields = parseKeyValueRecord('oa_version=2; network=btc;address=abc;');
+ expect(fields, {'oa_version': '2', 'network': 'btc', 'address': 'abc'});
+ });
+
+ test('keeps everything after the first equals sign', () {
+ final fields = parseKeyValueRecord('oa_version=2; image=https://x.example/i?a=1&b=2;');
+ expect(fields!['image'], 'https://x.example/i?a=1&b=2');
+ });
+
+ test('lower-cases keys but not values', () {
+ final fields = parseKeyValueRecord('OA_Version=2; Name=OpenAlias Project;');
+ expect(fields, {'oa_version': '2', 'name': 'OpenAlias Project'});
+ });
+
+ test('rejects a repeated key rather than guessing', () {
+ expect(parseKeyValueRecord('oa_version=2; address=a; address=b;'), isNull);
+ });
+
+ test('rejects records that are not key-value data', () {
+ // An early OA2 draft used a bare `oa2 <network>` prefix; it is not valid
+ // under the final spec, and must not be half-parsed into a payment.
+ expect(parseKeyValueRecord('oa2 xmr address=888tNk;'), isNull);
+ expect(parseKeyValueRecord('oa1:xmr recipient_address=888tNk;'), isNull);
+ expect(parseKeyValueRecord('just some text'), isNull);
+ });
+
+ test('parses an unrelated TXT record that happens to be key-value shaped', () {
+ // An SPF record is well-formed key-value data; what disqualifies it is the
+ // missing oa_version, which the payment/metadata parsers check.
+ final fields = parseKeyValueRecord('v=spf1 include:_spf.example.com ~all')!;
+ expect(fields, {'v': 'spf1 include:_spf.example.com ~all'});
+ expect(parseOa2Payment(fields), isNull);
+ });
+ });
+
+ group('parseOa2Payment', () {
+ OpenAliasPayment? parse(String text) {
+ final fields = parseKeyValueRecord(text);
+ return fields == null ? null : parseOa2Payment(fields);
+ }
+
+ test('parses the spec example', () {
+ final record = parse(
+ 'oa_version=2; priority=10; network=btc; '
+ 'address=sp1qqfk0ag4gmq87agdy8lawrlt2mf3p8myhkuxgp5s7kdck4ywwg7mjjq; address_type=bip352;',
+ )!;
+ expect(record.version, 2);
+ expect(record.network, 'btc');
+ expect(record.asset, isNull);
+ expect(record.address, 'sp1qqfk0ag4gmq87agdy8lawrlt2mf3p8myhkuxgp5s7kdck4ywwg7mjjq');
+ expect(record.addressType, 'bip352');
+ expect(record.priority, 10);
+ });
+
+ test('ignores unrecognized keys but keeps them', () {
+ final record = parse('oa_version=2; network=btc; address=abc; future_field=somevalue;')!;
+ expect(record.address, 'abc');
+ expect(record.fields['future_field'], 'somevalue');
+ });
+
+ test('parses a multi-string TXT record once concatenated', () {
+ // DNS may split one record into 255-byte character-strings; the native
+ // resolver concatenates them in order, with no separator, before parsing.
+ const first = 'oa_version=2; network=xmr; address=46BeWrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1p';
+ const second = 'EYL2rAcuwufPN9rXHHtyUA4QVy66qeFQkn6sfK8aHYjA3jk3o1Bv16em;';
+ final record = parse('$first$second')!;
+ expect(
+ record.address,
+ '46BeWrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QV'
+ 'y66qeFQkn6sfK8aHYjA3jk3o1Bv16em',
+ );
+ });
+
+ test('rejects a record that is not version 2', () {
+ expect(parse('oa_version=3; network=xmr; address=abc;'), isNull);
+ expect(parse('network=xmr; address=abc;'), isNull);
+ });
+
+ test('rejects a record missing a required field', () {
+ expect(parse('oa_version=2; address=abc;'), isNull);
+ expect(parse('oa_version=2; network=xmr;'), isNull);
+ expect(parse('oa_version=2; network=xmr; address=;'), isNull);
+ });
+
+ test('parses amount and memo', () {
+ final record = parse('oa_version=2; network=xmr; address=abc; amount=0.01; memo=12345;')!;
+ expect(record.amount, '0.01');
+ expect(record.memo, '12345');
+ });
+ });
+
+ group('selectPayments', () {
+ OpenAliasPayment record(String text) => parseOa2Payment(parseKeyValueRecord(text)!)!;
+
+ test('prefers the lower priority number among payable records', () {
+ // The spec's two-record example: BTC at priority 10, XMR at priority 20.
+ final records = [
+ record(
+ 'oa_version=2; priority=10; network=btc; '
+ 'address=sp1qqfk0ag4gmq87agdy8lawrlt2mf3p8myhkuxgp5s7kdck4ywwg7mjjq;',
+ ),
+ record('oa_version=2; priority=20; network=xmr; address=46BeWrHpwXmHDpDEUmZBWZfoQ;'),
+ record('oa_version=2; priority=5; network=xmr; address=888tNkZrPN6JsEgekjMnABU4TB;'),
+ ];
+
+ final payable = selectPayments(records, network: 'xmr', asset: 'xmr', nativeAsset: 'xmr');
+
+ // The BTC record is filtered out even though it is the highest priority
+ // overall: priority applies after filtering to what the sender can pay.
+ expect(payable.map((r) => r.address), [
+ '888tNkZrPN6JsEgekjMnABU4TB',
+ '46BeWrHpwXmHDpDEUmZBWZfoQ',
+ ]);
+ });
+
+ test('sorts records with no priority last, keeping published order', () {
+ final records = [
+ record('oa_version=2; network=xmr; address=none1;'),
+ record('oa_version=2; priority=30; network=xmr; address=p30;'),
+ record('oa_version=2; network=xmr; address=none2;'),
+ ];
+
+ final payable = selectPayments(records, network: 'xmr', asset: 'xmr', nativeAsset: 'xmr');
+ expect(payable.map((r) => r.address), ['p30', 'none1', 'none2']);
+ });
+
+ test('treats an omitted asset and the network native asset as the same', () {
+ // On Base the native asset is eth, not base: both of these are native.
+ final records = [
+ record('oa_version=2; network=base; address=0xA;'),
+ record('oa_version=2; network=base; asset=eth; address=0xB;'),
+ record('oa_version=2; network=base; asset=usdc; address=0xC;'),
+ ];
+
+ final payable = selectPayments(records, network: 'base', asset: 'eth', nativeAsset: 'eth');
+ expect(payable.map((r) => r.address), ['0xA', '0xB']);
+ });
+
+ test('matches an explicitly stated native asset for xmr', () {
+ final records = [
+ record('oa_version=2; network=xmr; asset=xmr; address=explicit;'),
+ record('oa_version=2; network=xmr; address=omitted;'),
+ ];
+ expect(selectPayments(records, network: 'xmr', asset: 'xmr', nativeAsset: 'xmr').length, 2);
+ });
+
+ test('does not match a token on the network when the native asset is wanted', () {
+ final records = [record('oa_version=2; network=eth; asset=usdt; address=0xUSDT;')];
+ expect(selectPayments(records, network: 'eth', asset: 'eth', nativeAsset: 'eth'), isEmpty);
+ });
+
+ test('matches network and asset case-insensitively', () {
+ final records = [record('oa_version=2; network=XMR; asset=XMR; address=abc;')];
+ expect(
+ selectPayments(records, network: 'xmr', asset: 'xmr', nativeAsset: 'xmr').single.address,
+ 'abc',
+ );
+ });
+
+ test('does not treat an omitted asset as native when the caller cannot say', () {
+ final records = [record('oa_version=2; network=eth; address=0xA;')];
+ expect(selectPayments(records, network: 'eth', asset: 'eth'), isEmpty);
+ });
+ });
+
+ group('parseOa1Payment', () {
+ test('parses a live-style oa1:xmr record', () {
+ const text =
+ 'oa1:xmr recipient_address=888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1'
+ 'UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H; recipient_name=Monero Development; '
+ 'tx_description=Donation to Monero Core Team;';
+
+ final record = parseOa1Payment(text, 'xmr')!;
+ expect(record.version, 1);
+ expect(record.network, 'xmr');
+ expect(record.asset, 'xmr');
+ expect(
+ record.address,
+ '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1'
+ 'UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H',
+ );
+ expect(record.recipientName, 'Monero Development');
+ expect(record.description, 'Donation to Monero Core Team');
+ });
+
+ test('ignores records for other assets', () {
+ const text = 'oa1:btc recipient_address=1KTexdemPdxSBcG55heUuTjDRYqbC5ZL8H;';
+ expect(parseOa1Payment(text, 'xmr'), isNull);
+ expect(parseOa1Payment(text, 'btc')!.address, '1KTexdemPdxSBcG55heUuTjDRYqbC5ZL8H');
+ });
+
+ test('does not match a prefix that is only a prefix', () {
+ expect(parseOa1Payment('oa1:xmrx recipient_address=abc;', 'xmr'), isNull);
+ });
+
+ test('ignores unrelated TXT records', () {
+ expect(parseOa1Payment('v=spf1 include:_spf.example.com ~all', 'xmr'), isNull);
+ expect(parseOa1Payment('oa_version=2; network=xmr; address=abc;', 'xmr'), isNull);
+ });
+
+ test('rejects a record with no recipient address', () {
+ expect(parseOa1Payment('oa1:xmr recipient_name=Nobody;', 'xmr'), isNull);
+ });
+
+ test('parses the optional amount and payment id', () {
+ const text = 'oa1:xmr recipient_address=888tNk; tx_amount=1.5; tx_payment_id=deadbeef;';
+ final record = parseOa1Payment(text, 'xmr')!;
+ expect(record.amount, '1.5');
+ expect(record.memo, 'deadbeef');
+ });
+ });
+
+ group('resolveFromLookups', () {
+ // Shaped after the live records on privacyguides.magicgrants.org, with
+ // distinct addresses per version so it is clear which one was used.
+ const oa2Xmr =
+ 'oa_version=2; priority=10; network=xmr; asset=xmr; address=882XLsoGHjXipTq8oKF35H2;';
+ const oa2Btc = 'oa_version=2; priority=5; network=btc; address=bc1q3hrzx7h45m5jjqu5rlkl6ct;';
+ const oa2Metadata = 'oa_version=2; name=MAGIC Privacy Guides Fund;';
+ const oa1Xmr =
+ 'oa1:xmr recipient_address=888tNkZrPN6JsEgekjMnABU4TB; recipient_name=Older Record;';
+
+ OpenAliasResult resolve(OpenAliasLookups lookups) => resolveFromLookups(
+ lookups,
+ alias: 'privacyguides.magicgrants.org',
+ network: 'xmr',
+ asset: 'xmr',
+ nativeAsset: 'xmr',
+ );
+
+ test('prefers a v2 record over the v1 record on the same alias', () {
+ final result = resolve(
+ const OpenAliasLookups(
+ paymentRecords: [oa2Xmr],
+ metadataRecords: [oa2Metadata],
+ oa1Records: [oa1Xmr],
+ ),
+ );
+
+ expect(result.version, 2);
+ expect(result.payment.address, '882XLsoGHjXipTq8oKF35H2');
+ expect(result.recipientName, 'MAGIC Privacy Guides Fund');
+ });
+
+ test('falls back to v1 when the alias publishes no v2 records', () {
+ final result = resolve(
+ const OpenAliasLookups(
+ oa1Records: [oa1Xmr],
+ paymentProblem: 'lookup failed: no records found',
+ ),
+ );
+
+ expect(result.version, 1);
+ expect(result.payment.address, '888tNkZrPN6JsEgekjMnABU4TB');
+ expect(result.recipientName, 'Older Record');
+ expect(result.metadata, isNull);
+ });
+
+ test('falls back to v1 when every v2 record is unparseable', () {
+ // Some zones still publish the pre-final OA2 draft syntax; it must not
+ // strand an alias whose v1 record still works.
+ final result = resolve(
+ const OpenAliasLookups(
+ paymentRecords: ['oa2 xmr address=882XLsoGHjXipTq8oKF35H2;'],
+ oa1Records: [oa1Xmr],
+ ),
+ );
+
+ expect(result.version, 1);
+ });
+
+ test('does not fall back to v1 when v2 records exist but none are payable', () {
+ // The v2 records are the recipient's current statement of where to pay
+ // them; quietly using the superseded v1 address instead would be wrong.
+ expect(
+ () => resolve(const OpenAliasLookups(paymentRecords: [oa2Btc], oa1Records: [oa1Xmr])),
+ throwsA(isA<OpenAliasException>()),
+ );
+ });
+
+ test('picks the highest-priority payable v2 record and keeps the rest', () {
+ final result = resolve(
+ const OpenAliasLookups(
+ paymentRecords: [
+ oa2Btc,
+ 'oa_version=2; priority=20; network=xmr; address=lowerPriority;',
+ oa2Xmr,
+ ],
+ ),
+ );
+
+ expect(result.payment.address, '882XLsoGHjXipTq8oKF35H2'); // priority 10
+ expect(result.alternatives.map((r) => r.address), ['lowerPriority']); // priority 20
+ });
+
+ test('throws and reports both lookups when nothing was published', () {
+ expect(
+ () => resolve(
+ const OpenAliasLookups(paymentProblem: 'timed out', oa1Problem: 'no TXT records'),
+ ),
+ throwsA(
+ isA<OpenAliasException>().having(
+ (e) => e.message,
+ 'message',
+ allOf(contains('timed out'), contains('no TXT records')),
+ ),
+ ),
+ );
+ });
+
+ test('ignores v1 records for other assets', () {
+ expect(
+ () => resolve(
+ const OpenAliasLookups(oa1Records: ['oa1:btc recipient_address=1KTexdemPdxSBcG55he;']),
+ ),
+ throwsA(isA<OpenAliasException>()),
+ );
+ });
+ });
+
+ group('hostile records', () {
+ // Record content is whatever the zone publishes. DNSSEC proves who
+ // published it, not that it is sane, so nothing here may throw.
+ const nasty = [
+ '',
+ ';;;;',
+ '=',
+ '=value',
+ 'oa_version=2',
+ 'oa_version=2;;;; network=xmr;; address=abc;;;;',
+ 'oa_version=2; network=xmr; address=;',
+ 'oa_version=2; network=; address=abc;',
+ 'oa_version=2; network=xmr; address=abc; priority=notanumber;',
+ 'oa_version=2; network=xmr; address=abc; priority=99999999999999999999999;',
+ 'oa_version=2; network=xmr; address=abc; priority=-2147483648;',
+ 'oa_version=2; network=xmr; address=abc; amount=NaN; memo=;',
+ 'oa_version=2; network=xmr; address=abc; = ;',
+ 'oa1:xmr',
+ 'oa1:xmr ',
+ 'oa1:xmr recipient_address=;',
+ 'oa1:xmr recipient_address=a; recipient_address=b;',
+ 'oa1:xmr=weird',
+ '\u0000\u0001\u001f',
+ '🙂=🙃; oa_version=2;',
+ ];
+
+ test('parsing never throws', () {
+ for (final text in nasty) {
+ expect(
+ () {
+ final fields = parseKeyValueRecord(text);
+ if (fields != null) parseOa2Payment(fields);
+ parseOa1Payment(text, 'xmr');
+ parseMetadata([text]);
+ },
+ returnsNormally,
+ reason: 'input: $text',
+ );
+ }
+ });
+
+ test('resolution never throws anything but OpenAliasException', () {
+ for (final text in nasty) {
+ expect(
+ () => resolveFromLookups(
+ OpenAliasLookups(paymentRecords: [text], metadataRecords: [text], oa1Records: [text]),
+ alias: 'example.com',
+ network: 'xmr',
+ asset: 'xmr',
+ nativeAsset: 'xmr',
+ ),
+ anyOf(returnsNormally, throwsA(isA<OpenAliasException>())),
+ reason: 'input: $text',
+ );
+ }
+ });
+
+ test('an unparseable or absurd priority sorts last instead of throwing', () {
+ final records = [
+ parseOa2Payment(
+ parseKeyValueRecord('oa_version=2; priority=notanumber; network=xmr; address=bad;')!,
+ )!,
+ parseOa2Payment(
+ parseKeyValueRecord('oa_version=2; priority=7; network=xmr; address=good;')!,
+ )!,
+ ];
+ final payable = selectPayments(records, network: 'xmr', asset: 'xmr', nativeAsset: 'xmr');
+ expect(payable.map((r) => r.address), ['good', 'bad']);
+ });
+
+ test('an oversized address is returned verbatim for the caller to reject', () {
+ // The plugin does not know what a valid address looks like; the wallet
+ // validates before anything downstream sees it.
+ final huge = 'A' * 60000;
+ final result = resolveFromLookups(
+ OpenAliasLookups(paymentRecords: ['oa_version=2; network=xmr; address=$huge;']),
+ alias: 'example.com',
+ network: 'xmr',
+ asset: 'xmr',
+ nativeAsset: 'xmr',
+ );
+ expect(result.payment.address, huge);
+ });
+
+ test('a record set far larger than a DNS answer still resolves in order', () {
+ final many = [
+ for (var i = 0; i < 2000; i++)
+ 'oa_version=2; priority=${2000 - i}; network=xmr; address=addr$i;',
+ ];
+ final result = resolveFromLookups(
+ OpenAliasLookups(paymentRecords: many),
+ alias: 'example.com',
+ network: 'xmr',
+ asset: 'xmr',
+ nativeAsset: 'xmr',
+ );
+ expect(result.payment.address, 'addr1999'); // priority 1
+ expect(result.alternatives, hasLength(1999));
+ });
+
+ test('a publisher cannot flood the error message with record content', () {
+ final flood = [
+ for (var i = 0; i < 50; i++) 'oa_version=2; network=${'n' * 500}$i; address=a$i;',
+ ];
+ expect(
+ () => resolveFromLookups(
+ OpenAliasLookups(paymentRecords: flood),
+ alias: 'example.com',
+ network: 'xmr',
+ asset: 'xmr',
+ nativeAsset: 'xmr',
+ ),
+ throwsA(
+ isA<OpenAliasException>().having(
+ (e) => e.message.length,
+ 'message length',
+ lessThan(200),
+ ),
+ ),
+ );
+ });
+ });
+
+ group('parseMetadata', () {
+ test('returns the first version 2 metadata record', () {
+ final metadata = parseMetadata([
+ 'v=spf1 -all',
+ 'oa_version=2; name=OpenAlias Project; image=https://openalias.org/image.png;',
+ ])!;
+ expect(metadata['name'], 'OpenAlias Project');
+ expect(metadata['image'], 'https://openalias.org/image.png');
+ });
+
+ test('ignores draft-format and non-OpenAlias records', () {
+ expect(parseMetadata(['oa2 name=MAGIC Grants; image=https://x.example/i.png']), isNull);
+ expect(parseMetadata(['name=No version;']), isNull);
+ expect(parseMetadata([]), isNull);
+ });
+ });
+}
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.