spki_hash_pinning 0.1.1 copy "spki_hash_pinning: ^0.1.1" to clipboard
spki_hash_pinning: ^0.1.1 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,
            ),
      ),
    );
  }
}
0
likes
160
points
141
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

SPKI hash certificate pinning for Dart and Flutter clients.

Repository (GitHub)

Topics

#security #ssl-pinning #certificate-pinning #tls

License

MIT (license)

More

Packages that depend on spki_hash_pinning