diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index 80f0b06c..50c61892 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -12,6 +12,9 @@ import 'package:server_box/data/provider/private_key.dart'; import 'package:server_box/data/res/misc.dart'; const _format = 'text/plain'; +final _whitespaceRegex = RegExp(r'\s+'); +final _pemBeginRegex = RegExp(r'^-----BEGIN ([A-Z0-9 ]+)-----$'); +final _pemEndRegex = RegExp(r'^-----END ([A-Z0-9 ]+)-----$'); final class PrivateKeyEditPageArgs { final PrivateKeyInfo? pki; @@ -116,6 +119,63 @@ class _PrivateKeyEditPageState extends ConsumerState { return value.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); } + /// Normalizes the private key format: + /// - Removes whitespace from Base64 content (spaces, tabs, etc.) + /// - Ensures the key ends with a newline + String _normalizePrivateKey(String key) { + final lines = key.split('\n'); + // Guard: need at least header + body + footer (3 lines) for valid PEM + if (lines.length < 3) return key; + + final header = lines.first; + final footer = lines.last; + + // Validate PEM boundaries before mutating input + final headerMatch = _pemBeginRegex.firstMatch(header); + final footerMatch = _pemEndRegex.firstMatch(footer); + if (headerMatch == null || footerMatch == null) { + return key; + } + + // Ensure header and footer labels match + final headerLabel = headerMatch.group(1); + final footerLabel = footerMatch.group(1); + if (headerLabel != footerLabel) { + return key; + } + + // Extract Base64 content (everything between header and footer) + final bodyLines = lines.sublist(1, lines.length - 1); + + // Check for RFC 1421 metadata headers (e.g., Proc-Type, DEK-Info) + // These appear in encrypted PEM keys and must be preserved + final hasMetadataHeaders = bodyLines.any( + (line) => line.contains(':') && !line.startsWith('-----'), + ); + + if (hasMetadataHeaders) { + // For encrypted keys, preserve structure and just ensure trailing newline + if (!key.endsWith('\n')) { + return '$key\n'; + } + return key; + } + + // Remove all whitespace from Base64 content + final cleanBody = bodyLines.join('').replaceAll(_whitespaceRegex, ''); + + // Rebuild the key with standard formatting (64 chars per line) + final buffer = StringBuffer(); + buffer.writeln(header); + for (var i = 0; i < cleanBody.length; i += 64) { + final end = (i + 64 < cleanBody.length) ? i + 64 : cleanBody.length; + buffer.writeln(cleanBody.substring(i, end)); + } + buffer.writeln(footer); + + return buffer.toString(); + } + Widget _buildFAB() { return FloatingActionButton(tooltip: l10n.save, onPressed: _onTapSave, child: const Icon(Icons.save)); } @@ -186,7 +246,7 @@ class _PrivateKeyEditPageState extends ConsumerState { void _onTapSave() async { final name = _nameController.text; - final key = _standardizeLineSeparators(_keyController.text.trim()); + final key = _normalizePrivateKey(_standardizeLineSeparators(_keyController.text.trim())); final pwd = _pwdController.text; if (name.isEmpty || key.isEmpty) { context.showSnackBar(libL10n.empty);