Skip to content

feat: Public key pinning (SPKI) support - #96

Open
TseoH wants to merge 9 commits into
diefferson:masterfrom
TseoH:master
Open

feat: Public key pinning (SPKI) support#96
TseoH wants to merge 9 commits into
diefferson:masterfrom
TseoH:master

Conversation

@TseoH

@TseoH TseoH commented Jul 29, 2026

Copy link
Copy Markdown

We were previously using http_certificate_pinning with fingerprint pinning, but we now have a case where we need to pin the public key instead of the whole certificate hash.
This PR adds public key pinning (SPKI) as a separate, opt-in feature. Pins are base64-encoded SHA-256 hashes of the certificate's SubjectPublicKeyInfo.

While checking the existing issues, I came across #54 and #78 and saw your concerns about this kind of implementation (in short: pinning the key of a shared public CA would match any certificate that CA issues, and widening the existing check would silently weaken current users' pinning). These concerns are legitimate and I understand them well.
I still think a flexible approach is the right one: end users should be aware of the implications, and certificate pinning is not the kind of feature that can be implemented on the fly. This PR treats it that way. The pinned chain position is an explicit choice per call, nothing is widened silently, and the README documents the trade-offs so the decision is an informed one. And to make it clear: the current check (through cert fingerprint) remains unchanged, independent and completely separated from the public key pinning concern, giving anyone the possibility to go with the approach that fits their use case better.

// Leaf pins, with intermediate pins as fallback
await HttpCertificatePinning.checkPublicKeys(
  serverURL: url,
  allowedLeafPublicKeyHashes: ['<leaf pin>'],
  allowedIntermediatePublicKeyHashes: ['<intermediate pin>'],
);

// Position-specific checks
await HttpCertificatePinning.checkLeaf(serverURL: url, publicKeyHashes: ['<pin>']);
await HttpCertificatePinning.checkIntermediate(serverURL: url, publicKeyHashes: ['<pin>']);
await HttpCertificatePinning.checkRoot(serverURL: url, publicKeyHashes: ['<pin>']);

What changed:

  • New Dart API: checkPublicKeys (leaf pins with intermediate fallback), plus the position-specific checkLeaf, checkIntermediate and checkRoot. Every method accepts an allowCache flag (default true), useful to disable while testing pin changes
  • iOS: added PublicKeyPinningTrustEvaluator, an Alamofire ServerTrustEvaluating implementation following the same pattern as the existing fingerprint evaluator. It takes pins per chain position, requires standard X.509 evaluation to pass first, and rebuilds the SPKI ASN.1 structure before hashing (RSA 2048/4096, ECDSA P-256/P-384), so hashes match the standard openssl pipeline
  • iOS: added PublicKeyPinningChecker, which wraps the evaluator in a configured session that fails closed (allHostsMustBeEvaluated: true), does not follow redirects, and maps failures to distinct error codes. Compatible with the package's iOS 12 target
  • Android: pins are computed from PublicKey.getEncoded() (already DER SubjectPublicKeyInfo), with chain position logic mirroring iOS. allowCache maps to useCaches, plus Connection: close when disabled so the check observes a fresh handshake
  • README: new "Public Key Pinning (SPKI)" section presenting both strategies as equally supported options, a caution block linking the OWASP Pinning Cheat Sheet (backup pins, key lifecycle, recovery), and openssl commands to extract leaf, intermediate and root pins from a certificate file or a live server
  • Added get_public_key_pins.sh, a helper script that prints the pin of every certificate in a server's chain, labeled by position
  • Example app: a check-type selector exercising all five methods, with an "Allow cache" toggle for the public key modes
  • Known caveats are documented in README and dartdoc: on Android checkRoot compares against the last certificate the server sent (some servers omit the root), and on iOS the OS TLS session cache can resume a recent session without re-triggering the pin check

Tested:

  • Tested through the example project: every method exercised on the iOS simulator against a live host, including pin mismatch (rejected) and connectivity failure (distinct error) behavior
  • Unit tests for argument marshalling, pin normalization, default values and allowCache forwarding, 11 passing
  • flutter build ios and flutter build apk both build clean

No breaking changes: the existing check API, its method channel contract and its behavior are completely unchanged.

TseoH and others added 9 commits July 29, 2026 09:12
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
- Android: safe-cast method channel arguments in the public key handlers and
  return a Params incorrect error instead of crashing on malformed input
- Android: force Connection: close when allowCache=false so the check observes
  a fresh handshake despite keep-alive pooling, and release the connection in
  a finally block on all paths
- iOS: remove unused CommonCrypto import from PublicKeyPinningTrustEvaluator
- Dart: strip all whitespace in pin normalization, not just the ends
- Example: define the global iOS platform (13.0) in the Podfile
- README: document that the second certificate of a served chain is only the
  intermediate for chains of 3+, add code fence languages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVSg1UgxvaJQxCJJbnPSKL
feat: add support for spki public key pinning
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@TseoH
TseoH marked this pull request as ready for review July 29, 2026 11:58
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant