├── analysis_options.yaml ├── .vscode ├── settings.json ├── launch.json ├── tasks.json └── dart.code-snippets ├── bin └── bw_pinentry.dart ├── .github ├── dependabot.yml └── workflows │ ├── auto_update.yaml │ └── ci.yaml ├── lib └── src │ ├── app │ ├── bitwarden │ │ ├── models │ │ │ ├── bw_field_type.dart │ │ │ ├── bw_item_type.dart │ │ │ ├── bw_match_type.dart │ │ │ ├── bw_uri.dart │ │ │ ├── bw_field.dart │ │ │ ├── bw_status.dart │ │ │ ├── bw_login.dart │ │ │ └── bw_object.dart │ │ └── bitwarden_cli.dart │ └── pinentry │ │ ├── bw_pinentry_client.dart │ │ └── bw_pinentry_server.dart │ └── assuan │ ├── core │ ├── protocol │ │ ├── base │ │ │ ├── assuan_message.dart │ │ │ ├── assuan_exception.dart │ │ │ ├── assuan_message_handler.dart │ │ │ ├── assuan_data_writer.dart │ │ │ ├── assuan_error_code.dart │ │ │ └── assuan_data_reader.dart │ │ ├── requests │ │ │ ├── assuan_bye_request.dart │ │ │ ├── assuan_end_request.dart │ │ │ ├── assuan_nop_request.dart │ │ │ ├── assuan_help_request.dart │ │ │ ├── assuan_reset_request.dart │ │ │ ├── assuan_cancel_request.dart │ │ │ └── assuan_option_request.dart │ │ ├── assuan_comment.dart │ │ ├── responses │ │ │ ├── assuan_ok_response.dart │ │ │ ├── assuan_error_response.dart │ │ │ ├── assuan_inquire_response.dart │ │ │ └── assuan_status_response.dart │ │ ├── assuan_data_message.dart │ │ └── assuan_protocol.dart │ ├── codec │ │ ├── converter_sink.dart │ │ ├── assuan_data_decoder.dart │ │ ├── auto_newline_converter.dart │ │ ├── assuan_codec.dart │ │ ├── assuan_percent_codec.dart │ │ ├── assuan_message_encoder.dart │ │ ├── assuan_message_decoder.dart │ │ └── assuan_data_encoder.dart │ └── services │ │ ├── models │ │ ├── inquiry_reply.dart │ │ ├── server_reply.dart │ │ └── pending_reply.dart │ │ ├── assuan_server.dart │ │ └── assuan_client.dart │ └── pinentry │ ├── services │ ├── pinentry_server.dart │ └── pinentry_client.dart │ └── protocol │ ├── requests │ ├── pinentry_get_pin_request.dart │ ├── pinentry_message_request.dart │ ├── pinentry_set_gen_pin_request.dart │ ├── pinentry_set_repeat_request.dart │ ├── pinentry_enable_quality_bar_request.dart │ ├── pinentry_set_keyinfo_request.dart │ ├── pinentry_set_timeout_request.dart │ ├── pinentry_get_info_request.dart │ ├── pinentry_set_text_request.dart │ └── pinentry_confirm_request.dart │ └── pinentry_protocol.dart ├── README.md ├── tool ├── setup_hooks.dart └── pinentry_wrapper.dart ├── .gitignore ├── pubspec.yaml ├── CHANGELOG.md └── LICENSE /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:dart_test_tools/strict.yaml 2 | plugins: 3 | dart_test_tools: ^7.0.1 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "assuan", 4 | "bitwarden", 5 | "keyinfo", 6 | "pinentry" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /bin/bw_pinentry.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:bw_pinentry/src/app/pinentry/bw_pinentry_server.dart'; 4 | 5 | void main(List arguments) => BwPinentryServer(stdin, stdout, arguments); 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pub" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_field_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | @JsonEnum(valueField: 'id') 4 | enum BwFieldType { 5 | text(0), 6 | hidden(1), 7 | boolean(2), 8 | linked(3); 9 | 10 | final int id; 11 | 12 | const BwFieldType(this.id); 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_item_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | @JsonEnum(valueField: 'id') 4 | enum BwItemType { 5 | login(1), 6 | secureNote(2), 7 | card(3), 8 | identity(4), 9 | sshKey(5); 10 | 11 | final int id; 12 | const BwItemType(this.id); 13 | } 14 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_match_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | @JsonEnum(valueField: 'id') 4 | enum BwMatchType { 5 | domain(0), 6 | host(1), 7 | startsWith(2), 8 | exact(3), 9 | regExp(4), 10 | never(5); 11 | 12 | final int id; 13 | const BwMatchType(this.id); 14 | } 15 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/base/assuan_message.dart: -------------------------------------------------------------------------------- 1 | abstract interface class AssuanMessage { 2 | String get command; 3 | } 4 | 5 | abstract interface class AssuanRequest implements AssuanMessage {} 6 | 7 | abstract interface class AssuanResponse implements AssuanMessage {} 8 | 9 | abstract interface class AssuanCommentBase 10 | implements AssuanRequest, AssuanResponse {} 11 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/converter_sink.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | class ConverterSink implements Sink { 4 | final Sink _sink; 5 | final Converter _converter; 6 | 7 | ConverterSink(this._sink, this._converter); 8 | 9 | @override 10 | void add(S data) => _sink.add(_converter.convert(data)); 11 | 12 | @override 13 | void close() => _sink.close(); 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "bw-pinentry", 9 | "request": "launch", 10 | "type": "dart" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bw-pinentry 2 | [![Continuous integration](https://github.com/Skycoder42/bw-pinentry/actions/workflows/ci.yaml/badge.svg)](https://github.com/Skycoder42/bw-pinentry/actions/workflows/ci.yaml) 3 | [![AUR Version](https://img.shields.io/aur/version/bw-pinentry)](https://aur.archlinux.org/packages/bw-pinentry) 4 | 5 | 6 | A pinentry wrapper around the bitwarden CLI to use your vault for GPG-Key storage. 7 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_uri.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import 'bw_match_type.dart'; 4 | 5 | part 'bw_uri.freezed.dart'; 6 | part 'bw_uri.g.dart'; 7 | 8 | @freezed 9 | sealed class BwUri with _$BwUri { 10 | const factory BwUri(String uri, [BwMatchType? match]) = _BwUri; 11 | 12 | factory BwUri.fromJson(Map json) => _$BwUriFromJson(json); 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/auto_update.yaml: -------------------------------------------------------------------------------- 1 | name: Automatic dependency updates 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "34 2 * * 3" 7 | 8 | jobs: 9 | ci: 10 | name: Updates 11 | uses: Skycoder42/dart_test_tools/.github/workflows/auto-update.yml@main 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | with: 16 | flutterCompat: false 17 | secrets: 18 | githubToken: ${{ secrets.GH_PAT }} 19 | -------------------------------------------------------------------------------- /lib/src/assuan/core/services/models/inquiry_reply.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'inquiry_reply.freezed.dart'; 4 | 5 | @freezed 6 | sealed class InquiryReply with _$InquiryReply { 7 | const factory InquiryReply.data(String data) = InquiryDataReply; 8 | const factory InquiryReply.dataStream(Stream stream) = 9 | InquiryDataStreamReply; 10 | const factory InquiryReply.cancel() = InquiryCancelReply; 11 | } 12 | -------------------------------------------------------------------------------- /lib/src/assuan/core/services/models/server_reply.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'server_reply.freezed.dart'; 4 | 5 | @freezed 6 | sealed class ServerReply with _$ServerReply { 7 | const factory ServerReply.ok([String? message]) = OkReply; 8 | const factory ServerReply.data(String data, [String? message]) = DataReply; 9 | const factory ServerReply.dataStream(Stream data, [String? message]) = 10 | DataStreamReply; 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "dart", 6 | "command": "dart", 7 | "cwd": "", 8 | "args": [ 9 | "run", 10 | "build_runner", 11 | "watch" 12 | ], 13 | "problemMatcher": [], 14 | "label": "dart: dart run build_runner watch", 15 | "detail": "", 16 | "group": { 17 | "kind": "build", 18 | "isDefault": true 19 | }, 20 | "runOptions": { 21 | "runOn": "folderOpen" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_field.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import 'bw_field_type.dart'; 4 | 5 | part 'bw_field.freezed.dart'; 6 | part 'bw_field.g.dart'; 7 | 8 | @freezed 9 | sealed class BwField with _$BwField { 10 | const factory BwField({ 11 | required BwFieldType type, 12 | String? name, 13 | String? value, 14 | int? linkedId, 15 | }) = _BwField; 16 | 17 | factory BwField.fromJson(Map json) => 18 | _$BwFieldFromJson(json); 19 | } 20 | -------------------------------------------------------------------------------- /tool/setup_hooks.dart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env dart 2 | 3 | import 'dart:io'; 4 | 5 | Future main() async { 6 | final preCommitHook = File('.git/hooks/pre-commit'); 7 | await preCommitHook.parent.create(); 8 | await preCommitHook.writeAsString(''' 9 | #!/bin/sh 10 | exec dart run dart_pre_commit 11 | '''); 12 | 13 | if (!Platform.isWindows) { 14 | final result = await Process.run('chmod', ['a+x', preCommitHook.path]); 15 | stdout.write(result.stdout); 16 | stderr.write(result.stderr); 17 | exitCode = result.exitCode; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/assuan/core/services/models/pending_reply.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | import '../../protocol/assuan_data_message.dart'; 6 | 7 | part 'pending_reply.freezed.dart'; 8 | 9 | @freezed 10 | sealed class PendingReply with _$PendingReply { 11 | const factory PendingReply.action(Completer completer) = 12 | PendingActionReply; 13 | const factory PendingReply.data( 14 | StreamController controller, 15 | ) = PendingDataReply; 16 | 17 | const PendingReply._(); 18 | } 19 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_status.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'bw_status.freezed.dart'; 4 | part 'bw_status.g.dart'; 5 | 6 | enum Status { unauthenticated, locked, unlocked } 7 | 8 | @freezed 9 | sealed class BwStatus with _$BwStatus { 10 | const factory BwStatus({ 11 | required Status status, 12 | Uri? serverUrl, 13 | DateTime? lastSync, 14 | String? userId, 15 | String? userEmail, 16 | }) = _BwStatus; 17 | 18 | factory BwStatus.fromJson(Map json) => 19 | _$BwStatusFromJson(json); 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_login.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import 'bw_uri.dart'; 4 | 5 | part 'bw_login.freezed.dart'; 6 | part 'bw_login.g.dart'; 7 | 8 | @freezed 9 | sealed class BwLogin with _$BwLogin { 10 | const factory BwLogin({ 11 | String? username, 12 | String? password, 13 | String? totp, 14 | @Default([]) List uris, 15 | @Default([]) List fido2Credentials, 16 | DateTime? passwordRevisionDate, 17 | }) = _BwLogin; 18 | 19 | factory BwLogin.fromJson(Map json) => 20 | _$BwLoginFromJson(json); 21 | } 22 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/base/assuan_exception.dart: -------------------------------------------------------------------------------- 1 | import 'assuan_error_code.dart'; 2 | 3 | class AssuanException implements Exception { 4 | final int code; 5 | final String message; 6 | 7 | AssuanException(this.message, [int? code]) 8 | : code = code ?? AssuanErrorCode.general.code; 9 | 10 | AssuanException.code(AssuanErrorCode code, [String? message]) 11 | : code = code.code, 12 | message = message ?? code.message; 13 | 14 | AssuanErrorCode? get knowErrorCode => 15 | AssuanErrorCode.values.where((c) => c.code == code).firstOrNull; 16 | 17 | @override 18 | String toString() => 'AssuanException($code): $message'; 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/services/pinentry_server.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:stream_channel/stream_channel.dart'; 4 | 5 | import '../../core/services/assuan_server.dart'; 6 | import '../protocol/pinentry_protocol.dart'; 7 | 8 | abstract class PinentryServer extends AssuanServer { 9 | static const notConfirmedCode = 0x05000063; 10 | 11 | PinentryServer(StreamChannel channel) 12 | : super(PinentryProtocol(), channel); 13 | 14 | PinentryServer.raw(StreamChannel> channel, {super.encoding}) 15 | : super.raw(PinentryProtocol(), channel); 16 | 17 | PinentryServer.io(Stdin stdin, Stdout stdout, {super.encoding}) 18 | : super.io(PinentryProtocol(), stdin, stdout); 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/base/assuan_message_handler.dart: -------------------------------------------------------------------------------- 1 | import 'assuan_data_reader.dart'; 2 | import 'assuan_data_writer.dart'; 3 | import 'assuan_message.dart'; 4 | 5 | abstract interface class AssuanMessageHandler { 6 | bool hasData(T message); 7 | 8 | void encodeData(T message, AssuanDataWriter writer); 9 | 10 | T decodeData(AssuanDataReader reader); 11 | } 12 | 13 | abstract class EmptyAssuanMessageHandler 14 | implements AssuanMessageHandler { 15 | final T Function() _factory; 16 | 17 | const EmptyAssuanMessageHandler(this._factory); 18 | 19 | @override 20 | bool hasData(_) => false; 21 | 22 | @override 23 | void encodeData(_, _) {} 24 | 25 | @override 26 | T decodeData(_) => _factory(); 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/requests/assuan_bye_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_message.dart'; 4 | import '../base/assuan_message_handler.dart'; 5 | 6 | part 'assuan_bye_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class AssuanByeRequest with _$AssuanByeRequest implements AssuanRequest { 10 | static const cmd = 'BYE'; 11 | static const handler = AssuanByeRequestHandler(); 12 | 13 | const factory AssuanByeRequest() = _AssuanByeRequest; 14 | 15 | const AssuanByeRequest._(); 16 | 17 | @override 18 | String get command => cmd; 19 | } 20 | 21 | class AssuanByeRequestHandler 22 | extends EmptyAssuanMessageHandler { 23 | const AssuanByeRequestHandler() : super(AssuanByeRequest.new); 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/requests/assuan_end_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_message.dart'; 4 | import '../base/assuan_message_handler.dart'; 5 | 6 | part 'assuan_end_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class AssuanEndRequest with _$AssuanEndRequest implements AssuanRequest { 10 | static const cmd = 'END'; 11 | static const handler = AssuanEndRequestHandler(); 12 | 13 | const factory AssuanEndRequest() = _AssuanEndRequest; 14 | 15 | const AssuanEndRequest._(); 16 | 17 | @override 18 | String get command => cmd; 19 | } 20 | 21 | class AssuanEndRequestHandler 22 | extends EmptyAssuanMessageHandler { 23 | const AssuanEndRequestHandler() : super(AssuanEndRequest.new); 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/requests/assuan_nop_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_message.dart'; 4 | import '../base/assuan_message_handler.dart'; 5 | 6 | part 'assuan_nop_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class AssuanNopRequest with _$AssuanNopRequest implements AssuanRequest { 10 | static const cmd = 'NOP'; 11 | static const handler = AssuanNopRequestHandler(); 12 | 13 | const factory AssuanNopRequest() = _AssuanNopRequest; 14 | 15 | const AssuanNopRequest._(); 16 | 17 | @override 18 | String get command => cmd; 19 | } 20 | 21 | class AssuanNopRequestHandler 22 | extends EmptyAssuanMessageHandler { 23 | const AssuanNopRequestHandler() : super(AssuanNopRequest.new); 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/models/bw_object.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import 'bw_field.dart'; 4 | import 'bw_item_type.dart'; 5 | import 'bw_login.dart'; 6 | 7 | part 'bw_object.freezed.dart'; 8 | part 'bw_object.g.dart'; 9 | 10 | @Freezed(unionKey: 'object') 11 | sealed class BwObject with _$BwObject { 12 | const factory BwObject.folder({required String id, required String name}) = 13 | BwFolder; 14 | 15 | const factory BwObject.item({ 16 | required String id, 17 | required String name, 18 | required String folderId, 19 | required BwItemType type, 20 | @Default([]) List fields, 21 | BwLogin? login, 22 | }) = BwItem; 23 | 24 | factory BwObject.fromJson(Map json) => 25 | _$BwObjectFromJson(json); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/requests/assuan_help_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_message.dart'; 4 | import '../base/assuan_message_handler.dart'; 5 | 6 | part 'assuan_help_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class AssuanHelpRequest 10 | with _$AssuanHelpRequest 11 | implements AssuanRequest { 12 | static const cmd = 'HELP'; 13 | static const handler = AssuanHelpRequestHandler(); 14 | 15 | const factory AssuanHelpRequest() = _AssuanHelpRequest; 16 | 17 | const AssuanHelpRequest._(); 18 | 19 | @override 20 | String get command => cmd; 21 | } 22 | 23 | class AssuanHelpRequestHandler 24 | extends EmptyAssuanMessageHandler { 25 | const AssuanHelpRequestHandler() : super(AssuanHelpRequest.new); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/requests/assuan_reset_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_message.dart'; 4 | import '../base/assuan_message_handler.dart'; 5 | 6 | part 'assuan_reset_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class AssuanResetRequest 10 | with _$AssuanResetRequest 11 | implements AssuanRequest { 12 | static const cmd = 'RESET'; 13 | static const handler = AssuanResetRequestHandler(); 14 | 15 | const factory AssuanResetRequest() = _AssuanResetRequest; 16 | 17 | const AssuanResetRequest._(); 18 | 19 | @override 20 | String get command => cmd; 21 | } 22 | 23 | class AssuanResetRequestHandler 24 | extends EmptyAssuanMessageHandler { 25 | const AssuanResetRequestHandler() : super(AssuanResetRequest.new); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/requests/assuan_cancel_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_message.dart'; 4 | import '../base/assuan_message_handler.dart'; 5 | 6 | part 'assuan_cancel_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class AssuanCancelRequest 10 | with _$AssuanCancelRequest 11 | implements AssuanRequest { 12 | static const cmd = 'CAN'; 13 | static const handler = AssuanCancelRequestHandler(); 14 | 15 | const factory AssuanCancelRequest() = _AssuanCancelRequest; 16 | 17 | const AssuanCancelRequest._(); 18 | 19 | @override 20 | String get command => cmd; 21 | } 22 | 23 | class AssuanCancelRequestHandler 24 | extends EmptyAssuanMessageHandler { 25 | const AssuanCancelRequestHandler() : super(AssuanCancelRequest.new); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_get_pin_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_message.dart'; 4 | import '../../../core/protocol/base/assuan_message_handler.dart'; 5 | 6 | part 'pinentry_get_pin_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class PinentryGetPinRequest 10 | with _$PinentryGetPinRequest 11 | implements AssuanRequest { 12 | static const cmd = 'GETPIN'; 13 | static const handler = PinentryGetPinRequestHandler(); 14 | 15 | const factory PinentryGetPinRequest() = _PinentryGetPinRequest; 16 | 17 | const PinentryGetPinRequest._(); 18 | 19 | @override 20 | String get command => cmd; 21 | } 22 | 23 | class PinentryGetPinRequestHandler 24 | extends EmptyAssuanMessageHandler { 25 | const PinentryGetPinRequestHandler() : super(PinentryGetPinRequest.new); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_message_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_message.dart'; 4 | import '../../../core/protocol/base/assuan_message_handler.dart'; 5 | 6 | part 'pinentry_message_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class PinentryMessageRequest 10 | with _$PinentryMessageRequest 11 | implements AssuanRequest { 12 | static const cmd = 'MESSAGE'; 13 | static const handler = PinentryMessageRequestHandler(); 14 | 15 | const factory PinentryMessageRequest() = _PinentryMessageRequest; 16 | 17 | const PinentryMessageRequest._(); 18 | 19 | @override 20 | String get command => cmd; 21 | } 22 | 23 | class PinentryMessageRequestHandler 24 | extends EmptyAssuanMessageHandler { 25 | const PinentryMessageRequestHandler() : super(PinentryMessageRequest.new); 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | *.exe 8 | # If you're building an application, you may want to check-in your pubspec.lock 9 | pubspec.lock 10 | 11 | # Directory created by dartdoc 12 | # If you don't generate documentation locally you can remove this line. 13 | doc/api/ 14 | 15 | # dotenv environment variables file 16 | .env* 17 | 18 | # Avoid committing generated Javascript files: 19 | *.dart.js 20 | *.info.json # Produced by the --dump-info flag. 21 | *.js # When generated by dart2js. Don't specify *.js if your 22 | # project includes source files written in JavaScript. 23 | *.js_ 24 | *.js.deps 25 | *.js.map 26 | 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | 30 | # Generated files 31 | lib/gen 32 | *.freezed.dart 33 | *.g.dart 34 | 35 | # logfiles 36 | *.log 37 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/assuan_data_decoder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import '../protocol/assuan_data_message.dart'; 4 | import 'assuan_percent_codec.dart'; 5 | 6 | class AssuanDataDecoder 7 | extends StreamTransformerBase { 8 | const AssuanDataDecoder(); 9 | 10 | @override 11 | Stream bind(Stream stream) => 12 | Stream.eventTransformed(stream, _AssuanDataDecoderSink.new); 13 | } 14 | 15 | class _AssuanDataDecoderSink implements EventSink { 16 | final EventSink _sink; 17 | 18 | _AssuanDataDecoderSink(this._sink); 19 | 20 | @override 21 | void add(AssuanDataMessage event) => 22 | _sink.add(assuanPercentCodec.decode(event.data)); 23 | 24 | @override 25 | void addError(Object error, [StackTrace? stackTrace]) => 26 | _sink.addError(error, stackTrace); 27 | 28 | @override 29 | void close() => _sink.close(); 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "**" 8 | pull_request: 9 | branches: 10 | - "**" 11 | 12 | jobs: 13 | ci: 14 | name: CI 15 | uses: Skycoder42/dart_test_tools/.github/workflows/dart.yml@main 16 | with: 17 | buildRunner: true 18 | unitTestPaths: '' 19 | 20 | cd: 21 | name: CD 22 | needs: 23 | - ci 24 | uses: Skycoder42/dart_test_tools/.github/workflows/compile.yml@main 25 | permissions: 26 | contents: write 27 | with: 28 | enabledPlatforms: ${{ needs.ci.outputs.enabledPlatforms }} 29 | buildRunner: true 30 | 31 | aur: 32 | name: AUR 33 | needs: 34 | - cd 35 | if: needs.cd.outputs.releaseCreated == 'true' 36 | uses: Skycoder42/dart_test_tools/.github/workflows/aur.yml@main 37 | secrets: 38 | AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 39 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_set_gen_pin_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_message.dart'; 4 | import '../../../core/protocol/base/assuan_message_handler.dart'; 5 | 6 | part 'pinentry_set_gen_pin_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class PinentrySetGenPinRequest 10 | with _$PinentrySetGenPinRequest 11 | implements AssuanRequest { 12 | static const cmd = 'SETGENPIN'; 13 | static const handler = PinentrySetGenPinRequestHandler(); 14 | 15 | const factory PinentrySetGenPinRequest() = _PinentrySetGenPinRequest; 16 | 17 | const PinentrySetGenPinRequest._(); 18 | 19 | @override 20 | String get command => cmd; 21 | } 22 | 23 | class PinentrySetGenPinRequestHandler 24 | extends EmptyAssuanMessageHandler { 25 | const PinentrySetGenPinRequestHandler() : super(PinentrySetGenPinRequest.new); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_set_repeat_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_message.dart'; 4 | import '../../../core/protocol/base/assuan_message_handler.dart'; 5 | 6 | part 'pinentry_set_repeat_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class PinentrySetRepeatRequest 10 | with _$PinentrySetRepeatRequest 11 | implements AssuanRequest { 12 | static const cmd = 'SETREPEAT'; 13 | static const handler = PinentrySetRepeatRequestHandler(); 14 | 15 | const factory PinentrySetRepeatRequest() = _PinentrySetRepeatRequest; 16 | 17 | const PinentrySetRepeatRequest._(); 18 | 19 | @override 20 | String get command => cmd; 21 | } 22 | 23 | class PinentrySetRepeatRequestHandler 24 | extends EmptyAssuanMessageHandler { 25 | const PinentrySetRepeatRequestHandler() : super(PinentrySetRepeatRequest.new); 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/auto_newline_converter.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | class AppendLineTerminatorConverter extends Converter { 5 | final String lineTerminator; 6 | 7 | AppendLineTerminatorConverter({String? lineTerminator}) 8 | : lineTerminator = lineTerminator ?? Platform.lineTerminator; 9 | 10 | @override 11 | String convert(String input) => '$input$lineTerminator'; 12 | 13 | @override 14 | Sink startChunkedConversion(Sink sink) => 15 | _AppendLineTerminatorSink(sink, lineTerminator); 16 | } 17 | 18 | class _AppendLineTerminatorSink implements Sink { 19 | final Sink _sink; 20 | final String _lineTerminator; 21 | 22 | _AppendLineTerminatorSink(this._sink, this._lineTerminator); 23 | 24 | @override 25 | void add(String data) { 26 | _sink 27 | ..add(data) 28 | ..add(_lineTerminator); 29 | } 30 | 31 | @override 32 | void close() => _sink.close(); 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_enable_quality_bar_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_message.dart'; 4 | import '../../../core/protocol/base/assuan_message_handler.dart'; 5 | 6 | part 'pinentry_enable_quality_bar_request.freezed.dart'; 7 | 8 | @freezed 9 | sealed class PinentryEnableQualityBarRequest 10 | with _$PinentryEnableQualityBarRequest 11 | implements AssuanRequest { 12 | static const cmd = 'SETQUALITYBAR'; 13 | static const handler = PinentryEnableQualityBarRequestHandler(); 14 | 15 | const factory PinentryEnableQualityBarRequest() = 16 | _PinentryEnableQualityBarRequest; 17 | 18 | const PinentryEnableQualityBarRequest._(); 19 | 20 | @override 21 | String get command => cmd; 22 | } 23 | 24 | class PinentryEnableQualityBarRequestHandler 25 | extends EmptyAssuanMessageHandler { 26 | const PinentryEnableQualityBarRequestHandler() 27 | : super(PinentryEnableQualityBarRequest.new); 28 | } 29 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: bw_pinentry 2 | description: A pinentry wrapper around the bitwarden CLI to use your vault for GPG-Key storage. 3 | version: 1.0.4 4 | repository: https://github.com/Skycoder42/bw-pinentry 5 | publish_to: none 6 | 7 | environment: 8 | sdk: ^3.10.0 9 | 10 | platforms: 11 | linux: 12 | 13 | executables: 14 | bw-pinentry: bw_pinentry 15 | 16 | dependencies: 17 | async: ^2.13.0 18 | freezed_annotation: ^3.1.0 19 | json_annotation: ^4.9.0 20 | meta: ^1.17.0 21 | stream_channel: ^2.1.4 22 | 23 | dev_dependencies: 24 | build_runner: ^2.10.4 25 | dart_pre_commit: ^6.1.0 26 | dart_test_tools: ^7.0.1 27 | freezed: ^3.2.3 28 | json_serializable: ^6.11.2 29 | 30 | aur: 31 | maintainer: Skycoder42 32 | pkgname: bw-pinentry 33 | license: BSD 34 | depends: 35 | - pinentry 36 | - bitwarden-cli 37 | 38 | cider: 39 | link_template: 40 | tag: https://github.com/Skycoder42/bw-pinentry/releases/tag/v%tag% 41 | diff: https://github.com/Skycoder42/bw-pinentry/compare/v%from%...v%to% 42 | 43 | dart_pre_commit: 44 | flutter-compat: false 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.4] - 2025-12-03 8 | ### Changed 9 | - Updated min sdk version to ^3.10.0 10 | - Updated dependencies 11 | 12 | ## [1.0.3] - 2025-09-24 13 | ### Changed 14 | - Updated min sdk version to ^3.9.0 15 | - Updated dependencies 16 | 17 | ## [1.0.2] - 2025-08-17 18 | ### Changed 19 | - Updated dependencies 20 | 21 | ## [1.0.1] - 2025-04-03 22 | ### Added 23 | - Display keygrip when no matching entry is found in keyvault 24 | 25 | ## [1.0.0] - 2025-03-15 26 | ### Changed 27 | - Initial release 28 | 29 | [1.0.4]: https://github.com/Skycoder42/bw-pinentry/compare/v1.0.3...v1.0.4 30 | [1.0.3]: https://github.com/Skycoder42/bw-pinentry/compare/v1.0.2...v1.0.3 31 | [1.0.2]: https://github.com/Skycoder42/bw-pinentry/compare/v1.0.1...v1.0.2 32 | [1.0.1]: https://github.com/Skycoder42/bw-pinentry/compare/v1.0.0...v1.0.1 33 | [1.0.0]: https://github.com/Skycoder42/bw-pinentry/releases/tag/v1.0.0 34 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/assuan_codec.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../protocol/assuan_protocol.dart'; 6 | import '../protocol/base/assuan_message.dart'; 7 | import 'assuan_message_decoder.dart'; 8 | import 'assuan_message_encoder.dart'; 9 | 10 | sealed class AssuanCodec extends Codec { 11 | @protected 12 | final AssuanProtocol protocol; 13 | 14 | AssuanCodec(this.protocol); 15 | 16 | @override 17 | AssuanMessageEncoder get encoder; 18 | 19 | @override 20 | AssuanMessageDecoder get decoder; 21 | } 22 | 23 | class AssuanRequestCodec extends AssuanCodec { 24 | AssuanRequestCodec(super.protocol); 25 | 26 | @override 27 | AssuanRequestEncoder get encoder => AssuanRequestEncoder(protocol); 28 | 29 | @override 30 | AssuanRequestDecoder get decoder => AssuanRequestDecoder(protocol); 31 | } 32 | 33 | class AssuanResponseCodec extends AssuanCodec { 34 | AssuanResponseCodec(super.protocol); 35 | 36 | @override 37 | AssuanResponseEncoder get encoder => AssuanResponseEncoder(protocol); 38 | 39 | @override 40 | AssuanResponseDecoder get decoder => AssuanResponseDecoder(protocol); 41 | } 42 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/base/assuan_data_writer.dart: -------------------------------------------------------------------------------- 1 | import '../../codec/assuan_percent_codec.dart'; 2 | import 'assuan_error_code.dart'; 3 | import 'assuan_exception.dart'; 4 | 5 | class AssuanDataWriter { 6 | final StringBuffer _buffer; 7 | 8 | AssuanDataWriter(this._buffer); 9 | 10 | void write(T object, {bool autoSpace = true}) => 11 | switch (object) { 12 | String() => writeRaw( 13 | assuanPercentCodec.encode(object), 14 | autoSpace: autoSpace, 15 | ), 16 | num() || bool() => writeRaw(object.toString(), autoSpace: autoSpace), 17 | _ => throw AssuanException.code( 18 | AssuanErrorCode.invValue, 19 | 'Unsupported data type <${object.runtimeType}>', 20 | ), 21 | }; 22 | 23 | void writeRaw(String data, {bool autoSpace = true}) { 24 | if (autoSpace && _buffer.isNotEmpty) { 25 | _buffer.write(' '); 26 | } 27 | _buffer.write(data); 28 | 29 | if (_buffer.length > 1000) { 30 | throw AssuanException.code( 31 | AssuanErrorCode.lineTooLong, 32 | 'Message too long! Must be less than 1000 bytes, ' 33 | 'but is at least ${_buffer.length}', 34 | ); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/app/pinentry/bw_pinentry_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import '../../assuan/core/services/models/inquiry_reply.dart'; 4 | import '../../assuan/pinentry/services/pinentry_client.dart'; 5 | import 'bw_pinentry_server.dart'; 6 | 7 | class BwPinentryClient extends PinentryClient { 8 | final BwPinentryServer _server; 9 | 10 | BwPinentryClient._(this._server, super.process) : super.process(); 11 | 12 | static Future start( 13 | BwPinentryServer server, 14 | String pinentry, 15 | List arguments, 16 | ) async { 17 | final proc = await Process.start(pinentry, arguments); 18 | final client = BwPinentryClient._(server, proc); 19 | try { 20 | await client.connected; 21 | return client; 22 | } on Exception { 23 | client.close().ignore(); 24 | rethrow; 25 | } 26 | } 27 | 28 | @override 29 | Future onInquire(String keyword, List parameters) => 30 | Future.value( 31 | InquiryReply.dataStream(_server.forwardInquiry(keyword, parameters)), 32 | ); 33 | 34 | @override 35 | Future onStatus(String keyword, String status) { 36 | _server.forwardStatus(keyword, status); 37 | return Future.value(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/assuan_comment.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import 'base/assuan_data_reader.dart'; 4 | import 'base/assuan_data_writer.dart'; 5 | import 'base/assuan_error_code.dart'; 6 | import 'base/assuan_exception.dart'; 7 | import 'base/assuan_message.dart'; 8 | import 'base/assuan_message_handler.dart'; 9 | 10 | part 'assuan_comment.freezed.dart'; 11 | 12 | @freezed 13 | sealed class AssuanComment with _$AssuanComment implements AssuanCommentBase { 14 | static const cmd = '#'; 15 | static const handler = AssuanCommentHandler(); 16 | 17 | const factory AssuanComment(String comment) = _AssuanComment; 18 | 19 | const AssuanComment._(); 20 | 21 | @override 22 | String get command => cmd; 23 | } 24 | 25 | class AssuanCommentHandler implements AssuanMessageHandler { 26 | const AssuanCommentHandler(); 27 | 28 | @override 29 | bool hasData(_) => true; 30 | 31 | @override 32 | void encodeData(AssuanComment message, AssuanDataWriter writer) { 33 | writer.write(message.comment); 34 | } 35 | 36 | @override 37 | AssuanComment decodeData(AssuanDataReader reader) => 38 | throw AssuanException.code( 39 | AssuanErrorCode.syntax, 40 | 'Comment messages can only be sent, not received', 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/responses/assuan_ok_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_data_reader.dart'; 4 | import '../base/assuan_data_writer.dart'; 5 | import '../base/assuan_message.dart'; 6 | import '../base/assuan_message_handler.dart'; 7 | 8 | part 'assuan_ok_response.freezed.dart'; 9 | 10 | @freezed 11 | sealed class AssuanOkResponse 12 | with _$AssuanOkResponse 13 | implements AssuanResponse { 14 | static const cmd = 'OK'; 15 | static const handler = AssuanOkResponseHandler(); 16 | 17 | const factory AssuanOkResponse([String? debugData]) = _AssuanOkResponse; 18 | 19 | const AssuanOkResponse._(); 20 | 21 | @override 22 | String get command => cmd; 23 | } 24 | 25 | class AssuanOkResponseHandler 26 | implements AssuanMessageHandler { 27 | const AssuanOkResponseHandler(); 28 | 29 | @override 30 | bool hasData(AssuanOkResponse message) => message.debugData != null; 31 | 32 | @override 33 | void encodeData(AssuanOkResponse message, AssuanDataWriter writer) { 34 | if (message.debugData case final String data) { 35 | writer.write(data); 36 | } 37 | } 38 | 39 | @override 40 | AssuanOkResponse decodeData(AssuanDataReader reader) => 41 | AssuanOkResponse(reader.readAllOptional()); 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/responses/assuan_error_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_data_reader.dart'; 4 | import '../base/assuan_data_writer.dart'; 5 | import '../base/assuan_message.dart'; 6 | import '../base/assuan_message_handler.dart'; 7 | 8 | part 'assuan_error_response.freezed.dart'; 9 | 10 | @freezed 11 | sealed class AssuanErrorResponse 12 | with _$AssuanErrorResponse 13 | implements AssuanResponse { 14 | static const cmd = 'ERR'; 15 | static const handler = AssuanErrorResponseHandler(); 16 | 17 | const factory AssuanErrorResponse(int code, [String? message]) = 18 | _AssuanErrorResponse; 19 | 20 | const AssuanErrorResponse._(); 21 | 22 | @override 23 | String get command => cmd; 24 | } 25 | 26 | class AssuanErrorResponseHandler 27 | implements AssuanMessageHandler { 28 | const AssuanErrorResponseHandler(); 29 | 30 | @override 31 | bool hasData(_) => true; 32 | 33 | @override 34 | void encodeData(AssuanErrorResponse message, AssuanDataWriter writer) { 35 | writer.write(message.code); 36 | if (message.message case final String msg) { 37 | writer.write(msg); 38 | } 39 | } 40 | 41 | @override 42 | AssuanErrorResponse decodeData(AssuanDataReader reader) => 43 | AssuanErrorResponse(reader.read(), reader.readAllOptional()); 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_set_keyinfo_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_data_reader.dart'; 4 | import '../../../core/protocol/base/assuan_data_writer.dart'; 5 | import '../../../core/protocol/base/assuan_message.dart'; 6 | import '../../../core/protocol/base/assuan_message_handler.dart'; 7 | 8 | part 'pinentry_set_keyinfo_request.freezed.dart'; 9 | 10 | @freezed 11 | sealed class PinentrySetKeyinfoRequest 12 | with _$PinentrySetKeyinfoRequest 13 | implements AssuanRequest { 14 | static const cmd = 'SETKEYINFO'; 15 | static const handler = PinentrySetKeyinfoRequestHandler(); 16 | 17 | const factory PinentrySetKeyinfoRequest(String keyGrip) = 18 | _PinentrySetKeyinfoRequest; 19 | 20 | const PinentrySetKeyinfoRequest._(); 21 | 22 | @override 23 | String get command => cmd; 24 | } 25 | 26 | class PinentrySetKeyinfoRequestHandler 27 | implements AssuanMessageHandler { 28 | const PinentrySetKeyinfoRequestHandler(); 29 | 30 | @override 31 | bool hasData(_) => true; 32 | 33 | @override 34 | void encodeData(PinentrySetKeyinfoRequest message, AssuanDataWriter writer) => 35 | writer.write(message.keyGrip); 36 | 37 | @override 38 | PinentrySetKeyinfoRequest decodeData(AssuanDataReader reader) => 39 | PinentrySetKeyinfoRequest(reader.readAll()); 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/base/assuan_error_code.dart: -------------------------------------------------------------------------------- 1 | // https://github.com/gpg/libgpg-error/blob/master/src/err-codes.h.in#L293 2 | enum AssuanErrorCode { 3 | general(257, 'General IPC error'), 4 | acceptFailed(258, 'IPC accept call failed'), 5 | connectFailed(259, 'IPC connect call failed'), 6 | invResponse(260, 'Invalid IPC response'), 7 | invValue(261, 'Invalid value passed to IPC'), 8 | incompleteLine(262, 'Incomplete line passed to IPC'), 9 | lineTooLong(263, 'Line passed to IPC too long'), 10 | nestedCommands(264, 'Nested IPC commands'), 11 | noDataCb(265, 'No data callback in IPC'), 12 | noInquireCb(266, 'No inquire callback in IPC'), 13 | notAServer(267, 'Not an IPC server'), 14 | notAClient(268, 'Not an IPC client'), 15 | serverStart(269, 'Problem starting IPC server'), 16 | readError(270, 'IPC read error'), 17 | writeError(271, 'IPC write error'), 18 | // reserved 19 | tooMuchData(273, 'Too much data for IPC layer'), 20 | unexpectedCmd(274, 'Unexpected IPC command'), 21 | unknownCmd(275, 'Unknown IPC command'), 22 | syntax(276, 'IPC syntax error'), 23 | canceled(277, 'IPC call has been cancelled'), 24 | noInput(278, 'No input source for IPC'), 25 | noOutput(279, 'No output source for IPC'), 26 | parameter(280, 'IPC parameter error'), 27 | unknownInquire(281, 'Unknown IPC inquire'); 28 | 29 | final int code; 30 | final String message; 31 | 32 | const AssuanErrorCode(this.code, this.message); 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_set_timeout_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_data_reader.dart'; 4 | import '../../../core/protocol/base/assuan_data_writer.dart'; 5 | import '../../../core/protocol/base/assuan_message.dart'; 6 | import '../../../core/protocol/base/assuan_message_handler.dart'; 7 | 8 | part 'pinentry_set_timeout_request.freezed.dart'; 9 | 10 | @freezed 11 | sealed class PinentrySetTimeoutRequest 12 | with _$PinentrySetTimeoutRequest 13 | implements AssuanRequest { 14 | static const cmd = 'SETTIMEOUT'; 15 | static const handler = PinentrySetTimeoutRequestHandler(); 16 | 17 | const factory PinentrySetTimeoutRequest(Duration timeout) = 18 | _PinentrySetTimeoutRequest; 19 | 20 | const PinentrySetTimeoutRequest._(); 21 | 22 | @override 23 | String get command => cmd; 24 | } 25 | 26 | class PinentrySetTimeoutRequestHandler 27 | implements AssuanMessageHandler { 28 | const PinentrySetTimeoutRequestHandler(); 29 | 30 | @override 31 | bool hasData(_) => true; 32 | 33 | @override 34 | void encodeData(PinentrySetTimeoutRequest message, AssuanDataWriter writer) => 35 | writer.write(message.timeout.inSeconds); 36 | 37 | @override 38 | PinentrySetTimeoutRequest decodeData(AssuanDataReader reader) => 39 | PinentrySetTimeoutRequest(Duration(seconds: reader.read())); 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_get_info_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_data_reader.dart'; 4 | import '../../../core/protocol/base/assuan_data_writer.dart'; 5 | import '../../../core/protocol/base/assuan_message.dart'; 6 | import '../../../core/protocol/base/assuan_message_handler.dart'; 7 | 8 | part 'pinentry_get_info_request.freezed.dart'; 9 | 10 | enum PinentryInfoKey { flavor, version, ttyinfo, pid } 11 | 12 | @freezed 13 | sealed class PinentryGetInfoRequest 14 | with _$PinentryGetInfoRequest 15 | implements AssuanRequest { 16 | static const cmd = 'GETINFO'; 17 | static const handler = PinentryGetInfoRequestHandler(); 18 | 19 | const factory PinentryGetInfoRequest(PinentryInfoKey key) = 20 | _PinentryGetInfoRequest; 21 | 22 | const PinentryGetInfoRequest._(); 23 | 24 | @override 25 | String get command => cmd; 26 | } 27 | 28 | class PinentryGetInfoRequestHandler 29 | implements AssuanMessageHandler { 30 | const PinentryGetInfoRequestHandler(); 31 | 32 | @override 33 | bool hasData(_) => true; 34 | 35 | @override 36 | void encodeData(PinentryGetInfoRequest message, AssuanDataWriter writer) => 37 | writer.write(message.key.name); 38 | 39 | @override 40 | PinentryGetInfoRequest decodeData(AssuanDataReader reader) => 41 | PinentryGetInfoRequest(PinentryInfoKey.values.byName(reader.read())); 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/responses/assuan_inquire_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_data_reader.dart'; 4 | import '../base/assuan_data_writer.dart'; 5 | import '../base/assuan_message.dart'; 6 | import '../base/assuan_message_handler.dart'; 7 | 8 | part 'assuan_inquire_response.freezed.dart'; 9 | 10 | @freezed 11 | sealed class AssuanInquireResponse 12 | with _$AssuanInquireResponse 13 | implements AssuanResponse { 14 | static const cmd = 'INQUIRE'; 15 | static const handler = AssuanInquireResponseHandler(); 16 | 17 | const factory AssuanInquireResponse( 18 | String keyword, [ 19 | @Default([]) List parameters, 20 | ]) = _AssuanInquireResponse; 21 | 22 | const AssuanInquireResponse._(); 23 | 24 | @override 25 | String get command => cmd; 26 | } 27 | 28 | class AssuanInquireResponseHandler 29 | implements AssuanMessageHandler { 30 | const AssuanInquireResponseHandler(); 31 | 32 | @override 33 | bool hasData(_) => true; 34 | 35 | @override 36 | void encodeData(AssuanInquireResponse message, AssuanDataWriter writer) { 37 | writer.write(message.keyword); 38 | message.parameters.forEach(writer.write); 39 | } 40 | 41 | @override 42 | AssuanInquireResponse decodeData(AssuanDataReader reader) { 43 | final keyword = reader.read(); 44 | final parameters = [for (; reader.hasMoreData();) reader.read()]; 45 | return AssuanInquireResponse(keyword, parameters); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/assuan_percent_codec.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | const assuanPercentCodec = AssuanPercentCodec(); 4 | 5 | class AssuanPercentCodec extends Codec { 6 | const AssuanPercentCodec(); 7 | 8 | @override 9 | Converter get decoder => const _AssuanPercentDecoder(); 10 | 11 | @override 12 | Converter get encoder => const _AssuanPercentEncoder(); 13 | } 14 | 15 | class _AssuanPercentEncoder extends Converter { 16 | static final _escapeRequiredCharsPattern = RegExp(r'[\%\n\r\\]'); 17 | 18 | const _AssuanPercentEncoder(); 19 | 20 | @override 21 | String convert(String input) => 22 | input.replaceAllMapped(_escapeRequiredCharsPattern, _encode); 23 | 24 | String _encode(Match match) => utf8.encode(match[0]!).map(_encodeChar).join(); 25 | 26 | String _encodeChar(int char) { 27 | final hex = char.toRadixString(16).padLeft(2, '0').toUpperCase(); 28 | return '%$hex'; 29 | } 30 | } 31 | 32 | class _AssuanPercentDecoder extends Converter { 33 | static final _percentEncodedPattern = RegExp('((?:%[0-9a-fA-F]{2})+)'); 34 | 35 | const _AssuanPercentDecoder(); 36 | 37 | @override 38 | String convert(String input) => 39 | input.replaceAllMapped(_percentEncodedPattern, _decode); 40 | 41 | String _decode(Match match) { 42 | final bytes = match[0]! 43 | .split('%') 44 | .skip(1) 45 | .map((hex) => int.parse(hex, radix: 16)) 46 | .toList(); 47 | return utf8.decode(bytes); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/pinentry_protocol.dart: -------------------------------------------------------------------------------- 1 | import '../../core/protocol/assuan_protocol.dart'; 2 | import 'requests/pinentry_confirm_request.dart'; 3 | import 'requests/pinentry_enable_quality_bar_request.dart'; 4 | import 'requests/pinentry_get_info_request.dart'; 5 | import 'requests/pinentry_get_pin_request.dart'; 6 | import 'requests/pinentry_message_request.dart'; 7 | import 'requests/pinentry_set_gen_pin_request.dart'; 8 | import 'requests/pinentry_set_keyinfo_request.dart'; 9 | import 'requests/pinentry_set_repeat_request.dart'; 10 | import 'requests/pinentry_set_text_request.dart'; 11 | import 'requests/pinentry_set_timeout_request.dart'; 12 | 13 | final class PinentryProtocol extends AssuanProtocol { 14 | PinentryProtocol() 15 | : super({ 16 | PinentryConfirmRequest.cmd: PinentryConfirmRequest.handler, 17 | PinentryEnableQualityBarRequest.cmd: 18 | PinentryEnableQualityBarRequest.handler, 19 | PinentryGetInfoRequest.cmd: PinentryGetInfoRequest.handler, 20 | PinentryGetPinRequest.cmd: PinentryGetPinRequest.handler, 21 | PinentryMessageRequest.cmd: PinentryMessageRequest.handler, 22 | PinentrySetGenPinRequest.cmd: PinentrySetGenPinRequest.handler, 23 | PinentrySetKeyinfoRequest.cmd: PinentrySetKeyinfoRequest.handler, 24 | PinentrySetRepeatRequest.cmd: PinentrySetRepeatRequest.handler, 25 | for (final cmd in PinentrySetTextRequest.cmds) 26 | cmd: PinentrySetTextRequest.handler, 27 | PinentrySetTimeoutRequest.cmd: PinentrySetTimeoutRequest.handler, 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/assuan_data_message.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import 'base/assuan_data_reader.dart'; 4 | import 'base/assuan_data_writer.dart'; 5 | import 'base/assuan_error_code.dart'; 6 | import 'base/assuan_exception.dart'; 7 | import 'base/assuan_message.dart'; 8 | import 'base/assuan_message_handler.dart'; 9 | 10 | part 'assuan_data_message.freezed.dart'; 11 | 12 | @freezed 13 | sealed class AssuanDataMessage 14 | with _$AssuanDataMessage 15 | implements AssuanRequest, AssuanResponse { 16 | static const maxDataLength = 1000 - 2; 17 | 18 | static const cmd = 'D'; 19 | static const handler = AssuanDataMessageHandler(); 20 | 21 | factory AssuanDataMessage(String data) = _AssuanDataMessage; 22 | 23 | AssuanDataMessage._() { 24 | if (data.contains('/r') || data.contains('/n')) { 25 | throw AssuanException.code( 26 | AssuanErrorCode.parameter, 27 | 'data must not contain CR or LF', 28 | ); 29 | } 30 | } 31 | 32 | @override 33 | String get command => cmd; 34 | } 35 | 36 | class AssuanDataMessageHandler 37 | implements AssuanMessageHandler { 38 | const AssuanDataMessageHandler(); 39 | 40 | @override 41 | bool hasData(_) => true; 42 | 43 | @override 44 | void encodeData(AssuanDataMessage message, AssuanDataWriter writer) { 45 | writer.writeRaw(message.data); 46 | } 47 | 48 | @override 49 | AssuanDataMessage decodeData(AssuanDataReader reader) => 50 | AssuanDataMessage(reader.readRaw(fixedSpace: true, readToEnd: true)); 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Felix Barz 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/responses/assuan_status_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_data_reader.dart'; 4 | import '../base/assuan_data_writer.dart'; 5 | import '../base/assuan_error_code.dart'; 6 | import '../base/assuan_exception.dart'; 7 | import '../base/assuan_message.dart'; 8 | import '../base/assuan_message_handler.dart'; 9 | 10 | part 'assuan_status_response.freezed.dart'; 11 | 12 | @freezed 13 | sealed class AssuanStatusResponse 14 | with _$AssuanStatusResponse 15 | implements AssuanResponse { 16 | static const cmd = 'S'; 17 | static const handler = AssuanStatusResponseHandler(); 18 | 19 | static final _keywordPattern = RegExp(r'^[a-zA-Z_][a-zA-Z0-9_]*$'); 20 | 21 | factory AssuanStatusResponse(String keyword, String status) = 22 | _AssuanStatusResponse; 23 | 24 | AssuanStatusResponse._() { 25 | if (!_keywordPattern.hasMatch(keyword)) { 26 | throw AssuanException.code( 27 | AssuanErrorCode.parameter, 28 | 'Invalid keyword: $keyword', 29 | ); 30 | } 31 | } 32 | 33 | @override 34 | String get command => cmd; 35 | } 36 | 37 | class AssuanStatusResponseHandler 38 | implements AssuanMessageHandler { 39 | const AssuanStatusResponseHandler(); 40 | 41 | @override 42 | bool hasData(_) => true; 43 | 44 | @override 45 | void encodeData(AssuanStatusResponse message, AssuanDataWriter writer) { 46 | writer.write(message.keyword); 47 | if (message.status.isNotEmpty) { 48 | writer.write(message.status); 49 | } 50 | } 51 | 52 | @override 53 | AssuanStatusResponse decodeData(AssuanDataReader reader) => 54 | AssuanStatusResponse(reader.read(), reader.readAllOptional() ?? ''); 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/requests/assuan_option_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../base/assuan_data_reader.dart'; 4 | import '../base/assuan_data_writer.dart'; 5 | import '../base/assuan_message.dart'; 6 | import '../base/assuan_message_handler.dart'; 7 | 8 | part 'assuan_option_request.freezed.dart'; 9 | 10 | @freezed 11 | sealed class AssuanOptionRequest 12 | with _$AssuanOptionRequest 13 | implements AssuanRequest { 14 | static const cmd = 'OPTION'; 15 | static const handler = AssuanOptionRequestHandler(); 16 | 17 | const factory AssuanOptionRequest(String name, [String? value]) = 18 | _AssuanOptionRequest; 19 | 20 | const AssuanOptionRequest._(); 21 | 22 | @override 23 | String get command => cmd; 24 | } 25 | 26 | class AssuanOptionRequestHandler 27 | implements AssuanMessageHandler { 28 | const AssuanOptionRequestHandler(); 29 | 30 | @override 31 | bool hasData(_) => true; 32 | 33 | @override 34 | void encodeData(AssuanOptionRequest message, AssuanDataWriter writer) { 35 | writer.write(message.name); 36 | if (message.value case final String value) { 37 | writer 38 | ..write('=', autoSpace: false) 39 | ..write(value, autoSpace: false); 40 | } 41 | } 42 | 43 | @override 44 | AssuanOptionRequest decodeData(AssuanDataReader reader) { 45 | var data = reader.readAll(); 46 | if (data.startsWith('--')) { 47 | data = data.substring(2); 48 | } 49 | 50 | final equalsIndex = data.indexOf('='); 51 | if (equalsIndex == -1) { 52 | return AssuanOptionRequest(data); 53 | } else { 54 | return AssuanOptionRequest( 55 | data.substring(0, equalsIndex).trim(), 56 | data.substring(equalsIndex + 1).trim(), 57 | ); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_set_text_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_data_reader.dart'; 4 | import '../../../core/protocol/base/assuan_data_writer.dart'; 5 | import '../../../core/protocol/base/assuan_message.dart'; 6 | import '../../../core/protocol/base/assuan_message_handler.dart'; 7 | 8 | part 'pinentry_set_text_request.freezed.dart'; 9 | 10 | enum SetCommand { 11 | description('SETDESC'), 12 | prompt('SETPROMPT'), 13 | title('SETTITLE'), 14 | ok('SETOK'), 15 | cancel('SETCANCEL'), 16 | notOk('SETNOTOK'), 17 | error('SETERROR'), 18 | qualityBar('SETQUALITYBAR_TT'), 19 | genPin('SETGENPIN_TT'); 20 | 21 | final String command; 22 | 23 | const SetCommand(this.command); 24 | } 25 | 26 | @freezed 27 | sealed class PinentrySetTextRequest 28 | with _$PinentrySetTextRequest 29 | implements AssuanRequest { 30 | static Iterable get cmds => SetCommand.values.map((v) => v.command); 31 | static const handler = PinentrySetTextRequestHandler(); 32 | 33 | factory PinentrySetTextRequest(SetCommand setCommand, String text) => 34 | PinentrySetTextRequest.internal(setCommand.command, text); 35 | 36 | @protected 37 | const factory PinentrySetTextRequest.internal(String command, String text) = 38 | _PinentrySetTextRequest; 39 | 40 | const PinentrySetTextRequest._(); 41 | 42 | SetCommand get setCommand => 43 | SetCommand.values.singleWhere((c) => c.command == command); 44 | } 45 | 46 | class PinentrySetTextRequestHandler 47 | implements AssuanMessageHandler { 48 | const PinentrySetTextRequestHandler(); 49 | 50 | @override 51 | bool hasData(_) => true; 52 | 53 | @override 54 | void encodeData(PinentrySetTextRequest message, AssuanDataWriter writer) => 55 | writer.write(message.text); 56 | 57 | @override 58 | PinentrySetTextRequest decodeData(AssuanDataReader reader) => 59 | PinentrySetTextRequest.internal(reader.command, reader.readAll()); 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/protocol/requests/pinentry_confirm_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | import '../../../core/protocol/base/assuan_data_reader.dart'; 4 | import '../../../core/protocol/base/assuan_data_writer.dart'; 5 | import '../../../core/protocol/base/assuan_error_code.dart'; 6 | import '../../../core/protocol/base/assuan_exception.dart'; 7 | import '../../../core/protocol/base/assuan_message.dart'; 8 | import '../../../core/protocol/base/assuan_message_handler.dart'; 9 | 10 | part 'pinentry_confirm_request.freezed.dart'; 11 | 12 | @freezed 13 | sealed class PinentryConfirmRequest 14 | with _$PinentryConfirmRequest 15 | implements AssuanRequest { 16 | static const cmd = 'CONFIRM'; 17 | static const handler = PinentryConfirmRequestHandler(); 18 | 19 | const factory PinentryConfirmRequest({@Default(false) bool oneButton}) = 20 | _PinentryConfirmRequest; 21 | 22 | const PinentryConfirmRequest._(); 23 | 24 | @override 25 | String get command => cmd; 26 | } 27 | 28 | class PinentryConfirmRequestHandler 29 | implements AssuanMessageHandler { 30 | static const _oneButtonOption = '--one-button'; 31 | 32 | const PinentryConfirmRequestHandler(); 33 | 34 | @override 35 | bool hasData(PinentryConfirmRequest message) => message.oneButton; 36 | 37 | @override 38 | void encodeData(PinentryConfirmRequest message, AssuanDataWriter writer) { 39 | if (message.oneButton) { 40 | writer.write(_oneButtonOption); 41 | } 42 | } 43 | 44 | @override 45 | PinentryConfirmRequest decodeData(AssuanDataReader reader) { 46 | if (reader.hasMoreData()) { 47 | final option = reader.read(); 48 | if (option == _oneButtonOption) { 49 | return const PinentryConfirmRequest(oneButton: true); 50 | } else { 51 | throw AssuanException.code( 52 | AssuanErrorCode.parameter, 53 | 'Parameter $option is not allowed for CONFIRM', 54 | ); 55 | } 56 | } else { 57 | return const PinentryConfirmRequest(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/assuan_message_encoder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../protocol/assuan_protocol.dart'; 6 | import '../protocol/base/assuan_data_writer.dart'; 7 | import '../protocol/base/assuan_error_code.dart'; 8 | import '../protocol/base/assuan_exception.dart'; 9 | import '../protocol/base/assuan_message.dart'; 10 | import '../protocol/base/assuan_message_handler.dart'; 11 | import 'converter_sink.dart'; 12 | 13 | class AssuanRequestEncoder extends AssuanMessageEncoder { 14 | AssuanRequestEncoder(super.protocol); 15 | 16 | @override 17 | @visibleForOverriding 18 | AssuanMessageHandler? getHandler(String command) => 19 | protocol.requestHandler(command); 20 | } 21 | 22 | class AssuanResponseEncoder extends AssuanMessageEncoder { 23 | AssuanResponseEncoder(super.protocol); 24 | 25 | @override 26 | @visibleForOverriding 27 | AssuanMessageHandler? getHandler(String command) => 28 | protocol.responseHandler(command); 29 | } 30 | 31 | sealed class AssuanMessageEncoder 32 | extends Converter { 33 | @protected 34 | final AssuanProtocol protocol; 35 | 36 | AssuanMessageEncoder(this.protocol); 37 | 38 | @visibleForOverriding 39 | AssuanMessageHandler? getHandler(String command); 40 | 41 | @override 42 | String convert(T response) { 43 | final command = response.command; 44 | final handler = getHandler(command); 45 | if (handler == null) { 46 | throw AssuanException.code( 47 | AssuanErrorCode.unknownCmd, 48 | 'Unknown command: $command', 49 | ); 50 | } 51 | 52 | final buffer = StringBuffer(command); 53 | if (handler.hasData(response)) { 54 | final writer = AssuanDataWriter(buffer); 55 | handler.encodeData(response, writer); 56 | } 57 | 58 | if (buffer.length > 1000) { 59 | throw AssuanException.code( 60 | AssuanErrorCode.lineTooLong, 61 | 'Message too long! Must be less than 1000 bytes, ' 62 | 'but is at least ${buffer.length}', 63 | ); 64 | } 65 | 66 | return buffer.toString(); 67 | } 68 | 69 | @override 70 | Sink startChunkedConversion(Sink sink) => 71 | ConverterSink(sink, this); 72 | } 73 | -------------------------------------------------------------------------------- /.vscode/dart.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Assuan Message": { 3 | "scope": "dart", 4 | "prefix": "assuan-message", 5 | "body": [ 6 | "import 'package:freezed_annotation/freezed_annotation.dart';", 7 | "", 8 | "import '../../../core/protocol/base/assuan_data_reader.dart';", 9 | "import '../../../core/protocol/base/assuan_data_writer.dart';", 10 | "import '../../../core/protocol/base/assuan_message.dart';", 11 | "import '../../../core/protocol/base/assuan_message_handler.dart';", 12 | "", 13 | "part '$TM_FILENAME_BASE.freezed.dart';", 14 | "", 15 | "@freezed", 16 | "sealed class ${1:Assuan} with _$$1 implements Assuan$2 {", 17 | " static const cmd = '$3';", 18 | " static const handler = $1Handler();", 19 | "", 20 | " const factory $1($0) = _$1;", 21 | "", 22 | " const $1._();", 23 | "", 24 | " @override", 25 | " String get command => cmd;", 26 | "}", 27 | "", 28 | "class $1Handler implements AssuanMessageHandler<$1> {", 29 | " const $1Handler();", 30 | "", 31 | " @override", 32 | " bool hasData($1 message) => throw UnimplementedError();", 33 | "", 34 | " @override", 35 | " void encodeData($1 message, AssuanDataWriter writer) => throw UnimplementedError();", 36 | "", 37 | " @override", 38 | " $1 decodeData(AssuanDataReader reader) => throw UnimplementedError();", 39 | "}", 40 | ], 41 | "description": "Log output to console" 42 | }, 43 | "Empty Assuan Message": { 44 | "scope": "dart", 45 | "prefix": "assuan-empty-message", 46 | "body": [ 47 | "import 'package:freezed_annotation/freezed_annotation.dart';", 48 | "", 49 | "import '../../../core/protocol/base/assuan_message.dart';", 50 | "import '../../../core/protocol/base/assuan_message_handler.dart';", 51 | "", 52 | "part '$TM_FILENAME_BASE.freezed.dart';", 53 | "", 54 | "@freezed", 55 | "sealed class ${1:Assuan} with _$$1 implements Assuan$2 {", 56 | " static const cmd = '$3';", 57 | " static const handler = $1Handler();", 58 | "", 59 | " const factory $1() = _$1;", 60 | "", 61 | " const $1._();", 62 | "", 63 | " @override", 64 | " String get command => cmd;", 65 | "}", 66 | "", 67 | "class $1Handler extends EmptyAssuanMessageHandler<$1> {", 68 | " const $1Handler() : super($1.new);", 69 | "}", 70 | ], 71 | "description": "Log output to console" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/assuan_message_decoder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../protocol/assuan_protocol.dart'; 6 | import '../protocol/base/assuan_data_reader.dart'; 7 | import '../protocol/base/assuan_error_code.dart'; 8 | import '../protocol/base/assuan_exception.dart'; 9 | import '../protocol/base/assuan_message.dart'; 10 | import '../protocol/base/assuan_message_handler.dart'; 11 | import 'converter_sink.dart'; 12 | 13 | class AssuanRequestDecoder extends AssuanMessageDecoder { 14 | AssuanRequestDecoder(super.protocol); 15 | 16 | @override 17 | @visibleForOverriding 18 | AssuanMessageHandler? getHandler(String command) => 19 | protocol.requestHandler(command); 20 | } 21 | 22 | class AssuanResponseDecoder extends AssuanMessageDecoder { 23 | AssuanResponseDecoder(super.protocol); 24 | 25 | @override 26 | @visibleForOverriding 27 | AssuanMessageHandler? getHandler(String command) => 28 | protocol.responseHandler(command); 29 | } 30 | 31 | sealed class AssuanMessageDecoder 32 | extends Converter { 33 | @protected 34 | final AssuanProtocol protocol; 35 | 36 | AssuanMessageDecoder(this.protocol); 37 | 38 | @visibleForOverriding 39 | AssuanMessageHandler? getHandler(String command); 40 | 41 | @override 42 | T convert(String line) { 43 | // special handling for comments 44 | if (line.startsWith(protocol.commentPrefix)) { 45 | return protocol.createComment(line.substring(1).trim()) as T; 46 | } 47 | 48 | final (command, offset) = _splitLine(line); 49 | 50 | final handler = getHandler(command); 51 | if (handler == null) { 52 | throw AssuanException.code( 53 | AssuanErrorCode.unknownCmd, 54 | 'Unknown command: $command', 55 | ); 56 | } 57 | 58 | final reader = AssuanDataReader(command, line, offset); 59 | return handler.decodeData(reader); 60 | } 61 | 62 | @override 63 | Sink startChunkedConversion(Sink sink) => 64 | ConverterSink(sink, this); 65 | 66 | (String command, int offset) _splitLine(String line) { 67 | final spaceIdx = line.indexOf(' '); 68 | if (spaceIdx == -1) { 69 | return (line, line.length); 70 | } else { 71 | return (line.substring(0, spaceIdx), spaceIdx); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/src/assuan/core/codec/assuan_data_encoder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:math'; 3 | 4 | import '../protocol/assuan_data_message.dart'; 5 | import 'assuan_percent_codec.dart'; 6 | 7 | class AssuanDataEncoder 8 | extends StreamTransformerBase { 9 | const AssuanDataEncoder(); 10 | 11 | @override 12 | Stream bind(Stream stream) => 13 | Stream.eventTransformed(stream, _AssuanDataEncoderSink.new); 14 | } 15 | 16 | class _AssuanDataEncoderSink implements EventSink { 17 | static final _escapeRequiredChars = RegExp(r'[\%\n\r\\]'); 18 | 19 | final EventSink _sink; 20 | final _buffer = StringBuffer(); 21 | 22 | _AssuanDataEncoderSink(this._sink); 23 | 24 | int get _remainingBufferLen => 25 | AssuanDataMessage.maxDataLength - _buffer.length; 26 | 27 | @override 28 | void add(String event) { 29 | var eventOffset = 0; 30 | while (eventOffset < event.length) { 31 | final escapeIdx = event.indexOf(_escapeRequiredChars, eventOffset); 32 | 33 | // no escaping required -> add whole string 34 | if (escapeIdx == -1) { 35 | _addSegment(event, eventOffset, event.length); 36 | return; 37 | } 38 | 39 | // add data until escape position 40 | _addSegment(event, eventOffset, escapeIdx); 41 | // add the escaped data 42 | _addEscaped(event[escapeIdx]); 43 | // continue after the escaped char 44 | eventOffset = escapeIdx + 1; 45 | } 46 | } 47 | 48 | @override 49 | void addError(Object error, [StackTrace? stackTrace]) => 50 | _sink.addError(error, stackTrace); 51 | 52 | @override 53 | void close() { 54 | // send remaining buffer 55 | if (_buffer.isNotEmpty) { 56 | _sink.add(AssuanDataMessage(_buffer.toString())); 57 | _buffer.clear(); 58 | } 59 | 60 | _sink.close(); 61 | } 62 | 63 | void _addSegment(String event, int start, int end) { 64 | var offset = start; 65 | while (offset < end) { 66 | final remainingEventLen = end - offset; 67 | final chunkSize = min(_remainingBufferLen, remainingEventLen); 68 | _buffer.write(event.substring(offset, offset + chunkSize)); 69 | 70 | if (_remainingBufferLen == 0) { 71 | _sink.add(AssuanDataMessage(_buffer.toString())); 72 | _buffer.clear(); 73 | } 74 | 75 | offset += chunkSize; 76 | } 77 | } 78 | 79 | void _addEscaped(String char) { 80 | final escaped = assuanPercentCodec.encode(char); 81 | if (escaped.length > _remainingBufferLen) { 82 | _sink.add(AssuanDataMessage(_buffer.toString())); 83 | _buffer.clear(); 84 | } 85 | _buffer.write(escaped); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/assuan_protocol.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'assuan_comment.dart'; 4 | import 'assuan_data_message.dart'; 5 | import 'base/assuan_message.dart'; 6 | import 'base/assuan_message_handler.dart'; 7 | import 'requests/assuan_bye_request.dart'; 8 | import 'requests/assuan_cancel_request.dart'; 9 | import 'requests/assuan_end_request.dart'; 10 | import 'requests/assuan_help_request.dart'; 11 | import 'requests/assuan_nop_request.dart'; 12 | import 'requests/assuan_option_request.dart'; 13 | import 'requests/assuan_reset_request.dart'; 14 | import 'responses/assuan_error_response.dart'; 15 | import 'responses/assuan_inquire_response.dart'; 16 | import 'responses/assuan_ok_response.dart'; 17 | import 'responses/assuan_status_response.dart'; 18 | 19 | base class AssuanProtocol { 20 | final _responseHandlers = 21 | const >{ 22 | AssuanComment.cmd: AssuanComment.handler, 23 | AssuanDataMessage.cmd: AssuanDataMessage.handler, 24 | AssuanOkResponse.cmd: AssuanOkResponse.handler, 25 | AssuanErrorResponse.cmd: AssuanErrorResponse.handler, 26 | AssuanStatusResponse.cmd: AssuanStatusResponse.handler, 27 | AssuanInquireResponse.cmd: AssuanInquireResponse.handler, 28 | }; 29 | 30 | final _requestHandlers = >{ 31 | AssuanComment.cmd: AssuanComment.handler, 32 | AssuanDataMessage.cmd: AssuanDataMessage.handler, 33 | AssuanByeRequest.cmd: AssuanByeRequest.handler, 34 | AssuanResetRequest.cmd: AssuanResetRequest.handler, 35 | AssuanEndRequest.cmd: AssuanEndRequest.handler, 36 | AssuanHelpRequest.cmd: AssuanHelpRequest.handler, 37 | AssuanOptionRequest.cmd: AssuanOptionRequest.handler, 38 | AssuanNopRequest.cmd: AssuanNopRequest.handler, 39 | AssuanCancelRequest.cmd: AssuanCancelRequest.handler, 40 | }; 41 | 42 | AssuanProtocol([ 43 | Map> requestHandlers = const {}, 44 | ]) { 45 | _requestHandlers.addAll(requestHandlers); 46 | } 47 | 48 | @nonVirtual 49 | Iterable get requestCommands => _requestHandlers.keys; 50 | 51 | @nonVirtual 52 | Iterable get responseCommands => _responseHandlers.keys; 53 | 54 | @nonVirtual 55 | AssuanMessageHandler? requestHandler(String command) => 56 | _requestHandlers[command]; 57 | 58 | @nonVirtual 59 | AssuanMessageHandler? responseHandler(String command) => 60 | _responseHandlers[command]; 61 | 62 | @nonVirtual 63 | String get commentPrefix => AssuanComment.cmd; 64 | 65 | @nonVirtual 66 | AssuanComment createComment(String comment) => AssuanComment(comment); 67 | } 68 | -------------------------------------------------------------------------------- /tool/pinentry_wrapper.dart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env dart 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | import 'dart:io'; 6 | 7 | import 'package:async/async.dart'; 8 | 9 | void main(List args) async { 10 | final logFile = File('/tmp/pinentry.log'); 11 | final logFileSink = logFile.openWrite(); 12 | try { 13 | logFileSink.writeln('STARTED: $args'); 14 | final pinentry = await Process.start('/usr/bin/bw-pinentry', args); 15 | 16 | await Future.wait([ 17 | stdin 18 | .tee(_lineWrapped('IN ', logFileSink)) 19 | .pipe(pinentry.stdin) 20 | .whenComplete(() => logFileSink.writeln('IN: <>')), 21 | pinentry.stdout 22 | .tee(_lineWrapped('OUT', logFileSink)) 23 | .pipe(stdout) 24 | .whenComplete(() => logFileSink.writeln('OUT: <>')), 25 | pinentry.stderr 26 | .tee(_lineWrapped('ERR', logFileSink)) 27 | .pipe(stderr) 28 | .whenComplete(() => logFileSink.writeln('ERR: <>')), 29 | ]); 30 | 31 | exitCode = await pinentry.exitCode; 32 | logFileSink.writeln('EXIT: $exitCode'); 33 | // ignore: avoid_catches_without_on_clauses for explicit error handling 34 | } catch (e, s) { 35 | logFileSink 36 | ..writeln('###############################################') 37 | ..writeln(e) 38 | ..writeln(s); 39 | } finally { 40 | await logFileSink.flush(); 41 | await logFileSink.close(); 42 | } 43 | } 44 | 45 | extension _StreamX on Stream { 46 | Stream tee(EventSink teeSink) => transform( 47 | StreamTransformer.fromHandlers( 48 | handleData: (data, sink) { 49 | teeSink.add(data); 50 | sink.add(data); 51 | }, 52 | handleError: (error, stackTrace, sink) { 53 | teeSink.addError(error, stackTrace); 54 | sink.addError(error, stackTrace); 55 | }, 56 | handleDone: (sink) { 57 | teeSink.close(); 58 | sink.close(); 59 | }, 60 | ), 61 | ); 62 | } 63 | 64 | StreamSink> _lineWrapped( 65 | String prefix, 66 | StreamSink> original, 67 | ) => original 68 | .transform( 69 | StreamSinkTransformer, List>.fromHandlers( 70 | handleData: (data, sink) => sink.add(data), 71 | handleError: (err, trace, sink) => sink.addError(err, trace), 72 | handleDone: (_) {}, 73 | ), 74 | ) 75 | .transform(StreamSinkTransformer.fromStreamTransformer(utf8.encoder)) 76 | .transform( 77 | StreamSinkTransformer.fromHandlers( 78 | handleData: (data, sink) => sink.add('$prefix: $data\n'), 79 | ), 80 | ) 81 | .transform( 82 | const StreamSinkTransformer.fromStreamTransformer(LineSplitter()), 83 | ) 84 | .transform(StreamSinkTransformer.fromStreamTransformer(utf8.decoder)); 85 | -------------------------------------------------------------------------------- /lib/src/assuan/core/protocol/base/assuan_data_reader.dart: -------------------------------------------------------------------------------- 1 | import '../../codec/assuan_percent_codec.dart'; 2 | import 'assuan_error_code.dart'; 3 | import 'assuan_exception.dart'; 4 | 5 | class AssuanDataReader { 6 | static const _space = ' '; 7 | static final _nonSpacePattern = RegExp(r'\S'); 8 | 9 | final String command; 10 | final String _data; 11 | int _offset; 12 | 13 | AssuanDataReader(this.command, this._data, this._offset); 14 | 15 | bool hasMoreData({bool fixedSpace = false}) => 16 | !_atEnd && (fixedSpace || _nextNonSpaceIndex() != -1); 17 | 18 | T read({bool fixedSpace = false}) { 19 | final raw = readRaw(fixedSpace: fixedSpace); 20 | // ignore: switch_on_type on purpose 21 | return switch (T) { 22 | const (String) => assuanPercentCodec.decode(raw) as T, 23 | const (int) => int.parse(raw) as T, 24 | const (double) => double.parse(raw) as T, 25 | const (bool) => bool.parse(raw) as T, 26 | _ => throw AssuanException.code( 27 | AssuanErrorCode.invValue, 28 | 'Unsupported data type <$T>', 29 | ), 30 | }; 31 | } 32 | 33 | String readAll({bool fixedSpace = false}) { 34 | final raw = readRaw(fixedSpace: fixedSpace, readToEnd: true); 35 | return assuanPercentCodec.decode(raw); 36 | } 37 | 38 | T? readOptional({bool fixedSpace = false}) { 39 | if (!hasMoreData(fixedSpace: fixedSpace)) { 40 | return null; 41 | } 42 | return read(fixedSpace: fixedSpace); 43 | } 44 | 45 | String? readAllOptional({bool fixedSpace = false}) { 46 | if (!hasMoreData(fixedSpace: fixedSpace)) { 47 | return null; 48 | } 49 | return readAll(fixedSpace: fixedSpace); 50 | } 51 | 52 | String readRaw({bool fixedSpace = false, bool readToEnd = false}) { 53 | if (_atEnd) { 54 | throw AssuanException.code( 55 | AssuanErrorCode.parameter, 56 | 'Missing a required parameter', 57 | ); 58 | } 59 | 60 | // debug assert, as this should never happen 61 | assert( 62 | _data[_offset] == _space, 63 | 'Expected character at $_offset to be a space, but got ${_data[_offset]}', 64 | ); 65 | 66 | final start = fixedSpace ? _offset + 1 : _nextNonSpaceIndex(); 67 | if (start == -1 || start >= _data.length) { 68 | throw AssuanException.code( 69 | AssuanErrorCode.incompleteLine, 70 | 'Unexpected end of data', 71 | ); 72 | } 73 | final end = readToEnd ? -1 : _data.indexOf(_space, start); 74 | 75 | if (end == -1) { 76 | final result = _data.substring(start); 77 | _offset = _data.length; 78 | return result; 79 | } else { 80 | final result = _data.substring(start, end); 81 | _offset = end; // not +1, as we want to keep the space for the next read 82 | return result; 83 | } 84 | } 85 | 86 | int _nextNonSpaceIndex() => _data.indexOf(_nonSpacePattern, _offset); 87 | 88 | bool get _atEnd => _offset >= _data.length; 89 | } 90 | -------------------------------------------------------------------------------- /lib/src/assuan/pinentry/services/pinentry_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:stream_channel/stream_channel.dart'; 4 | 5 | import '../../core/protocol/base/assuan_exception.dart'; 6 | import '../../core/services/assuan_client.dart'; 7 | import '../../core/services/models/inquiry_reply.dart'; 8 | import '../protocol/pinentry_protocol.dart'; 9 | import '../protocol/requests/pinentry_confirm_request.dart'; 10 | import '../protocol/requests/pinentry_enable_quality_bar_request.dart'; 11 | import '../protocol/requests/pinentry_get_info_request.dart'; 12 | import '../protocol/requests/pinentry_get_pin_request.dart'; 13 | import '../protocol/requests/pinentry_message_request.dart'; 14 | import '../protocol/requests/pinentry_set_gen_pin_request.dart'; 15 | import '../protocol/requests/pinentry_set_keyinfo_request.dart'; 16 | import '../protocol/requests/pinentry_set_repeat_request.dart'; 17 | import '../protocol/requests/pinentry_set_text_request.dart'; 18 | import '../protocol/requests/pinentry_set_timeout_request.dart'; 19 | import 'pinentry_server.dart'; 20 | 21 | abstract class PinentryClient extends AssuanClient { 22 | PinentryClient( 23 | StreamChannel channel, { 24 | super.terminateSignal, 25 | super.forceCloseCallback, 26 | super.forceCloseTimeout, 27 | }) : super(PinentryProtocol(), channel); 28 | 29 | PinentryClient.raw( 30 | StreamChannel> channel, { 31 | super.encoding, 32 | super.terminateSignal, 33 | super.forceCloseCallback, 34 | super.forceCloseTimeout, 35 | }) : super.raw(PinentryProtocol(), channel); 36 | 37 | PinentryClient.process( 38 | Process process, { 39 | super.encoding, 40 | super.forceCloseTimeout, 41 | }) : super.process(PinentryProtocol(), process); 42 | 43 | Future getInfo(PinentryInfoKey key) => 44 | sendRequest(PinentryGetInfoRequest(key)); 45 | 46 | Future setTimeout(Duration timeout) => 47 | sendAction(PinentrySetTimeoutRequest(timeout)); 48 | 49 | Future setText(SetCommand command, String text) => 50 | sendAction(PinentrySetTextRequest(command, text)); 51 | 52 | Future setKeyinfo(String keyGrip) => 53 | sendAction(PinentrySetKeyinfoRequest(keyGrip)); 54 | 55 | Future enableQualityBar() => 56 | sendAction(const PinentryEnableQualityBarRequest()); 57 | 58 | Future enablePinGeneration() => 59 | sendAction(const PinentrySetGenPinRequest()); 60 | 61 | Future enableRepeat() => sendAction(const PinentrySetRepeatRequest()); 62 | 63 | Future getPin() async { 64 | try { 65 | return await sendRequest(const PinentryGetPinRequest()); 66 | } on AssuanException catch (e) { 67 | if (e.code != PinentryServer.notConfirmedCode) { 68 | rethrow; 69 | } 70 | return null; 71 | } 72 | } 73 | 74 | Future confirm() async { 75 | try { 76 | await sendAction(const PinentryConfirmRequest()); 77 | return true; 78 | } on AssuanException catch (e) { 79 | if (e.code != PinentryServer.notConfirmedCode) { 80 | rethrow; 81 | } 82 | return false; 83 | } 84 | } 85 | 86 | Future showMessage() async { 87 | try { 88 | await sendAction(const PinentryMessageRequest()); 89 | } on AssuanException catch (e) { 90 | if (e.code != PinentryServer.notConfirmedCode) { 91 | rethrow; 92 | } 93 | } 94 | } 95 | 96 | @override 97 | Future onInquire(String keyword, List parameters) => 98 | Future.value(const InquiryReply.cancel()); 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/app/bitwarden/bitwarden_cli.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'models/bw_object.dart'; 6 | import 'models/bw_status.dart'; 7 | 8 | class BitwardenCli { 9 | static const _passwordEnvKey = '__BITWARDEN_MASTER_PASSWORD'; 10 | 11 | String? _session; 12 | 13 | Future status() => _execJson(const ['status'], BwStatus.fromJson); 14 | 15 | Future sync() => _exec(const ['sync']); 16 | 17 | Future unlock(String masterPassword) async { 18 | await lock(); 19 | _session = await _execString( 20 | const ['unlock'], 21 | arguments: const {'passwordenv': _passwordEnvKey}, 22 | environment: {_passwordEnvKey: masterPassword}, 23 | ); 24 | } 25 | 26 | Future lock() async { 27 | if (_session != null) { 28 | await _exec(const ['lock']); 29 | _session = null; 30 | } 31 | } 32 | 33 | Stream listFolders({ 34 | String? search, 35 | String? url, 36 | String? folderId, 37 | }) => 38 | _list('folders', search: search, url: url, folderId: folderId); 39 | 40 | Stream listItems({String? search, String? url, String? folderId}) => 41 | _list('items', search: search, url: url, folderId: folderId); 42 | 43 | Stream _list( 44 | String type, { 45 | String? search, 46 | String? url, 47 | String? folderId, 48 | }) => _execJsonStream( 49 | ['list', type], 50 | BwObject.fromJson, 51 | arguments: {'search': ?search, 'url': ?url, 'folderid': ?folderId}, 52 | ).cast(); 53 | 54 | Future _exec( 55 | List command, { 56 | Map arguments = const {}, 57 | Map? environment, 58 | }) => _streamBw( 59 | command, 60 | arguments: arguments, 61 | environment: environment, 62 | ).drain(); 63 | 64 | Future _execString( 65 | List command, { 66 | Map arguments = const {}, 67 | Map? environment, 68 | }) => _streamBw( 69 | command, 70 | arguments: arguments, 71 | environment: environment, 72 | ).transform(utf8.decoder).join(); 73 | 74 | Future _execJson( 75 | List command, 76 | T Function(Map) fromJson, { 77 | Map arguments = const {}, 78 | Map? environment, 79 | }) => _streamBw(command, arguments: arguments, environment: environment) 80 | .transform(utf8.decoder) 81 | .transform(json.decoder) 82 | .cast>() 83 | .map(fromJson) 84 | .single; 85 | 86 | Stream _execJsonStream( 87 | List command, 88 | T Function(Map) fromJson, { 89 | Map arguments = const {}, 90 | Map? environment, 91 | }) => _streamBw(command, arguments: arguments, environment: environment) 92 | .transform(utf8.decoder) 93 | .transform(json.decoder) 94 | .cast>() 95 | .expand((l) => l) 96 | .cast>() 97 | .map(fromJson); 98 | 99 | Stream> _streamBw( 100 | List command, { 101 | Map arguments = const {}, 102 | Map? environment, 103 | int? expectedExitCode = 0, 104 | }) async* { 105 | final proc = await Process.start( 106 | 'bw', 107 | [ 108 | ...command, 109 | '--raw', 110 | for (final MapEntry(:key, :value) in arguments.entries) ...[ 111 | '--$key', 112 | if (value != null) value.toString(), 113 | ], 114 | ], 115 | environment: { 116 | ...?environment, 117 | if (_session case final String session) 'BW_SESSION': session, 118 | }, 119 | ); 120 | final stderrSub = proc.stderr.listen(stderr.add); 121 | try { 122 | yield* proc.stdout; 123 | 124 | final exitCode = await proc.exitCode; 125 | if (expectedExitCode != null && exitCode != expectedExitCode) { 126 | throw Exception('bw failed with exit code: $exitCode'); 127 | } 128 | } finally { 129 | unawaited(stderrSub.cancel()); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/src/assuan/core/services/assuan_server.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:async/async.dart'; 6 | import 'package:freezed_annotation/freezed_annotation.dart'; 7 | import 'package:meta/meta.dart'; 8 | import 'package:stream_channel/stream_channel.dart'; 9 | 10 | import '../codec/assuan_codec.dart'; 11 | import '../codec/assuan_data_decoder.dart'; 12 | import '../codec/assuan_data_encoder.dart'; 13 | import '../codec/auto_newline_converter.dart'; 14 | import '../protocol/assuan_comment.dart'; 15 | import '../protocol/assuan_data_message.dart'; 16 | import '../protocol/assuan_protocol.dart'; 17 | import '../protocol/base/assuan_error_code.dart'; 18 | import '../protocol/base/assuan_exception.dart'; 19 | import '../protocol/base/assuan_message.dart'; 20 | import '../protocol/requests/assuan_bye_request.dart'; 21 | import '../protocol/requests/assuan_cancel_request.dart'; 22 | import '../protocol/requests/assuan_end_request.dart'; 23 | import '../protocol/requests/assuan_help_request.dart'; 24 | import '../protocol/requests/assuan_nop_request.dart'; 25 | import '../protocol/requests/assuan_option_request.dart'; 26 | import '../protocol/requests/assuan_reset_request.dart'; 27 | import '../protocol/responses/assuan_error_response.dart'; 28 | import '../protocol/responses/assuan_inquire_response.dart'; 29 | import '../protocol/responses/assuan_ok_response.dart'; 30 | import '../protocol/responses/assuan_status_response.dart'; 31 | import 'models/server_reply.dart'; 32 | 33 | abstract class AssuanServer { 34 | final AssuanProtocol protocol; 35 | final StreamChannel channel; 36 | 37 | var _closed = false; 38 | 39 | late final StreamSubscription _requestSub; 40 | late final StreamSink _responseSink; 41 | var _processingRequest = false; 42 | 43 | // ignore: close_sinks false positive 44 | StreamController? _pendingInquire; 45 | 46 | AssuanServer(this.protocol, this.channel) { 47 | _responseSink = channel.sink 48 | .transform( 49 | StreamSinkTransformer.fromStreamTransformer( 50 | AppendLineTerminatorConverter(), 51 | ), 52 | ) 53 | .transform( 54 | StreamSinkTransformer.fromStreamTransformer( 55 | AssuanResponseCodec(protocol).encoder, 56 | ), 57 | ); 58 | 59 | _requestSub = channel.stream 60 | .transform(const LineSplitter()) 61 | .transform(AssuanRequestCodec(protocol).decoder) 62 | .asyncMap(_handleRequest) 63 | .listen( 64 | null, 65 | onError: _handleError, 66 | onDone: close, 67 | cancelOnError: false, 68 | ); 69 | 70 | _requestSub.pause(init().catchError(_handleError)); 71 | } 72 | 73 | AssuanServer.raw( 74 | AssuanProtocol protocol, 75 | StreamChannel> channel, { 76 | Encoding encoding = utf8, 77 | }) : this( 78 | protocol, 79 | channel.transform(StreamChannelTransformer.fromCodec(encoding)), 80 | ); 81 | 82 | AssuanServer.io( 83 | AssuanProtocol protocol, 84 | Stdin stdin, 85 | Stdout stdout, { 86 | Encoding encoding = systemEncoding, 87 | }) : this.raw( 88 | protocol, 89 | StreamChannel.withGuarantees(stdin, stdout, allowSinkErrors: false), 90 | encoding: encoding, 91 | ); 92 | 93 | @nonVirtual 94 | bool get isOpen => !_closed; 95 | 96 | @nonVirtual 97 | Future close({bool clientInitiated = false}) async { 98 | if (_closed) { 99 | return; 100 | } 101 | _closed = true; 102 | 103 | await finalize(); 104 | 105 | if (clientInitiated) { 106 | _send(const AssuanOkResponse()); 107 | } 108 | 109 | await Future.wait([ 110 | reset(closing: true), 111 | _requestSub.cancel(), 112 | _responseSink.close(), 113 | ]); 114 | } 115 | 116 | @protected 117 | @nonVirtual 118 | void sendStatus(String keyword, String status) => 119 | _send(AssuanStatusResponse(keyword, status)); 120 | 121 | @protected 122 | @nonVirtual 123 | void sendComment(String comment) => _send(AssuanComment(comment)); 124 | 125 | @protected 126 | @nonVirtual 127 | Stream startInquire( 128 | String keyword, [ 129 | List parameters = const [], 130 | ]) { 131 | if (!_processingRequest) { 132 | throw AssuanException.code( 133 | AssuanErrorCode.unknownInquire, 134 | 'Can only inquire while processing a client request', 135 | ); 136 | } 137 | 138 | if (_pendingInquire != null) { 139 | throw AssuanException.code( 140 | AssuanErrorCode.nestedCommands, 141 | 'Cannot inquire while another inquiry is still pending', 142 | ); 143 | } 144 | 145 | // ignore: close_sinks false positive 146 | final ctr = _pendingInquire = StreamController(); 147 | _send(AssuanInquireResponse(keyword, parameters)); 148 | return ctr.stream.transform(const AssuanDataDecoder()); 149 | } 150 | 151 | @protected 152 | @nonVirtual 153 | Future inquire(String keyword, [List parameters = const []]) { 154 | final stream = startInquire(keyword, parameters); 155 | return stream.join(); 156 | } 157 | 158 | @protected 159 | @mustCallSuper 160 | Future init() async => _send(const AssuanOkResponse()); 161 | 162 | @protected 163 | @mustCallSuper 164 | Future finalize() => Future.value(); 165 | 166 | @protected 167 | Future setOption(String name, String? value); 168 | 169 | @protected 170 | Future handleRequest(AssuanRequest request); 171 | 172 | @protected 173 | @mustCallSuper 174 | Future reset({bool closing = false}) async { 175 | final error = AssuanException.code( 176 | AssuanErrorCode.canceled, 177 | closing ? 'Connection was closed' : 'Connection was reset', 178 | ); 179 | 180 | if (_pendingInquire case final StreamController ctr) { 181 | _pendingInquire = null; 182 | ctr.addError(error); 183 | await ctr.close(); 184 | } 185 | } 186 | 187 | @protected 188 | AssuanErrorResponse? mapException( 189 | Exception exception, 190 | StackTrace stackTrace, 191 | ) { 192 | if (exception case final AssuanException e) { 193 | return AssuanErrorResponse(e.code, e.message); 194 | } else { 195 | return AssuanErrorResponse( 196 | AssuanErrorCode.general.code, 197 | exception.toString(), 198 | ); 199 | } 200 | } 201 | 202 | Future _handleRequest(AssuanRequest request) async { 203 | if (_processingRequest) { 204 | await _handleInquiry(request); 205 | return; 206 | } 207 | 208 | try { 209 | _processingRequest = true; 210 | switch (request) { 211 | case AssuanByeRequest(): 212 | await close(clientInitiated: true); 213 | case AssuanResetRequest(): 214 | await reset(); 215 | _send(const AssuanOkResponse()); 216 | case AssuanHelpRequest(): 217 | _sendHelp(); 218 | case AssuanOptionRequest(:final name, :final value): 219 | await setOption(name, value); 220 | _send(const AssuanOkResponse()); 221 | case AssuanNopRequest(): 222 | _send(const AssuanOkResponse()); 223 | case AssuanDataMessage() || AssuanEndRequest() || AssuanCancelRequest(): 224 | throw AssuanException.code( 225 | AssuanErrorCode.unexpectedCmd, 226 | '${request.command} is only allowed as response to INQUIRE', 227 | ); 228 | default: 229 | final reply = await handleRequest(request); 230 | switch (reply) { 231 | case OkReply(:final message): 232 | _send(AssuanOkResponse(message)); 233 | case DataReply(:final data, :final message): 234 | await _sendStream(Stream.value(data), message); 235 | case DataStreamReply(:final data, :final message): 236 | await _sendStream(data, message); 237 | } 238 | } 239 | // ignore: avoid_catches_without_on_clauses for explicit error handling 240 | } catch (e, s) { 241 | _handleError(e, s); 242 | } finally { 243 | _processingRequest = false; 244 | } 245 | } 246 | 247 | void _send(AssuanResponse response) => _responseSink.add(response); 248 | 249 | Future _sendStream(Stream stream, String? doneMessage) => stream 250 | .transform(const AssuanDataEncoder()) 251 | .listen(_send) 252 | .asFuture(doneMessage) 253 | .then((message) => _send(AssuanOkResponse(message))); 254 | 255 | void _sendHelp() { 256 | for (final cmd in protocol.requestCommands) { 257 | _send(AssuanComment(cmd)); 258 | } 259 | _send(const AssuanOkResponse()); 260 | } 261 | 262 | Future _handleInquiry(AssuanRequest request) async { 263 | try { 264 | if (_pendingInquire case StreamController(:final sink)) { 265 | switch (request) { 266 | case final AssuanDataMessage message: 267 | sink.add(message); 268 | case AssuanEndRequest(): 269 | await sink.close(); 270 | _pendingInquire = null; 271 | case AssuanCancelRequest(): 272 | sink.addError( 273 | AssuanException.code( 274 | AssuanErrorCode.canceled, 275 | 'Inquire was canceled', 276 | ), 277 | ); 278 | await sink.close(); 279 | _pendingInquire = null; 280 | default: 281 | throw AssuanException.code( 282 | AssuanErrorCode.unexpectedCmd, 283 | '${request.command} is not allowed as response to INQUIRE', 284 | ); 285 | } 286 | } else { 287 | throw AssuanException.code( 288 | AssuanErrorCode.nestedCommands, 289 | 'Already processing another command', 290 | ); 291 | } 292 | // ignore: avoid_catches_without_on_clauses for explicit error handling 293 | } catch (e, s) { 294 | _handleError(e, s); 295 | } 296 | } 297 | 298 | void _handleError(Object error, StackTrace stackTrace) { 299 | if (error case final Exception exception) { 300 | final assuanErr = mapException(exception, stackTrace); 301 | if (assuanErr != null) { 302 | _send(assuanErr); 303 | return; 304 | } 305 | } else { 306 | Zone.current.handleUncaughtError(error, stackTrace); 307 | } 308 | 309 | _send(AssuanErrorResponse(AssuanErrorCode.general.code)); 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /lib/src/app/pinentry/bw_pinentry_server.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:async/async.dart'; 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | import 'package:meta/meta.dart'; 6 | 7 | import '../../assuan/core/protocol/base/assuan_error_code.dart'; 8 | import '../../assuan/core/protocol/base/assuan_exception.dart'; 9 | import '../../assuan/core/protocol/base/assuan_message.dart'; 10 | import '../../assuan/core/services/models/server_reply.dart'; 11 | import '../../assuan/pinentry/protocol/requests/pinentry_confirm_request.dart'; 12 | import '../../assuan/pinentry/protocol/requests/pinentry_enable_quality_bar_request.dart'; 13 | import '../../assuan/pinentry/protocol/requests/pinentry_get_info_request.dart'; 14 | import '../../assuan/pinentry/protocol/requests/pinentry_get_pin_request.dart'; 15 | import '../../assuan/pinentry/protocol/requests/pinentry_message_request.dart'; 16 | import '../../assuan/pinentry/protocol/requests/pinentry_set_gen_pin_request.dart'; 17 | import '../../assuan/pinentry/protocol/requests/pinentry_set_keyinfo_request.dart'; 18 | import '../../assuan/pinentry/protocol/requests/pinentry_set_repeat_request.dart'; 19 | import '../../assuan/pinentry/protocol/requests/pinentry_set_text_request.dart'; 20 | import '../../assuan/pinentry/protocol/requests/pinentry_set_timeout_request.dart'; 21 | import '../../assuan/pinentry/services/pinentry_server.dart'; 22 | import '../bitwarden/bitwarden_cli.dart'; 23 | import '../bitwarden/models/bw_item_type.dart'; 24 | import '../bitwarden/models/bw_login.dart'; 25 | import '../bitwarden/models/bw_object.dart'; 26 | import '../bitwarden/models/bw_status.dart'; 27 | import 'bw_pinentry_client.dart'; 28 | 29 | final class BwPinentryServer extends PinentryServer { 30 | static const _folderName = 'GPG-Keys'; 31 | 32 | final List _arguments; 33 | 34 | final _bwCli = BitwardenCli(); 35 | late final BwPinentryClient _client; 36 | 37 | final _textCache = {}; 38 | String? _keyGrip; 39 | bool _skipBitwarden = false; 40 | 41 | BwPinentryServer(super.stdin, super.stdout, this._arguments) : super.io(); 42 | 43 | Stream forwardInquiry(String keyword, List parameters) => 44 | startInquire(keyword, parameters); 45 | 46 | void forwardStatus(String keyword, String status) { 47 | sendStatus(keyword, status); 48 | } 49 | 50 | @override 51 | @protected 52 | Future init() async { 53 | final pinentry = Platform.environment['PINENTRY'] ?? 'pinentry'; 54 | sendComment( 55 | 'Server ready. Starting proxied $pinentry with arguments $_arguments...', 56 | ); 57 | _client = await BwPinentryClient.start(this, pinentry, _arguments); 58 | sendComment('Proxy is ready.'); 59 | return super.init(); 60 | } 61 | 62 | @override 63 | @protected 64 | Future setOption(String name, String? value) => 65 | _client.setOption(name, value); 66 | 67 | @override 68 | @protected 69 | Future reset({bool closing = false}) async { 70 | await _bwCli.lock(); 71 | if (!closing) { 72 | _textCache.clear(); 73 | _keyGrip = null; 74 | _skipBitwarden = false; 75 | await _client.reset(); 76 | } 77 | await super.reset(closing: closing); 78 | } 79 | 80 | @override 81 | @protected 82 | Future finalize() async { 83 | await _bwCli.lock(); 84 | await _client.close(); 85 | await super.finalize(); 86 | } 87 | 88 | @override 89 | @protected 90 | Future handleRequest(AssuanRequest request) async { 91 | switch (request) { 92 | case PinentryGetInfoRequest(:final key): 93 | final info = await _client.getInfo(key); 94 | return ServerReply.data(info); 95 | case PinentrySetTextRequest(:final setCommand, :final text): 96 | _textCache[setCommand] = text; 97 | await _client.setText(setCommand, text); 98 | return const OkReply(); 99 | case PinentrySetTimeoutRequest(:final timeout): 100 | await _client.setTimeout(timeout); 101 | return const OkReply(); 102 | case PinentryEnableQualityBarRequest(): 103 | await _client.enableQualityBar(); 104 | return const OkReply(); 105 | case PinentrySetGenPinRequest(): 106 | await _client.enablePinGeneration(); 107 | return const OkReply(); 108 | case PinentrySetRepeatRequest(): 109 | await _client.enableRepeat(); 110 | return const OkReply(); 111 | case PinentrySetKeyinfoRequest(:final keyGrip): 112 | _keyGrip = keyGrip; 113 | await _client.setKeyinfo(keyGrip); 114 | return const OkReply(); 115 | case PinentryMessageRequest(): 116 | case PinentryConfirmRequest(oneButton: true): 117 | return _showMessage(); 118 | case PinentryConfirmRequest(oneButton: false): 119 | return _confirm(); 120 | case PinentryGetPinRequest(): 121 | return _getPin(); 122 | default: 123 | throw AssuanException.code( 124 | AssuanErrorCode.unknownCmd, 125 | 'Unknown command <${request.command}>', 126 | ); 127 | } 128 | } 129 | 130 | Future _showMessage() async { 131 | await _client.showMessage(); 132 | return const ServerReply.ok(); 133 | } 134 | 135 | Future _confirm() async { 136 | final response = await _client.confirm(); 137 | if (!response) { 138 | throw AssuanException( 139 | 'Prompt was not confirmed', 140 | PinentryServer.notConfirmedCode, 141 | ); 142 | } 143 | return const ServerReply.ok(); 144 | } 145 | 146 | Future _getPin() async { 147 | if (_keyGrip case final String keyGrip when !_skipBitwarden) { 148 | final pin = await _getPinFromBitwarden(keyGrip); 149 | if (pin != null) { 150 | return ServerReply.data(pin); 151 | } 152 | _skipBitwarden = true; 153 | } 154 | 155 | final pin = await _client.getPin(); 156 | if (pin != null) { 157 | return ServerReply.data(pin); 158 | } else { 159 | throw AssuanException( 160 | 'Prompt was not confirmed', 161 | PinentryServer.notConfirmedCode, 162 | ); 163 | } 164 | } 165 | 166 | Future _getPinFromBitwarden(String keyGrip) async { 167 | try { 168 | final status = await _ensureUnlocked(); 169 | if (status == null) { 170 | await _resetClientTexts(); 171 | return null; 172 | } 173 | 174 | var synced = false; 175 | if (status.lastSync case final DateTime dt 176 | when DateTime.now().difference(dt) > const Duration(days: 1)) { 177 | sendComment('Last sync was on $dt. Syncing bitwarden vault'); 178 | await _bwCli.sync(); 179 | synced = true; 180 | } 181 | 182 | return await _findPassword(keyGrip, synced: synced); 183 | } finally { 184 | await _bwCli.lock(); 185 | } 186 | } 187 | 188 | Future _ensureUnlocked() async { 189 | sendComment('Checking bitwarden CLI status'); 190 | final status = await _bwCli.status(); 191 | switch (status.status) { 192 | case Status.unauthenticated: 193 | await _client.setText( 194 | SetCommand.error, 195 | 'Bitwarden CLI is not logged in! ' 196 | 'Continuing without bitwarden integration.', 197 | ); 198 | return null; 199 | case Status.locked: 200 | if (await _unlock(status)) { 201 | return status.copyWith(status: Status.unlocked); 202 | } else { 203 | return null; 204 | } 205 | case Status.unlocked: 206 | sendComment('WARNING: bitwarden sessions was already unlocked!'); 207 | return status; 208 | } 209 | } 210 | 211 | Future _unlock(BwStatus status) async { 212 | final descBuffer = StringBuffer(); 213 | if (_textCache case {SetCommand.description: final description}) { 214 | descBuffer.writeln(description); 215 | } 216 | descBuffer 217 | ..write('Please enter the master password for the bitwarden account "') 218 | ..write(status.userEmail) 219 | ..write('"') 220 | ..writeln(); 221 | 222 | await _client.setText(SetCommand.title, 'Bitwarden Authentication'); 223 | await _client.setText(SetCommand.description, descBuffer.toString()); 224 | await _client.setText(SetCommand.prompt, 'Master-Password'); 225 | for (var attempt = 1; attempt <= 3; ++attempt) { 226 | if (attempt > 1) { 227 | await _client.setText( 228 | SetCommand.error, 229 | 'Invalid Password! Please try again (Attempt $attempt/3)', 230 | ); 231 | } 232 | 233 | try { 234 | final masterPassword = await _client.getPin(); 235 | if (masterPassword == null) { 236 | return false; 237 | } 238 | 239 | await _bwCli.unlock(masterPassword); 240 | return true; 241 | } on Exception catch (e) { 242 | sendComment('Failed to unlock bitwarden with error: $e'); 243 | } 244 | } 245 | 246 | throw AssuanException('Failed to unlock bitwarden after 3 attempts'); 247 | } 248 | 249 | Future _findPassword(String keyGrip, {bool synced = false}) async { 250 | sendComment('Searching for folder to narrow down search'); 251 | final folder = await _bwCli.listFolders(search: _folderName).firstOrNull; 252 | sendComment('Searching for items within folder: ${folder?.name}'); 253 | final items = await _bwCli.listItems(folderId: folder?.id).toList(); 254 | sendComment('Found ${items.length} potential items'); 255 | 256 | final matchingItems = items.where(_filterKeyGrip(keyGrip)).toList(); 257 | switch (matchingItems) { 258 | case []: 259 | return await _retrySyncedOrFail( 260 | keyGrip: keyGrip, 261 | message: 'No matching items found!', 262 | synced: synced, 263 | ); 264 | case [BwItem(type: != BwItemType.login)]: 265 | return await _retrySyncedOrFail( 266 | keyGrip: keyGrip, 267 | message: 'Found matching item, but it is not a login!', 268 | synced: synced, 269 | ); 270 | case [BwItem(login: BwLogin(password: final String password))]: 271 | sendComment('Found matching login item with password set.'); 272 | return password; 273 | case [_]: 274 | return await _retrySyncedOrFail( 275 | keyGrip: keyGrip, 276 | message: 'Found matching login item, but it has no password set!', 277 | synced: synced, 278 | ); 279 | default: 280 | return await _retrySyncedOrFail( 281 | keyGrip: keyGrip, 282 | message: 'Found more then one matching item!', 283 | synced: synced, 284 | ); 285 | } 286 | } 287 | 288 | bool Function(BwItem) _filterKeyGrip(String keyGrip) => 289 | (i) => i.fields.any((f) => f.name == 'keygrip' && f.value == keyGrip); 290 | 291 | Future _retrySyncedOrFail({ 292 | required String keyGrip, 293 | required String message, 294 | required bool synced, 295 | }) async { 296 | if (synced) { 297 | sendComment(message); 298 | await _resetClientTexts(); 299 | await _client.setText(SetCommand.error, '$message\nKeygrip: $keyGrip'); 300 | return null; 301 | } else { 302 | sendComment('$message Syncing vault, then trying again.'); 303 | await _bwCli.sync(); 304 | return _findPassword(keyGrip, synced: true); 305 | } 306 | } 307 | 308 | Future _resetClientTexts() async { 309 | for (final MapEntry(:key, :value) in _textCache.entries) { 310 | await _client.setText(key, value); 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /lib/src/assuan/core/services/assuan_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:async/async.dart'; 6 | import 'package:meta/meta.dart'; 7 | import 'package:stream_channel/stream_channel.dart'; 8 | 9 | import '../codec/assuan_codec.dart'; 10 | import '../codec/assuan_data_decoder.dart'; 11 | import '../codec/assuan_data_encoder.dart'; 12 | import '../codec/auto_newline_converter.dart'; 13 | import '../protocol/assuan_data_message.dart'; 14 | import '../protocol/assuan_protocol.dart'; 15 | import '../protocol/base/assuan_error_code.dart'; 16 | import '../protocol/base/assuan_exception.dart'; 17 | import '../protocol/base/assuan_message.dart'; 18 | import '../protocol/requests/assuan_bye_request.dart'; 19 | import '../protocol/requests/assuan_cancel_request.dart'; 20 | import '../protocol/requests/assuan_end_request.dart'; 21 | import '../protocol/requests/assuan_nop_request.dart'; 22 | import '../protocol/requests/assuan_option_request.dart'; 23 | import '../protocol/requests/assuan_reset_request.dart'; 24 | import '../protocol/responses/assuan_error_response.dart'; 25 | import '../protocol/responses/assuan_inquire_response.dart'; 26 | import '../protocol/responses/assuan_ok_response.dart'; 27 | import '../protocol/responses/assuan_status_response.dart'; 28 | import 'models/inquiry_reply.dart'; 29 | import 'models/pending_reply.dart'; 30 | 31 | typedef CloseCallback = Future Function(); 32 | 33 | abstract class AssuanClient { 34 | static const defaultForceCloseTimeout = Duration(seconds: 5); 35 | 36 | final AssuanProtocol protocol; 37 | final StreamChannel channel; 38 | final Future? terminateSignal; 39 | final Duration forceCloseTimeout; 40 | final CloseCallback? forceCloseCallback; 41 | 42 | late Future connected; 43 | var _closed = false; 44 | 45 | late final StreamSubscription _responseSub; 46 | late final StreamSink _requestSink; 47 | 48 | PendingReply? _pendingReply; 49 | Timer? _forceCloseTimer; 50 | 51 | AssuanClient( 52 | this.protocol, 53 | this.channel, { 54 | this.terminateSignal, 55 | this.forceCloseCallback, 56 | this.forceCloseTimeout = defaultForceCloseTimeout, 57 | }) { 58 | _requestSink = channel.sink 59 | .transform( 60 | StreamSinkTransformer.fromStreamTransformer( 61 | AppendLineTerminatorConverter(), 62 | ), 63 | ) 64 | .transform( 65 | StreamSinkTransformer.fromStreamTransformer( 66 | AssuanRequestCodec(protocol).encoder, 67 | ), 68 | ); 69 | 70 | _responseSub = channel.stream 71 | .transform(const LineSplitter()) 72 | .transform(AssuanResponseCodec(protocol).decoder) 73 | .listen( 74 | _handleResponse, 75 | onError: _handleError, 76 | onDone: _handleStreamClosed, 77 | cancelOnError: false, 78 | ); 79 | 80 | if (terminateSignal case final Future signal) { 81 | unawaited( 82 | signal.then((_) => _handleTerminated()).catchError(_handleError), 83 | ); 84 | } else if (forceCloseCallback != null) { 85 | throw ArgumentError( 86 | 'can only be set if terminateSignal is set as well', 87 | 'forceCloseCallback', 88 | ); 89 | } 90 | 91 | final completer = Completer(); 92 | connected = completer.future; 93 | _pendingReply = PendingReply.action(completer); 94 | } 95 | 96 | AssuanClient.raw( 97 | AssuanProtocol protocol, 98 | StreamChannel> channel, { 99 | Encoding encoding = utf8, 100 | Future? terminateSignal, 101 | CloseCallback? forceCloseCallback, 102 | Duration forceCloseTimeout = defaultForceCloseTimeout, 103 | }) : this( 104 | protocol, 105 | channel.transform(StreamChannelTransformer.fromCodec(encoding)), 106 | terminateSignal: terminateSignal, 107 | forceCloseCallback: forceCloseCallback, 108 | forceCloseTimeout: forceCloseTimeout, 109 | ); 110 | 111 | AssuanClient.process( 112 | AssuanProtocol protocol, 113 | Process process, { 114 | Encoding encoding = systemEncoding, 115 | Duration forceCloseTimeout = defaultForceCloseTimeout, 116 | }) : this.raw( 117 | protocol, 118 | StreamChannel.withGuarantees( 119 | process.stdout, 120 | process.stdin, 121 | allowSinkErrors: false, 122 | ), 123 | encoding: encoding, 124 | terminateSignal: process.exitCode, 125 | forceCloseCallback: () => Future.sync(process.kill), 126 | forceCloseTimeout: forceCloseTimeout, 127 | ); 128 | 129 | @nonVirtual 130 | bool get isOpen => !_closed; 131 | 132 | @nonVirtual 133 | Future setOption(String name, [String? value]) => 134 | sendAction(AssuanOptionRequest(name, value)); 135 | 136 | @nonVirtual 137 | Future nop() => sendAction(const AssuanNopRequest()); 138 | 139 | @nonVirtual 140 | Future reset() => sendAction(const AssuanResetRequest()); 141 | 142 | @mustCallSuper 143 | Future close() async { 144 | if (_closed) { 145 | return; 146 | } 147 | _closed = true; 148 | 149 | try { 150 | if (forceCloseCallback case final CloseCallback callback) { 151 | _forceCloseTimer = Timer(forceCloseTimeout, callback); 152 | } 153 | await sendAction(const AssuanByeRequest()); 154 | await terminateSignal?.timeout(forceCloseTimeout); 155 | } finally { 156 | await _cleanup(force: true); 157 | } 158 | } 159 | 160 | @protected 161 | @nonVirtual 162 | Future sendAction(AssuanRequest request) { 163 | if (_pendingReply != null) { 164 | throw AssuanException.code( 165 | AssuanErrorCode.nestedCommands, 166 | 'Another command is still awaiting a reply', 167 | ); 168 | } 169 | 170 | final completer = Completer(); 171 | _pendingReply = PendingReply.action(completer); 172 | _send(request); 173 | return completer.future; 174 | } 175 | 176 | @protected 177 | @nonVirtual 178 | Future sendRequest(AssuanRequest request) => 179 | sendRequestStreamed(request).join(); 180 | 181 | @protected 182 | @nonVirtual 183 | Stream sendRequestStreamed(AssuanRequest request) { 184 | if (_pendingReply != null) { 185 | return Stream.error( 186 | AssuanException.code( 187 | AssuanErrorCode.nestedCommands, 188 | 'Another command is still awaiting a reply', 189 | ), 190 | StackTrace.current, 191 | ); 192 | } 193 | 194 | // ignore: close_sinks false positive 195 | final controller = StreamController(); 196 | _pendingReply = PendingReply.data(controller); 197 | _send(request); 198 | return controller.stream.transform(const AssuanDataDecoder()); 199 | } 200 | 201 | @protected 202 | Future onStatus(String keyword, String status); 203 | 204 | @protected 205 | Future onInquire(String keyword, List parameters); 206 | 207 | @protected 208 | void onUnhandledError(Object error, StackTrace stackTrace) => 209 | Zone.current.handleUncaughtError(error, stackTrace); 210 | 211 | Future _handleResponse(AssuanResponse response) async { 212 | try { 213 | switch (response) { 214 | case AssuanOkResponse(): 215 | await _handleOk(); 216 | case AssuanErrorResponse(:final code, :final message): 217 | await _handleErr(code, message); 218 | case AssuanStatusResponse(:final keyword, :final status): 219 | await onStatus(keyword, status); 220 | case AssuanDataMessage(): 221 | _handleData(response); 222 | case AssuanInquireResponse(:final keyword, :final parameters): 223 | await _handleInquire(keyword, parameters); 224 | default: 225 | throw AssuanException.code( 226 | AssuanErrorCode.unknownCmd, 227 | 'Unknown command <${response.command}>', 228 | ); 229 | } 230 | // ignore: avoid_catches_without_on_clauses for explicit error handling 231 | } catch (e, s) { 232 | await _handleError(e, s); 233 | } 234 | } 235 | 236 | Future _handleOk() async { 237 | switch (_pendingReply) { 238 | case PendingActionReply(:final completer): 239 | _pendingReply = null; 240 | completer.complete(); 241 | case PendingDataReply(:final controller): 242 | _pendingReply = null; 243 | await controller.close(); 244 | case null: 245 | _throwNotPending(); 246 | } 247 | } 248 | 249 | Future _handleErr(int code, String? message) async { 250 | final exception = AssuanException(message ?? '', code); 251 | switch (_pendingReply) { 252 | case PendingActionReply(:final completer): 253 | _pendingReply = null; 254 | completer.completeError(exception); 255 | case PendingDataReply(:final controller): 256 | _pendingReply = null; 257 | controller.addError(exception); 258 | await controller.close(); 259 | case null: 260 | _throwNotPending(); 261 | } 262 | } 263 | 264 | void _handleData(AssuanDataMessage message) { 265 | switch (_pendingReply) { 266 | case PendingDataReply(:final controller): 267 | controller.add(message); 268 | case PendingActionReply(): 269 | throw AssuanException.code( 270 | AssuanErrorCode.invResponse, 271 | 'Expected OK or ERR response, but got D response', 272 | ); 273 | case null: 274 | _throwNotPending(); 275 | } 276 | } 277 | 278 | Future _handleInquire(String keyword, List parameters) async { 279 | try { 280 | if (_pendingReply == null) { 281 | _throwNotPending(); 282 | } 283 | 284 | final response = await onInquire(keyword, parameters); 285 | switch (response) { 286 | case InquiryDataReply(:final data): 287 | await _sendStream(Stream.value(data)); 288 | case InquiryDataStreamReply(:final stream): 289 | await _sendStream(stream); 290 | case InquiryCancelReply(): 291 | _send(const AssuanCancelRequest()); 292 | } 293 | } catch (_) { 294 | _send(const AssuanCancelRequest()); 295 | rethrow; 296 | } 297 | } 298 | 299 | Future _handleError(Object error, StackTrace stackTrace) async { 300 | switch (_pendingReply) { 301 | case PendingActionReply(:final completer): 302 | _pendingReply = null; 303 | completer.completeError(error, stackTrace); 304 | case PendingDataReply(:final controller): 305 | _pendingReply = null; 306 | controller.addError(error, stackTrace); 307 | await controller.close(); 308 | case null: 309 | onUnhandledError(error, stackTrace); 310 | } 311 | } 312 | 313 | void _send(AssuanRequest request) { 314 | _requestSink.add(request); 315 | } 316 | 317 | Future _sendStream(Stream stream) => stream 318 | .transform(const AssuanDataEncoder()) 319 | .listen(_send) 320 | .asFuture() 321 | .then((message) => _send(const AssuanEndRequest())); 322 | 323 | Future _handleTerminated() async { 324 | try { 325 | final wasClosed = _closed; 326 | _forceCloseTimer?.cancel(); 327 | await _cleanup(); 328 | 329 | if (!wasClosed) { 330 | await _handleError( 331 | AssuanException.code( 332 | AssuanErrorCode.connectFailed, 333 | 'Server closed unexpectedly', 334 | ), 335 | StackTrace.current, 336 | ); 337 | } 338 | 339 | // ignore: avoid_catches_without_on_clauses for explicit error handling 340 | } catch (e, s) { 341 | await _handleError(e, s); 342 | } 343 | } 344 | 345 | Future _handleStreamClosed() async { 346 | try { 347 | final wasClosed = _closed; 348 | 349 | if (forceCloseCallback case final CloseCallback callback 350 | when !wasClosed) { 351 | _forceCloseTimer ??= Timer(forceCloseTimeout, callback); 352 | } 353 | 354 | await _cleanup(); 355 | 356 | if (!wasClosed) { 357 | await _handleError( 358 | AssuanException.code( 359 | AssuanErrorCode.connectFailed, 360 | 'Server closed unexpectedly', 361 | ), 362 | StackTrace.current, 363 | ); 364 | } 365 | 366 | // ignore: avoid_catches_without_on_clauses for explicit error handling 367 | } catch (e, s) { 368 | await _handleError(e, s); 369 | } 370 | } 371 | 372 | Future _cleanup({bool force = false}) { 373 | if (_closed && !force) { 374 | return Future.value(); 375 | } 376 | _closed = true; 377 | 378 | return Future.wait([ 379 | _responseSub.cancel(), 380 | _requestSink.close(), 381 | if (_pendingReply != null) 382 | _handleError( 383 | AssuanException.code( 384 | AssuanErrorCode.canceled, 385 | 'Connection was closed', 386 | ), 387 | StackTrace.current, 388 | ), 389 | ]); 390 | } 391 | 392 | Never _throwNotPending() => throw AssuanException.code( 393 | AssuanErrorCode.invResponse, 394 | 'Not awaiting any data from server', 395 | ); 396 | } 397 | --------------------------------------------------------------------------------