spki_hash_pinning 0.1.0
spki_hash_pinning: ^0.1.0 copied to clipboard
SPKI hash certificate pinning for Dart and Flutter clients.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'pinning_backend.dart';
import 'pinning_backend_base.dart';
void main() {
runApp(const SpkiExampleApp());
}
class SpkiExampleApp extends StatelessWidget {
final PinningBackend? backend;
final Future<String?> Function(String serverUrl)? fetchFingerprint;
final Future<bool> Function(
String serverUrl,
List<String> allowedFingerprints,
)? checkPin;
final Future<String> Function(
String serverUrl,
List<String> allowedFingerprints,
)? runPinnedRequest;
const SpkiExampleApp({
super.key,
this.backend,
this.fetchFingerprint,
this.checkPin,
this.runPinnedRequest,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'SPKI Pinning Example',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0F62FE),
brightness: Brightness.light,
),
),
home: PinningDemoPage(
backend: backend,
fetchFingerprint: fetchFingerprint,
checkPin: checkPin,
runPinnedRequest: runPinnedRequest,
),
);
}
}
class PinningDemoPage extends StatefulWidget {
final PinningBackend? backend;
final Future<String?> Function(String serverUrl)? fetchFingerprint;
final Future<bool> Function(
String serverUrl,
List<String> allowedFingerprints,
)? checkPin;
final Future<String> Function(
String serverUrl,
List<String> allowedFingerprints,
)? runPinnedRequest;
const PinningDemoPage({
super.key,
this.backend,
this.fetchFingerprint,
this.checkPin,
this.runPinnedRequest,
});
@override
State<PinningDemoPage> createState() => _PinningDemoPageState();
}
class _PinningDemoPageState extends State<PinningDemoPage> {
final _urlController = TextEditingController(text: 'https://example.com');
final _pinController = TextEditingController();
String _status = 'Ready';
String _bodyPreview = 'Fetch a fingerprint or run a request to see output.';
final List<String> _logs = <String>[];
bool _busy = false;
PinningBackend get _backend => widget.backend ?? createPinningBackend();
@override
void initState() {
super.initState();
_appendLog('Demo initialized', notify: false);
_appendLog(_backend.supportMessage, notify: false);
}
@override
void dispose() {
_urlController.dispose();
_pinController.dispose();
super.dispose();
}
void _log(String message) {
_appendLog(message, notify: true);
}
void _appendLog(String message, {required bool notify}) {
final now = DateTime.now();
final stamp =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
final line = '[$stamp] $message';
debugPrint(line);
_logs.insert(0, line);
if (_logs.length > 50) {
_logs.removeLast();
}
if (!notify || !mounted) return;
setState(() {});
}
void _clearLogs() {
setState(() => _logs.clear());
_appendLog('Logs cleared', notify: false);
if (mounted) {
setState(() {});
}
}
Future<void> _fetchFingerprint() async {
await _runBusy(() async {
try {
final url = _urlController.text.trim();
_log('Fetching fingerprint for $url');
final fingerprint =
await (widget.fetchFingerprint ?? _backend.fetchFingerprint)(url);
if (!mounted) return;
if (fingerprint == null) {
_log('Fingerprint fetch returned no value');
setState(() {
_status = 'Could not fetch fingerprint from $url';
});
return;
}
_log('Fingerprint fetched: $fingerprint');
setState(() {
_pinController.text = fingerprint;
_status = 'Fingerprint fetched and copied into the pin field.';
_bodyPreview = fingerprint;
});
} catch (error) {
if (!mounted) return;
_log('Fingerprint fetch failed: $error');
setState(() {
_status = 'Could not fetch fingerprint: $error';
});
}
});
}
Future<void> _checkPin() async {
await _runBusy(() async {
try {
final url = _urlController.text.trim();
final pin = _pinController.text.trim();
_log('Checking pin for $url');
_log('Using pin value with ${pin.length} characters');
if (pin.isEmpty) {
if (!mounted) return;
_log('Pin check skipped because the pin field is empty');
setState(() {
_status = 'Add a fingerprint first.';
});
return;
}
final secure = await (widget.checkPin ?? _backend.checkPin)(url, [pin]);
if (!mounted) return;
_log(secure ? 'Pin check passed' : 'Pin check failed');
if (!secure) {
final observed =
await (widget.fetchFingerprint ?? _backend.fetchFingerprint)(url);
if (!mounted) return;
if (observed == null) {
_log('Could not read the server fingerprint for comparison');
} else {
_log('Server fingerprint observed during check: $observed');
}
}
setState(() {
_status = secure
? 'Pin check passed for $url'
: 'Pin check failed for $url';
});
} catch (error) {
if (!mounted) return;
_log('Pin check failed with error: $error');
setState(() {
_status = 'Pin check failed: $error';
});
}
});
}
Future<void> _runPinnedRequest() async {
await _runBusy(() async {
final url = _urlController.text.trim();
final pin = _pinController.text.trim();
_log('Running pinned request for $url');
if (pin.isEmpty) {
if (!mounted) return;
_log('Pinned request skipped because the pin field is empty');
setState(() {
_status = 'Add a fingerprint before making a pinned request.';
});
return;
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
if (!mounted) return;
_log('Pinned request skipped because the URL is invalid');
setState(() {
_status = 'Enter a valid URL.';
});
return;
}
try {
final preview =
await (widget.runPinnedRequest ?? _backend.runPinnedRequest)(
url,
[pin],
);
if (!mounted) return;
_log('Pinned request completed successfully');
setState(() {
_status = 'GET completed from $url';
_bodyPreview = preview.isEmpty ? '<empty body>' : preview;
});
} catch (error) {
if (!mounted) return;
_log('Pinned request failed: $error');
setState(() {
_status = 'Request failed: $error';
_bodyPreview = error.toString();
});
}
});
}
Future<void> _runBusy(Future<void> Function() action) async {
if (_busy) return;
setState(() => _busy = true);
try {
await action();
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFFF5F8FF), Color(0xFFE7F0FF), Color(0xFFFDFEFF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 860),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'SPKI pinning example',
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
),
),
const SizedBox(height: 8),
Text(
'Fetch a server fingerprint, pin it, and make a request through package:http.',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.black87,
),
),
const SizedBox(height: 12),
_PlatformNotice(
message: _backend.supportMessage,
),
const SizedBox(height: 24),
_Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://example.com',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _pinController,
decoration: const InputDecoration(
labelText: 'Allowed SPKI fingerprint',
hintText: 'Base64 fingerprint',
border: OutlineInputBorder(),
),
minLines: 2,
maxLines: 4,
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: _busy || !_backend.isSupported
? null
: _fetchFingerprint,
icon: const Icon(Icons.fingerprint),
label: const Text('Fetch fingerprint'),
),
FilledButton.tonalIcon(
onPressed: _busy || !_backend.isSupported
? null
: _checkPin,
icon: const Icon(Icons.verified_outlined),
label: const Text('Check pin'),
),
FilledButton.tonalIcon(
onPressed: _busy || !_backend.isSupported
? null
: _runPinnedRequest,
icon: const Icon(Icons.http),
label: const Text('GET with pinned client'),
),
OutlinedButton.icon(
onPressed: _busy ? null : _clearLogs,
icon: const Icon(Icons.clear_all),
label: const Text('Clear logs'),
),
],
),
],
),
),
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _Panel(
title: 'Status',
child: Text(
_status,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
const SizedBox(width: 16),
Expanded(
child: _Panel(
title: 'Response preview',
child: SelectableText(
_bodyPreview,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
],
),
const SizedBox(height: 16),
Text(
'Tip: fetch the fingerprint first, then paste it into the pin field. '
'That mirrors how you would provision a release pin in production.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.black54,
),
),
const SizedBox(height: 24),
_Panel(
title: 'Logs',
child: SizedBox(
height: 220,
child: _logs.isEmpty
? Align(
alignment: Alignment.topLeft,
child: Text(
'No logs yet. Try one of the actions above.',
style: Theme.of(context).textTheme.bodyMedium,
),
)
: ListView.separated(
itemCount: _logs.length,
separatorBuilder: (_, __) =>
const Divider(height: 1),
itemBuilder: (context, index) {
return SelectableText(
_logs[index],
style:
Theme.of(context).textTheme.bodySmall,
);
},
),
),
),
const SizedBox(height: 16),
if (_busy)
const LinearProgressIndicator(minHeight: 4)
else
const SizedBox(height: 4),
],
),
),
),
),
),
),
);
}
}
class _Panel extends StatelessWidget {
final Widget child;
final String? title;
const _Panel({
required this.child,
this.title,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0x1A0F62FE)),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 24,
offset: Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
],
child,
],
),
);
}
}
class _PlatformNotice extends StatelessWidget {
final String message;
const _PlatformNotice({required this.message});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF4DB),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE7C86E)),
),
child: Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF5A4300),
fontWeight: FontWeight.w600,
),
),
);
}
}