diff --git a/CHANGELOG.md b/CHANGELOG.md index 5392c90..1a6843f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ # Changelog +## [0.5.1] - 2026-06-29 +### Fixed +* Require `git2dart_binaries` `>=1.11.2 <1.12.0` to pick up the latest + packaged libgit2 binaries and generated bindings. +* Add a remote `certificateCheck` callback so SSH clients can supply host key + trust decisions without relying on `known_hosts` lookup. + +### Documentation +* Document the `git2dart_binaries` `1.11.2` dependency baseline in the README. + ## [0.5.0] - 2026-05-30 ### Features * Update `git2dart_binaries` constraint to `>=1.11.0 <1.12.0`. diff --git a/README.md b/README.md index ddad9af..a231541 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ This is a hardfork of [libgit2dart](https://github.com/SkinnyMind/libgit2dart) Currently supported platforms are 64-bit Windows, Linux, macOS, Android (arm64-v8a, x86_64), and iOS on Flutter. Desktop platforms also work on the Dart VM when native libraries are available. + +Version `0.5.1` requires `git2dart_binaries >=1.11.2 <1.12.0`, which provides +the bundled libgit2 artifacts and generated FFI bindings used by this package. ## Usage git2dart provides you ability to manage Git repository. You can read and write objects (commit, tag, tree and blob), walk a tree, access the staging area, manage config and lots more. diff --git a/doc/types/remote.md b/doc/types/remote.md index 3007024..f81be42 100644 --- a/doc/types/remote.md +++ b/doc/types/remote.md @@ -37,6 +37,18 @@ remote.push(refspecs: ['refs/heads/main']); remote.prune(); ``` +## Certificate Checks + +```dart +final callbacks = Callbacks( + certificateCheck: (certificate, host, {required valid}) { + return host == 'github.com'; + }, +); + +remote.fetch(callbacks: callbacks); +``` + Network-dependent tests are skipped in CI unless explicitly enabled. See [test/remote_test.dart](../../test/remote_test.dart). diff --git a/lib/git2dart.dart b/lib/git2dart.dart index c6126fe..0927677 100644 --- a/lib/git2dart.dart +++ b/lib/git2dart.dart @@ -7,6 +7,7 @@ export 'src/blame.dart'; export 'src/blob.dart'; export 'src/branch.dart'; export 'src/callbacks.dart'; +export 'src/certificate.dart'; export 'src/checkout.dart'; export 'src/commit.dart'; export 'src/commit_graph.dart'; diff --git a/lib/src/bindings/certificate.dart b/lib/src/bindings/certificate.dart new file mode 100644 index 0000000..55f76c4 --- /dev/null +++ b/lib/src/bindings/certificate.dart @@ -0,0 +1,112 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:git2dart_binaries/git2dart_binaries.dart'; + +/// Return certificate type as a `GIT_CERT_` value. +int type(Pointer certificatePointer) { + return certificatePointer.ref.cert_typeAsInt; +} + +/// Return certificate as SSH host key structure. +Pointer hostkey(Pointer certificatePointer) { + return certificatePointer.cast(); +} + +/// Return certificate as X.509 structure. +Pointer x509(Pointer certificatePointer) { + return certificatePointer.cast(); +} + +/// Return raw bitmask of available SSH host key fields. +int hostkeyTypeFlags(Pointer hostkeyPointer) { + return hostkeyPointer.ref.typeAsInt; +} + +/// Return whether [flag] is set in SSH host key fields. +bool hostkeyHasFlag({ + required Pointer hostkeyPointer, + required int flag, +}) { + return hostkeyTypeFlags(hostkeyPointer) & flag != 0; +} + +/// Return whether MD5 host key hash is available. +bool hostkeyHasMd5(Pointer hostkeyPointer) { + return hostkeyHasFlag( + hostkeyPointer: hostkeyPointer, + flag: git_cert_ssh_t.GIT_CERT_SSH_MD5.value, + ); +} + +/// Return whether SHA-1 host key hash is available. +bool hostkeyHasSha1(Pointer hostkeyPointer) { + return hostkeyHasFlag( + hostkeyPointer: hostkeyPointer, + flag: git_cert_ssh_t.GIT_CERT_SSH_SHA1.value, + ); +} + +/// Return whether SHA-256 host key hash is available. +bool hostkeyHasSha256(Pointer hostkeyPointer) { + return hostkeyHasFlag( + hostkeyPointer: hostkeyPointer, + flag: git_cert_ssh_t.GIT_CERT_SSH_SHA256.value, + ); +} + +/// Return whether raw SSH host key is available. +bool hostkeyHasRaw(Pointer hostkeyPointer) { + return hostkeyHasFlag( + hostkeyPointer: hostkeyPointer, + flag: git_cert_ssh_t.GIT_CERT_SSH_RAW.value, + ); +} + +/// Return MD5 host key hash. +Uint8List hostkeyMd5(Pointer hostkeyPointer) { + return _copyArray(hostkeyPointer.ref.hash_md5, 16); +} + +/// Return SHA-1 host key hash. +Uint8List hostkeySha1(Pointer hostkeyPointer) { + return _copyArray(hostkeyPointer.ref.hash_sha1, 20); +} + +/// Return SHA-256 host key hash. +Uint8List hostkeySha256(Pointer hostkeyPointer) { + return _copyArray(hostkeyPointer.ref.hash_sha256, 32); +} + +/// Return raw SSH host key type. +int hostkeyRawType(Pointer hostkeyPointer) { + return hostkeyPointer.ref.raw_typeAsInt; +} + +/// Return raw SSH host key bytes. +Uint8List hostkeyRaw(Pointer hostkeyPointer) { + final hostkey = hostkeyPointer.ref.hostkey; + final length = hostkeyPointer.ref.hostkey_len; + + if (hostkey == nullptr || length == 0) { + return Uint8List(0); + } + + return Uint8List.fromList(hostkey.cast().asTypedList(length)); +} + +/// Return raw X.509 certificate bytes. +Uint8List x509Data(Pointer certificatePointer) { + final dataPointer = certificatePointer.ref.data; + final length = certificatePointer.ref.len; + + if (dataPointer == nullptr || length == 0) { + return Uint8List(0); + } + + return Uint8List.fromList(dataPointer.cast().asTypedList(length)); +} + +Uint8List _copyArray(Array array, int length) { + return Uint8List.fromList([for (var i = 0; i < length; i++) array[i]]); +} diff --git a/lib/src/bindings/remote_callbacks.dart b/lib/src/bindings/remote_callbacks.dart index a788d27..4a910ce 100644 --- a/lib/src/bindings/remote_callbacks.dart +++ b/lib/src/bindings/remote_callbacks.dart @@ -80,6 +80,25 @@ class RemoteCallbacks { return 0; } + /// Callback function used to validate a remote certificate. + static CertificateCheck? certificateCheck; + + /// Native callback that handles remote certificate validation. + static int certificateCheckCb( + Pointer cert, + int valid, + Pointer host, + Pointer payload, + ) { + final accepted = certificateCheck!( + GitCertificate(cert), + host == nullptr ? '' : host.toDartString(), + valid: valid == 1, + ); + + return accepted ? 0 : -1; + } + /// Values used to override the remote creation and customization process /// during a clone operation. static RemoteCallback? remoteCbData; @@ -260,6 +279,14 @@ class RemoteCallbacks { ); } + if (callbacks.certificateCheck != null) { + certificateCheck = callbacks.certificateCheck; + callbacksOptions.certificate_check = Pointer.fromFunction( + certificateCheckCb, + except, + ); + } + if (callbacks.credentials != null) { credentials = callbacks.credentials; final withUser = calloc()..value = 1; @@ -278,6 +305,7 @@ class RemoteCallbacks { sidebandProgress = null; updateTips = null; pushUpdateReference = null; + certificateCheck = null; remoteCbData = null; repositoryCbData = null; credentials = null; diff --git a/lib/src/callbacks.dart b/lib/src/callbacks.dart index 3d7c3c8..d127e9a 100644 --- a/lib/src/callbacks.dart +++ b/lib/src/callbacks.dart @@ -1,11 +1,22 @@ import 'package:git2dart/git2dart.dart'; +/// Callback for validating a remote certificate. +/// +/// Return `true` to accept the certificate, or `false` to reject it. +typedef CertificateCheck = + bool Function( + GitCertificate certificate, + String host, { + required bool valid, + }); + /// A class that encapsulates various callback functions used in Git operations. /// /// This class is primarily used with [Remote] methods and [Repository.clone] /// operations to handle different aspects of Git operations: /// - Authentication /// - Progress tracking +/// - Certificate checks /// - Reference updates /// - Push status updates class Callbacks { @@ -15,12 +26,14 @@ class Callbacks { /// Git operation requirements: /// /// * [credentials] - Authentication credentials (see [Credentials] implementations) + /// * [certificateCheck] - Remote certificate trust decision /// * [transferProgress] - Progress tracking for data transfers /// * [sidebandProgress] - Textual progress updates from remote /// * [updateTips] - Reference update notifications /// * [pushUpdateReference] - Push operation status updates const Callbacks({ this.credentials, + this.certificateCheck, this.transferProgress, this.sidebandProgress, this.updateTips, @@ -36,6 +49,15 @@ class Callbacks { /// * [KeypairFromMemory] - In-memory SSH key pair authentication final Credentials? credentials; + /// Callback for validating a remote certificate. + /// + /// Return `true` to accept the certificate, or `false` to reject it. Leave + /// this unset to use libgit2's default certificate validation behavior. + /// + /// This is especially useful on platforms such as Android where SSH + /// `known_hosts` lookup may not be available. + final CertificateCheck? certificateCheck; + /// Callback for tracking data transfer progress. /// /// Provides real-time updates about the progress of data transfers diff --git a/lib/src/certificate.dart b/lib/src/certificate.dart new file mode 100644 index 0000000..4598d20 --- /dev/null +++ b/lib/src/certificate.dart @@ -0,0 +1,184 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:git2dart/src/bindings/certificate.dart' as bindings; +import 'package:git2dart_binaries/git2dart_binaries.dart'; +import 'package:meta/meta.dart'; + +/// The type of certificate presented by a remote transport. +enum GitCertificateType { + /// No certificate was provided. + none, + + /// An X.509 certificate. + x509, + + /// An SSH host key provided by libssh2. + hostkeyLibssh2, + + /// A list of certificate strings. + strarray, + + /// A certificate type unknown to this version of git2dart. + unknown, +} + +/// The raw SSH host key algorithm. +enum GitCertificateSshRawType { + /// The raw key type is unknown. + unknown, + + /// RSA host key. + rsa, + + /// DSS host key. + dss, + + /// ECDSA P-256 host key. + ecdsa256, + + /// ECDSA P-384 host key. + ecdsa384, + + /// ECDSA P-521 host key. + ecdsa521, + + /// Ed25519 host key. + ed25519, +} + +/// Certificate information provided to [Callbacks.certificateCheck]. +/// +/// The object is only valid while the certificate callback is executing. +class GitCertificate { + /// Initializes a [GitCertificate] from a libgit2 certificate pointer. + /// + /// For internal use. + @internal + const GitCertificate(this._certificatePointer); + + final Pointer _certificatePointer; + + /// The type of certificate presented by the remote. + GitCertificateType get type { + return switch (bindings.type(_certificatePointer)) { + 0 => GitCertificateType.none, + 1 => GitCertificateType.x509, + 2 => GitCertificateType.hostkeyLibssh2, + 3 => GitCertificateType.strarray, + _ => GitCertificateType.unknown, + }; + } + + /// SSH host key information, when [type] is [GitCertificateType.hostkeyLibssh2]. + GitCertificateHostkey? get hostkey { + if (type != GitCertificateType.hostkeyLibssh2) { + return null; + } + + return GitCertificateHostkey(bindings.hostkey(_certificatePointer)); + } + + /// X.509 certificate information, when [type] is [GitCertificateType.x509]. + GitCertificateX509? get x509 { + if (type != GitCertificateType.x509) { + return null; + } + + return GitCertificateX509(bindings.x509(_certificatePointer)); + } +} + +/// SSH host key information presented by a remote. +/// +/// The object is only valid while the certificate callback is executing. +class GitCertificateHostkey { + /// Initializes a [GitCertificateHostkey] from a libgit2 host key pointer. + /// + /// For internal use. + @internal + const GitCertificateHostkey(this._hostkeyPointer); + + final Pointer _hostkeyPointer; + + /// Raw bitmask of available SSH host key fields. + int get typeFlags => bindings.hostkeyTypeFlags(_hostkeyPointer); + + /// Whether [md5] is available. + bool get hasMd5 => bindings.hostkeyHasMd5(_hostkeyPointer); + + /// Whether [sha1] is available. + bool get hasSha1 => bindings.hostkeyHasSha1(_hostkeyPointer); + + /// Whether [sha256] is available. + bool get hasSha256 => bindings.hostkeyHasSha256(_hostkeyPointer); + + /// Whether [rawHostkey] and [rawType] are available. + bool get hasRawHostkey => bindings.hostkeyHasRaw(_hostkeyPointer); + + /// MD5 host key hash, or `null` when unavailable. + Uint8List? get md5 { + if (!hasMd5) { + return null; + } + + return bindings.hostkeyMd5(_hostkeyPointer); + } + + /// SHA-1 host key hash, or `null` when unavailable. + Uint8List? get sha1 { + if (!hasSha1) { + return null; + } + + return bindings.hostkeySha1(_hostkeyPointer); + } + + /// SHA-256 host key hash, or `null` when unavailable. + Uint8List? get sha256 { + if (!hasSha256) { + return null; + } + + return bindings.hostkeySha256(_hostkeyPointer); + } + + /// Raw SSH host key algorithm. + GitCertificateSshRawType get rawType { + return switch (bindings.hostkeyRawType(_hostkeyPointer)) { + 1 => GitCertificateSshRawType.rsa, + 2 => GitCertificateSshRawType.dss, + 3 => GitCertificateSshRawType.ecdsa256, + 4 => GitCertificateSshRawType.ecdsa384, + 5 => GitCertificateSshRawType.ecdsa521, + 6 => GitCertificateSshRawType.ed25519, + _ => GitCertificateSshRawType.unknown, + }; + } + + /// Raw SSH host key bytes, or `null` when unavailable. + Uint8List? get rawHostkey { + if (!hasRawHostkey) { + return null; + } + + final result = bindings.hostkeyRaw(_hostkeyPointer); + return result.isEmpty ? null : result; + } +} + +/// X.509 certificate information presented by a remote. +/// +/// The object is only valid while the certificate callback is executing. +class GitCertificateX509 { + /// Initializes a [GitCertificateX509] from a libgit2 certificate pointer. + /// + /// For internal use. + @internal + const GitCertificateX509(this._certificatePointer); + + final Pointer _certificatePointer; + + /// Raw X.509 certificate bytes. + Uint8List get data => bindings.x509Data(_certificatePointer); +} diff --git a/pubspec.yaml b/pubspec.yaml index 09c4ed9..03f8a81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: git2dart description: Dart bindings to libgit2, provides ability to use libgit2 library in Dart and Flutter. repository: https://github.com/DartGit-dev/git2dart -version: 0.5.0 +version: 0.5.1 environment: sdk: ">=3.7.2 <4.0.0" @@ -13,7 +13,7 @@ dependencies: flutter: sdk: flutter - git2dart_binaries: ">=1.11.0 <1.12.0" + git2dart_binaries: ">=1.11.2 <1.12.0" meta: ^1.8.0 path: ^1.8.1 diff --git a/test/callbacks_test.dart b/test/callbacks_test.dart new file mode 100644 index 0000000..56ce53f --- /dev/null +++ b/test/callbacks_test.dart @@ -0,0 +1,119 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:git2dart/git2dart.dart'; +import 'package:git2dart_binaries/git2dart_binaries.dart'; +import 'package:test/test.dart'; + +void main() { + group('Callbacks', () { + test('initializes certificate check callback', () { + final cert = calloc(); + final callbacks = Callbacks( + certificateCheck: (certificate, host, {required valid}) { + expect(certificate, isA()); + expect(valid, isTrue); + expect(host, 'github.com'); + + return true; + }, + ); + + try { + expect( + callbacks.certificateCheck!( + GitCertificate(cert), + 'github.com', + valid: true, + ), + isTrue, + ); + } finally { + calloc.free(cert); + } + }); + + test('allows certificate check callback to reject a certificate', () { + final cert = calloc(); + final callbacks = Callbacks( + certificateCheck: (certificate, host, {required valid}) => false, + ); + + try { + expect( + callbacks.certificateCheck!( + GitCertificate(cert), + 'example.com', + valid: false, + ), + isFalse, + ); + } finally { + calloc.free(cert); + } + }); + + test('reads x509 certificate data', () { + final cert = calloc(); + final data = calloc(3); + + try { + cert.ref.parent.cert_typeAsInt = 1; + cert.ref.data = data.cast(); + cert.ref.len = 3; + data[0] = 1; + data[1] = 2; + data[2] = 3; + + final certificate = GitCertificate(cert.cast()); + + expect(certificate.type, GitCertificateType.x509); + expect(certificate.hostkey, isNull); + expect(certificate.x509!.data, [1, 2, 3]); + } finally { + calloc.free(data); + calloc.free(cert); + } + }); + + test('reads ssh hostkey hashes and raw key data', () { + final cert = calloc(); + final rawHostkey = calloc(4); + + try { + cert.ref.parent.cert_typeAsInt = 2; + cert.ref.typeAsInt = + git_cert_ssh_t.GIT_CERT_SSH_SHA256.value | + git_cert_ssh_t.GIT_CERT_SSH_RAW.value; + cert.ref.raw_typeAsInt = + git_cert_ssh_raw_type_t.GIT_CERT_SSH_RAW_TYPE_KEY_ED25519.value; + cert.ref.hostkey = rawHostkey; + cert.ref.hostkey_len = 4; + + for (var i = 0; i < 32; i++) { + cert.ref.hash_sha256[i] = i; + } + + rawHostkey[0] = 4; + rawHostkey[1] = 3; + rawHostkey[2] = 2; + rawHostkey[3] = 1; + + final certificate = GitCertificate(cert.cast()); + final hostkey = certificate.hostkey!; + + expect(certificate.type, GitCertificateType.hostkeyLibssh2); + expect(certificate.x509, isNull); + expect(hostkey.hasSha256, isTrue); + expect(hostkey.hasRawHostkey, isTrue); + expect(hostkey.hasMd5, isFalse); + expect(hostkey.rawType, GitCertificateSshRawType.ed25519); + expect(hostkey.sha256, List.generate(32, (i) => i)); + expect(hostkey.rawHostkey, [4, 3, 2, 1]); + } finally { + calloc.free(rawHostkey); + calloc.free(cert); + } + }); + }); +}