diff --git a/app/lib/provider/persistence_provider.dart b/app/lib/provider/persistence_provider.dart index 47705105..1d346d26 100644 --- a/app/lib/provider/persistence_provider.dart +++ b/app/lib/provider/persistence_provider.dart @@ -166,7 +166,7 @@ class PersistenceService { } if (prefs.getString(_securityContext) == null) { - await prefs.setString(_securityContext, jsonEncode(generateSecurityContext())); + await prefs.setString(_securityContext, jsonEncode(await generateSecurityContext())); } if (isFirstAppStart) { diff --git a/app/lib/provider/security_provider.dart b/app/lib/provider/security_provider.dart index 7137763d..da7e3c8a 100644 --- a/app/lib/provider/security_provider.dart +++ b/app/lib/provider/security_provider.dart @@ -30,7 +30,7 @@ class SecurityService extends ReduxNotifier { class ResetSecurityContextAction extends AsyncReduxAction { @override Future reduce() async { - final securityContext = generateSecurityContext(); + final securityContext = await generateSecurityContext(); await notifier._persistence.setSecurityContext(securityContext); return securityContext; } diff --git a/app/lib/util/security_helper.dart b/app/lib/util/security_helper.dart index 19f4a00c..0c5ccf80 100644 --- a/app/lib/util/security_helper.dart +++ b/app/lib/util/security_helper.dart @@ -1,67 +1,17 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:basic_utils/basic_utils.dart'; -import 'package:convert/convert.dart'; import 'package:localsend_isolates/model/stored_security_context.dart'; import 'package:localsend_isolates/rust/api/crypto.dart' as rust; -/// Generates a random [SecurityContextResult]. -StoredSecurityContext generateSecurityContext([AsymmetricKeyPair? keyPair]) { - keyPair ??= CryptoUtils.generateRSAKeyPair(); - final privateKey = keyPair.privateKey as RSAPrivateKey; - final publicKey = keyPair.publicKey as RSAPublicKey; - final dn = { - 'CN': 'LocalSend User', - 'O': '', - 'OU': '', - 'L': '', - 'S': '', - 'C': '', - }; - final csr = X509Utils.generateRsaCsrPem(dn, privateKey, publicKey); - final certificate = X509Utils.generateSelfSignedCertificate(keyPair.privateKey, csr, 365 * 10); - - final hash = calculateHashOfCertificate(certificate); - final spki = extractPublicKeyFromCertificate(certificate); - +/// Generates a random [StoredSecurityContext]. +Future generateSecurityContext() async { + final result = await rust.generateSecurityContext(); return StoredSecurityContext( - privateKey: CryptoUtils.encodeRSAPrivateKeyToPemPkcs1(privateKey), - publicKey: spki, - certificate: certificate, - certificateHash: hash, + privateKey: result.privateKey, + publicKey: result.publicKey, + certificate: result.certificate, + certificateHash: result.certificateHash, ); } -/// Calculates the hash of a certificate. -String calculateHashOfCertificate(String certificate) { - // Convert PEM to DER - final pemContent = certificate.replaceAll('\r\n', '\n').split('\n').where((line) => line.isNotEmpty && !line.startsWith('---')).join(); - final der = base64Decode(pemContent); - - // Calculate hash - return CryptoUtils.getHash( - Uint8List.fromList(der), - algorithmName: 'SHA-256', - ); -} - -String extractPublicKeyFromCertificate(String certificate) { - final cert = X509Utils.x509CertificateFromPem(certificate); - final publicHex = cert.tbsCertificate!.subjectPublicKeyInfo.bytes!; - return _hexToSpkiPem(publicHex); -} - -String _hexToSpkiPem(String hexBytes) { - final publicBytes = hex.decode(hexBytes); - final publicBase64 = base64Encode(publicBytes); - final temp = - '''-----BEGIN PUBLIC KEY----- -$publicBase64 ------END PUBLIC KEY-----'''; - return X509Utils.fixPem(temp); -} - /// Verifies a certificate with a public key. /// Throws an exception if the certificate is invalid. Future verifyCertificate({ diff --git a/app/pubspec.lock b/app/pubspec.lock index c9a002a7..bdf83943 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -73,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" - basic_utils: - dependency: "direct main" - description: - name: basic_utils - sha256: "548047bef0b3b697be19fa62f46de54d99c9019a69fb7db92c69e19d87f633c7" - url: "https://pub.dev" - source: hosted - version: "5.8.2" boolean_selector: dependency: transitive description: @@ -226,7 +218,7 @@ packages: source: hosted version: "2.1.0" convert: - dependency: "direct main" + dependency: transitive description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 @@ -1263,14 +1255,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" - url: "https://pub.dev" - source: hosted - version: "4.0.0" pool: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c6e4b26c..bfc7cfad 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -10,10 +10,8 @@ environment: sdk: ^3.11.0 dependencies: - basic_utils: 5.8.2 collection: ^1.17.2 # allow newer versions, so it can compile with newer Flutter versions connectivity_plus: 7.2.0 - convert: 3.1.2 dart_mappable: 4.8.0 desktop_drop: 0.7.1 device_apps: 2.2.0 diff --git a/app/test/unit/util/security_helper_test.dart b/app/test/unit/util/security_helper_test.dart deleted file mode 100644 index 8a88ba36..00000000 --- a/app/test/unit/util/security_helper_test.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:basic_utils/basic_utils.dart'; -import 'package:localsend_app/util/security_helper.dart'; -import 'package:test/test.dart'; - -void main() { - group('generateSecurityContext', () { - // certificate is dependent on time, so we skip this test - test('should generate a security context', () { - const modulus = - '17655370025740741038113156454854199388193503449104440877037318492305460691763108986359629213738068251658702764711191809795804339624629386590749322066892437190139062703234048708020881575102122778994526543290880868262952627726488977960776519938694907994518427540419624537894956809241753063372413058294447378662255052300193831939738828105579615087656442141880311261067266950751778487270744852541404898857201772242498437323791185553734672978477936608290952211534467577849886694581791190994405933665028620559373744808123499229584489594678456882464781972729864955090923226165749030219906764402228914184366893373631422503021'; - const privateExponent = - '14421008176265737041842552728760854897987189421761902811979258986703749275840685218255600827633436556136869519473805770330945910011841184271318521744401163544644005150782334580299156378790480712915696482733480991790654395453357362699213084041660086971551428119477591606848878236042545176547425781216107887562053872086603693550866169434312772263069345523523039280590638079481520003143359443743141464111595442124530326974852967024986370324441963907065113439224776888067662971143404975436160178871895218249902778285379960904695389191962540588337134680308483675532942364276364403564632886857709821444086544738315317174145'; - const privateP = - '178524065934350447993678840182944627942309006340524319909851831752106332954026000610471033106817833873729348468467401050633468292518369706764968836071362023882930741070266054692417392199462715540565991951570137107225695543150432250384310373010291328019775467135529294964213555809388513634733256540639643918349'; - const privateQ = - '98896302486373120089171491460892017227056533661820429564718404666288200233919205658787592871460875274200632466484814562010402123837563042503616158962868253836369412258319855713924996179145010239261725685588005640861685432715579085801665010203646507394652948031357507454692937609788924433344012456182622364129'; - const publicExponent = '65537'; - - final keyPair = AsymmetricKeyPair( - RSAPublicKey( - BigInt.parse(modulus), - BigInt.parse(publicExponent), - ), - RSAPrivateKey( - BigInt.parse(modulus), - BigInt.parse(privateExponent), - BigInt.parse(privateP), - BigInt.parse(privateQ), - ), - ); - - final context = generateSecurityContext(keyPair); - expect( - context.privateKey, - '''-----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAi9uDMYRn63SZtEPGRogZGdu5XXBAoQeMO60mycoinqLKDWyZ -dpMo+XWY3wYVhoAyxgzDOcPjIf+Uq1oEy/0K4WwfpbK8SCy851qgYkfMCT9D9mFv -XwWoULJCUHFF7f947ArDE1nmuK1nNx2RodN2wJCXyzPjw0jn06bwGeg0EqfUC8wv -W4FTZ6t1tErzmRqRdMUWuCJwsk1IMbDbFePhiK5jecOBG0RVVWLuw+TkuX8TUgrp -IktH2+qEM1KdLyAMnL71hx2wMvE+lDKFKK9p37zXK8omjl+VgTC8ocjGeYDDsl43 -ZtW09V0pb7Vz2FM8b7BgM06kvJl48PIe5puYbQIDAQABAoIBAHI8hAW/TA7j9+Rp -c5wK8M3RTrCGzxN5Ec9w2Hz80ZhYCcg7S4KyI0bYCl0pIA3zywVASXp2iaEsnSf1 -aHOipe+JHLSAsRAXEtm6icSdNojbF005OvoXqer4H/iK/X6wLPpItByrUyzH3sYD -LgBLtPUHZiWBpenONCYKdpYCt/3/uyAeC4qj+sBlfN5Fio2A86qIgntg3n6he8Cd -Yq0cjyEGzRnIa9EUDHoHUU30oPPj16LkK0uZxR7H+UKZkYNXQUGtPt0cnAvgS/2f -UnOAIZop1r9NkS7sntg4l9iuDqI4xYMg9wtBStrgLx65nV1l4HDNZGq/LDo3msam -NQoeX4ECgYEA/joJk42iTF3D2IJjFzMf539V9D/1OaXcxmiLMODsIW8ZcyKRRmzD -NIBfMxX/0+X8IgkMwkcnZWOFMWw+2FDgfz2l+PMACJvGEGokxFDiJe68W4KAy8dr -uB0M0ydhRWLG4ZD6U+gvwyqqlMNpEosCH0fJilag+tW/fx6ti2MBYA0CgYEAjNVA -HItnlpnOmzOuyQob9kqE/8eDTSeX5ZFD/OZOypvLPukdg6qWnzaBzUgSJg/Tl9RD -XWE9WgKc2Gpki7/EYvtRW4YwBcoZKBTkAio4t8WBRmLT6L3PUKKdJHOtFbmwlUPw -mfw9HdJG/TgDiTlyAdTbuGhGY/unuUdPMvt2oeECgYEApthqHoeOo3XKKZbw93Hb -F3AvdhxfkVT0ftZvu0VyU0L5veFK3KBWwGcbk4h1nJjMj33G/N370gOtj1EOMaNq -ordP7QF13TB2naE7vgejU+fJgHk2lAauAGg4WX/3y7TW94TRdS3l4r1mtDlHBR9r -5iGT+JGAFv8fLYtxtA/nACUCgYAb6Gpi/bESY/pQQSaiyjEOVmgSs7uuP2lXYbkC -VbVJayQUnGdv3w8oD8obHuwRxNMeZD7RM2LQAnKIZFT2aJMHNlxB8c50Zz8i9TjV -wP4qVKYwh4cMuQhrJz5SqeWjx39ZpPP538VQsonExiPVPp/8Au1jlq5UQ9tR2PK1 -3KT+oQKBgQCkmNd8ZHKBoIbG3oV0T4f4IFk8424QD5hQX7Zmb7gxwnCgQScOPBM/ -tk4v/rwNoiO9EUW4w4zZZIlvcFJu38+9pPX+rTFxGh6TZ6aRvw7962m2RBmqgYcq -IWwwBbDLI6KuU/iqqvk/1syLDHqeaCdDqTmmyoaKKa7kUhkZkhIlLw== ------END RSA PRIVATE KEY-----''', - ); - - expect( - context.publicKey, - '''-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi9uDMYRn63SZtEPGRogZ -Gdu5XXBAoQeMO60mycoinqLKDWyZdpMo+XWY3wYVhoAyxgzDOcPjIf+Uq1oEy/0K -4WwfpbK8SCy851qgYkfMCT9D9mFvXwWoULJCUHFF7f947ArDE1nmuK1nNx2RodN2 -wJCXyzPjw0jn06bwGeg0EqfUC8wvW4FTZ6t1tErzmRqRdMUWuCJwsk1IMbDbFePh -iK5jecOBG0RVVWLuw+TkuX8TUgrpIktH2+qEM1KdLyAMnL71hx2wMvE+lDKFKK9p -37zXK8omjl+VgTC8ocjGeYDDsl43ZtW09V0pb7Vz2FM8b7BgM06kvJl48PIe5puY -bQIDAQAB ------END PUBLIC KEY-----''', - ); - }); - }); - - group('calculateHashOfCertificate', () { - test('should calculate hash of certificate', () { - const certificate = '''-----BEGIN CERTIFICATE----- -MIIDGTCCAgGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMRcwFQYDVQQDEw5Mb2Nh -bFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQLEwAxCTAHBgNVBAcTADEJMAcG -A1UECBMAMQkwBwYDVQQGEwAwHhcNMjMwNDIxMjM0NTM3WhcNMzMwNDE4MjM0NTM3 -WjBQMRcwFQYDVQQDEw5Mb2NhbFNlbmQgVXNlcjEJMAcGA1UEChMAMQkwBwYDVQQL -EwAxCTAHBgNVBAcTADEJMAcGA1UECBMAMQkwBwYDVQQGEwAwggEiMA0GCSqGSIb3 -DQEBAQUAA4IBDwAwggEKAoIBAQCL24MxhGfrdJm0Q8ZGiBkZ27ldcEChB4w7rSbJ -yiKeosoNbJl2kyj5dZjfBhWGgDLGDMM5w+Mh/5SrWgTL/QrhbB+lsrxILLznWqBi -R8wJP0P2YW9fBahQskJQcUXt/3jsCsMTWea4rWc3HZGh03bAkJfLM+PDSOfTpvAZ -6DQSp9QLzC9bgVNnq3W0SvOZGpF0xRa4InCyTUgxsNsV4+GIrmN5w4EbRFVVYu7D -5OS5fxNSCukiS0fb6oQzUp0vIAycvvWHHbAy8T6UMoUor2nfvNcryiaOX5WBMLyh -yMZ5gMOyXjdm1bT1XSlvtXPYUzxvsGAzTqS8mXjw8h7mm5htAgMBAAEwDQYJKoZI -hvcNAQELBQADggEBAIs+T8Nkbl0gecT22CKW9/jMvUS1PGAyMqlwP8fNTsyv2xE9 -hLsyUrxsscuv+HGJu6Cz1R3hLI8YY5jEShmaelI0stlLahH9Fbm43EZuadGXOVKZ -gMrNzQqLY5lec55rmS17GJlkm5opidkq4OlsCHfrBJitX6071atb0B1cdAjysWwV -x40mnwq0TmYgBLDhWaM4/ZfZQRJQpPCtBJO06Nk7gTPiqJGJU5iEaz1PLvARq69o -bJobSekf9tx3uwOIfioaoQvX0khkZ3ljFuNUpW3IE87OfPnYJQhu5xsTx00wi+Ce -x64ghD4CzRa7wYsOjeb8cUUDMSj030NO9fBGVtA= ------END CERTIFICATE-----'''; - - final hash = calculateHashOfCertificate(certificate); - expect(hash, '247E5F7CF21DE14438EAE733E07AC5440593D0612570C7413674130608DF69A9'); - }); - }); -} diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 2c96b201..d9799b4d 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -1812,6 +1812,7 @@ dependencies = [ "pem 3.0.6", "percent-encoding", "rand 0.9.5", + "rcgen 0.14.8", "reqwest", "rsa", "rustls", diff --git a/cli/src/storage/identity.rs b/cli/src/storage/identity.rs index 744706f7..a6630ede 100644 --- a/cli/src/storage/identity.rs +++ b/cli/src/storage/identity.rs @@ -86,20 +86,14 @@ impl Identity { } fn generate(alias: String, port: u16) -> anyhow::Result { - let key_pair = rcgen::KeyPair::generate()?; - let mut params = rcgen::CertificateParams::default(); - params.distinguished_name = rcgen::DistinguishedName::new(); - params - .distinguished_name - .push(rcgen::DnType::CommonName, "LocalSend User"); - let cert = params.self_signed(&key_pair)?; + let cert = localsend::crypto::cert::generate_self_signed()?; Ok(Self { alias, port, - fingerprint: fingerprint_from_cert_der(cert.der()), - cert_pem: cert.pem(), - key_pem: key_pair.serialize_pem(), + fingerprint: cert.fingerprint, + cert_pem: cert.certificate_pem, + key_pem: cert.private_key_pem, }) } diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index 98715562..58f389c5 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -190,6 +190,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -549,7 +558,7 @@ dependencies = [ "portable-atomic", "rand 0.9.2", "rand_core 0.6.4", - "rcgen", + "rcgen 0.13.2", "ring", "rustls", "sec1", @@ -1262,7 +1271,7 @@ dependencies = [ "pem", "percent-encoding", "rand 0.9.2", - "rcgen", + "rcgen 0.14.8", "reqwest", "rsa", "rustls", @@ -1756,7 +1765,21 @@ dependencies = [ "rustls-pki-types", "time", "x509-parser 0.16.0", - "yasna", + "yasna 0.5.2", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.0", + "yasna 0.6.0", ] [[package]] @@ -2908,7 +2931,7 @@ dependencies = [ "log", "portable-atomic", "rand 0.9.2", - "rcgen", + "rcgen 0.13.2", "regex", "ring", "rtcp", @@ -3418,6 +3441,16 @@ dependencies = [ "time", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 6df1ce97..6612002e 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -18,6 +18,7 @@ hyper-util = { version = "0.1.19", features = ["server"], optional = true } lru = "0.16.3" pem = { version = "3.0.6", optional = true } percent-encoding = { version = "2.3", optional = true } +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "ring"], optional = true } reqwest = { version = "0.13.1", features = ["charset", "http2", "system-proxy", "json", "rustls-no-provider", "stream", "webpki-roots"], default-features = false, optional = true } rand = "0.9.1" rsa = { version = "0.9.8", optional = true } @@ -39,14 +40,19 @@ uuid = { version = "1.20.0", features = ["serde", "v4"] } webrtc = { version = "0.14.0", optional = true } x509-parser = { version = "0.18.0", features = ["verify"], optional = true } -[dev-dependencies] -rcgen = "0.13.2" - [features] default = [] -crypto = ["ed25519-dalek", "rsa", "sha2", "tokio-util"] +crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"] http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"] multicast = ["if-addrs", "socket2", "tokio-util"] webrtc-signaling = ["tokio-tungstenite"] webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"] full = ["crypto", "http", "multicast", "webrtc"] + +# RSA key generation is bignum-heavy and takes ~10x longer unoptimized; +# keep the crypto crates optimized in dev so tests stay fast. +[profile.dev.package.rsa] +opt-level = 2 + +[profile.dev.package.num-bigint-dig] +opt-level = 2 diff --git a/packages/core/src/crypto/cert.rs b/packages/core/src/crypto/cert.rs index 99dfd1b1..e5518129 100644 --- a/packages/core/src/crypto/cert.rs +++ b/packages/core/src/crypto/cert.rs @@ -4,6 +4,57 @@ use x509_parser::certificate::X509Certificate; use x509_parser::pem::Pem; use x509_parser::x509::SubjectPublicKeyInfo; +/// A freshly generated device identity: an RSA-2048 key pair and a +/// self-signed certificate whose SHA-256 fingerprint identifies the device. +pub struct SelfSignedCert { + /// The private key, PEM-encoded (PKCS#8). + pub private_key_pem: String, + /// The public key, PEM-encoded (SPKI). + pub public_key_pem: String, + /// The self-signed certificate, PEM-encoded. + pub certificate_pem: String, + /// The SHA-256 fingerprint of the certificate in DER format, + /// encoded as uppercase hex (see [fingerprint_from_cert_der]). + pub fingerprint: String, +} + +/// Generates a new device identity, used for both the HTTP server and client +/// certificates. +/// +/// - RSA-2048, matching the certificates the Flutter app has historically +/// generated in Dart. +/// - `CN=LocalSend User` and no SANs: peers identify each other purely by the +/// certificate fingerprint, so the name carries no information. +/// - The serial number is derived from the hash of the public key +/// (rcgen's default when no serial number is set). +/// - Validity is rcgen's default (1975 to 4096), so certificates do not expire +/// in practice and never need to be rotated for time reasons. +pub fn generate_self_signed() -> anyhow::Result { + use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; + + let mut rng = rsa::rand_core::OsRng; + let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048)?; + let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF)?; + let public_key_pem = private_key + .to_public_key() + .to_public_key_pem(LineEnding::LF)?; + + let key_pair = rcgen::KeyPair::try_from(private_key.to_pkcs8_der()?.as_bytes())?; + let mut params = rcgen::CertificateParams::default(); + params.distinguished_name = rcgen::DistinguishedName::new(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "LocalSend User"); + let certificate = params.self_signed(&key_pair)?; + + Ok(SelfSignedCert { + private_key_pem: private_key_pem.to_string(), + public_key_pem, + certificate_pem: certificate.pem(), + fingerprint: fingerprint_from_cert_der(certificate.der()), + }) +} + pub fn verify_cert_from_pem(cert: String, public_key: Option<&str>) -> anyhow::Result<()> { let (cert_pem, _) = Pem::read(Cursor::new(cert.into_bytes()))?; let parsed_cert: X509Certificate = cert_pem.parse_x509()?; @@ -188,6 +239,40 @@ nidU/qXQvBJ7NPUkXXgbcgqxK735iijOqQHmKts= ); } + #[test] + fn test_generate_self_signed() { + let generated = generate_self_signed().unwrap(); + + assert!(generated + .private_key_pem + .starts_with("-----BEGIN PRIVATE KEY-----")); + assert!(generated + .public_key_pem + .starts_with("-----BEGIN PUBLIC KEY-----")); + + // The certificate is self-consistent and carries the generated public key. + verify_cert_from_pem( + generated.certificate_pem.clone(), + Some(&generated.public_key_pem), + ) + .unwrap(); + + // The fingerprint matches the DER bytes of the certificate. + let (cert_pem, _) = + Pem::read(Cursor::new(generated.certificate_pem.as_bytes().to_vec())).unwrap(); + assert_eq!( + generated.fingerprint, + fingerprint_from_cert_der(&cert_pem.contents) + ); + + // The public key extracted from the certificate is the generated one. + let extracted = public_key_from_cert_der(&cert_pem.contents).unwrap(); + assert_eq!( + extracted.replace("\r\n", "\n").trim(), + generated.public_key_pem.trim() + ); + } + #[test] fn test_fingerprint_from_cert_der() { let (cert_pem, _) = Pem::read(Cursor::new(CERT.as_bytes().to_vec())).unwrap(); diff --git a/packages/core/tests/v2_tls_pinning.rs b/packages/core/tests/v2_tls_pinning.rs index 007b3ebc..8d79693b 100644 --- a/packages/core/tests/v2_tls_pinning.rs +++ b/packages/core/tests/v2_tls_pinning.rs @@ -5,7 +5,6 @@ use bytes::Bytes; use futures_util::StreamExt; -use localsend::crypto::cert::fingerprint_from_cert_der; use localsend::http::client::{ClientError, LsHttpClientV2}; use localsend::http::dto::ProtocolType; use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, ProtocolTypeV2, RegisterDtoV2}; @@ -30,16 +29,12 @@ struct Identity { } fn generate_identity() -> Identity { - let key_pair = rcgen::KeyPair::generate().unwrap(); - let cert = rcgen::CertificateParams::new(vec!["LocalSend User".to_string()]) - .unwrap() - .self_signed(&key_pair) - .unwrap(); + let cert = localsend::crypto::cert::generate_self_signed().unwrap(); Identity { - cert: cert.pem(), - private_key: key_pair.serialize_pem(), - fingerprint: fingerprint_from_cert_der(cert.der()), + cert: cert.certificate_pem, + private_key: cert.private_key_pem, + fingerprint: cert.fingerprint, } } diff --git a/packages/localsend_isolates/lib/rust/api/crypto.dart b/packages/localsend_isolates/lib/rust/api/crypto.dart index bed0b98a..88a0ea2f 100644 --- a/packages/localsend_isolates/lib/rust/api/crypto.dart +++ b/packages/localsend_isolates/lib/rust/api/crypto.dart @@ -12,6 +12,10 @@ Future verifyCert({required String cert, required String publicKey}) => Future generateKeyPair() => RustLib.instance.api.crateApiCryptoGenerateKeyPair(); +/// Generates a new device identity: an RSA-2048 key pair and a self-signed +/// certificate whose SHA-256 fingerprint identifies the device. +Future generateSecurityContext() => RustLib.instance.api.crateApiCryptoGenerateSecurityContext(); + /// Computes the SHA-256 checksum of a file, encoded as lowercase hex. /// /// The file is read chunk by chunk, so it is never fully loaded into memory. @@ -41,3 +45,30 @@ class KeyPair { identical(this, other) || other is KeyPair && runtimeType == other.runtimeType && privateKey == other.privateKey && publicKey == other.publicKey; } + +class SecurityContext { + final String privateKey; + final String publicKey; + final String certificate; + final String certificateHash; + + const SecurityContext({ + required this.privateKey, + required this.publicKey, + required this.certificate, + required this.certificateHash, + }); + + @override + int get hashCode => privateKey.hashCode ^ publicKey.hashCode ^ certificate.hashCode ^ certificateHash.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SecurityContext && + runtimeType == other.runtimeType && + privateKey == other.privateKey && + publicKey == other.publicKey && + certificate == other.certificate && + certificateHash == other.certificateHash; +} diff --git a/packages/localsend_isolates/lib/rust/frb_generated.dart b/packages/localsend_isolates/lib/rust/frb_generated.dart index 602ab249..cd11c669 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.dart @@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 1795427439; + int get rustContentHash => -286605518; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_localsend_app', @@ -247,6 +247,8 @@ abstract class RustLibApi extends BaseApi { Future crateApiCryptoGenerateKeyPair(); + Future crateApiCryptoGenerateSecurityContext(); + Future crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}); Future crateApiMulticastStartMulticast({ @@ -1670,6 +1672,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateApiCryptoGenerateSecurityContext() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 45, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_security_context, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiCryptoGenerateSecurityContextConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiCryptoGenerateSecurityContextConstMeta => const TaskConstMeta( + debugName: 'generate_security_context', + argNames: [], + ); + @override Future crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken}) { return handler.executeNormal( @@ -1680,7 +1706,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_box_autoadd_i_32(fileDescriptor, serializer); sse_encode_opt_list_prim_u_8_strict(bytes, serializer); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken(cancelToken, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 45, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 46, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1727,7 +1753,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(fingerprint, serializer); sse_encode_protocol_type_v_2(protocol, serializer); sse_encode_bool(download, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 46, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 47, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsMulticast, @@ -1784,7 +1810,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(pin, serializer); sse_encode_opt_box_autoadd_web_send_params(webSend, serializer); sse_encode_opt_String(showToken, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 47, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 48, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer, @@ -1810,7 +1836,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(cert, serializer); sse_encode_String(publicKey, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 48, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 49, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2913,6 +2939,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + SecurityContext dco_decode_security_context(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return SecurityContext( + privateKey: dco_decode_String(arr[0]), + publicKey: dco_decode_String(arr[1]), + certificate: dco_decode_String(arr[2]), + certificateHash: dco_decode_String(arr[3]), + ); + } + @protected SessionEndReasonV2 dco_decode_session_end_reason_v_2(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -4126,6 +4165,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + SecurityContext sse_decode_security_context(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_privateKey = sse_decode_String(deserializer); + var var_publicKey = sse_decode_String(deserializer); + var var_certificate = sse_decode_String(deserializer); + var var_certificateHash = sse_decode_String(deserializer); + return SecurityContext(privateKey: var_privateKey, publicKey: var_publicKey, certificate: var_certificate, certificateHash: var_certificateHash); + } + @protected SessionEndReasonV2 sse_decode_session_end_reason_v_2(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -5335,6 +5384,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_security_context(SecurityContext self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.privateKey, serializer); + sse_encode_String(self.publicKey, serializer); + sse_encode_String(self.certificate, serializer); + sse_encode_String(self.certificateHash, serializer); + } + @protected void sse_encode_session_end_reason_v_2(SessionEndReasonV2 self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/packages/localsend_isolates/lib/rust/frb_generated.io.dart b/packages/localsend_isolates/lib/rust/frb_generated.io.dart index acd0e74d..ad20534b 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.io.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.io.dart @@ -435,6 +435,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected RTCStatus dco_decode_rtc_status(dynamic raw); + @protected + SecurityContext dco_decode_security_context(dynamic raw); + @protected SessionEndReasonV2 dco_decode_session_end_reason_v_2(dynamic raw); @@ -865,6 +868,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected RTCStatus sse_decode_rtc_status(SseDeserializer deserializer); + @protected + SecurityContext sse_decode_security_context(SseDeserializer deserializer); + @protected SessionEndReasonV2 sse_decode_session_end_reason_v_2(SseDeserializer deserializer); @@ -1341,6 +1347,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_rtc_status(RTCStatus self, SseSerializer serializer); + @protected + void sse_encode_security_context(SecurityContext self, SseSerializer serializer); + @protected void sse_encode_session_end_reason_v_2(SessionEndReasonV2 self, SseSerializer serializer); diff --git a/packages/localsend_isolates/lib/rust/frb_generated.web.dart b/packages/localsend_isolates/lib/rust/frb_generated.web.dart index 435bff9a..b775cdc9 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.web.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.web.dart @@ -437,6 +437,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected RTCStatus dco_decode_rtc_status(dynamic raw); + @protected + SecurityContext dco_decode_security_context(dynamic raw); + @protected SessionEndReasonV2 dco_decode_session_end_reason_v_2(dynamic raw); @@ -867,6 +870,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected RTCStatus sse_decode_rtc_status(SseDeserializer deserializer); + @protected + SecurityContext sse_decode_security_context(SseDeserializer deserializer); + @protected SessionEndReasonV2 sse_decode_session_end_reason_v_2(SseDeserializer deserializer); @@ -1343,6 +1349,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_rtc_status(RTCStatus self, SseSerializer serializer); + @protected + void sse_encode_security_context(SecurityContext self, SseSerializer serializer); + @protected void sse_encode_session_end_reason_v_2(SessionEndReasonV2 self, SseSerializer serializer); diff --git a/packages/localsend_isolates/rust/Cargo.lock b/packages/localsend_isolates/rust/Cargo.lock index 7829cba7..b8777153 100644 --- a/packages/localsend_isolates/rust/Cargo.lock +++ b/packages/localsend_isolates/rust/Cargo.lock @@ -255,6 +255,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -666,7 +675,7 @@ dependencies = [ "portable-atomic", "rand 0.9.5", "rand_core 0.6.4", - "rcgen", + "rcgen 0.13.2", "ring", "rustls", "sec1", @@ -1477,6 +1486,7 @@ dependencies = [ "pem", "percent-encoding", "rand 0.9.5", + "rcgen 0.14.8", "reqwest", "rsa", "rustls", @@ -2005,7 +2015,21 @@ dependencies = [ "rustls-pki-types", "time", "x509-parser 0.16.0", - "yasna", + "yasna 0.5.2", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna 0.6.0", ] [[package]] @@ -3194,7 +3218,7 @@ dependencies = [ "log", "portable-atomic", "rand 0.9.5", - "rcgen", + "rcgen 0.13.2", "regex", "ring", "rtcp", @@ -3630,6 +3654,16 @@ dependencies = [ "time", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/packages/localsend_isolates/rust/src/api/crypto.rs b/packages/localsend_isolates/rust/src/api/crypto.rs index 2488cee2..04418887 100644 --- a/packages/localsend_isolates/rust/src/api/crypto.rs +++ b/packages/localsend_isolates/rust/src/api/crypto.rs @@ -20,6 +20,26 @@ pub struct KeyPair { pub public_key: String, } +/// Generates a new device identity: an RSA-2048 key pair and a self-signed +/// certificate whose SHA-256 fingerprint identifies the device. +pub fn generate_security_context() -> anyhow::Result { + let cert = localsend::crypto::cert::generate_self_signed()?; + + Ok(SecurityContext { + private_key: cert.private_key_pem, + public_key: cert.public_key_pem, + certificate: cert.certificate_pem, + certificate_hash: cert.fingerprint, + }) +} + +pub struct SecurityContext { + pub private_key: String, + pub public_key: String, + pub certificate: String, + pub certificate_hash: String, +} + /// Computes the SHA-256 checksum of a file, encoded as lowercase hex. /// /// The file is read chunk by chunk, so it is never fully loaded into memory. diff --git a/packages/localsend_isolates/rust/src/frb_generated.rs b/packages/localsend_isolates/rust/src/frb_generated.rs index 969d05fb..6511aa19 100644 --- a/packages/localsend_isolates/rust/src/frb_generated.rs +++ b/packages/localsend_isolates/rust/src/frb_generated.rs @@ -44,7 +44,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1795427439; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -286605518; // Section: executor @@ -2638,6 +2638,40 @@ fn wire__crate__api__crypto__generate_key_pair_impl( }, ) } +fn wire__crate__api__crypto__generate_security_context_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "generate_security_context", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || { + let output_ok = crate::api::crypto::generate_security_context()?; + Ok(output_ok) + })(), + ) + } + }, + ) +} fn wire__crate__api__crypto__hash_file_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4262,6 +4296,22 @@ impl SseDecode for crate::api::webrtc::RTCStatus { } } +impl SseDecode for crate::api::crypto::SecurityContext { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_privateKey = ::sse_decode(deserializer); + let mut var_publicKey = ::sse_decode(deserializer); + let mut var_certificate = ::sse_decode(deserializer); + let mut var_certificateHash = ::sse_decode(deserializer); + return crate::api::crypto::SecurityContext { + private_key: var_privateKey, + public_key: var_publicKey, + certificate: var_certificate, + certificate_hash: var_certificateHash, + }; + } +} + impl SseDecode for crate::api::server::SessionEndReasonV2 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -4622,10 +4672,16 @@ fn pde_ffi_dispatcher_primary_impl( wire__crate__api__logging__enable_debug_logging_impl(port, ptr, rust_vec_len, data_len) } 44 => wire__crate__api__crypto__generate_key_pair_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__crypto__hash_file_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__multicast__start_multicast_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__crypto__generate_security_context_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 46 => wire__crate__api__crypto__hash_file_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__multicast__start_multicast_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__server__start_server_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__crypto__verify_cert_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5516,6 +5572,29 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::crypto::SecurityContext { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.private_key.into_into_dart().into_dart(), + self.public_key.into_into_dart().into_dart(), + self.certificate.into_into_dart().into_dart(), + self.certificate_hash.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::crypto::SecurityContext +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::crypto::SecurityContext +{ + fn into_into_dart(self) -> crate::api::crypto::SecurityContext { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for FrbWrapper { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self.0 { @@ -6669,6 +6748,16 @@ impl SseEncode for crate::api::webrtc::RTCStatus { } } +impl SseEncode for crate::api::crypto::SecurityContext { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.private_key, serializer); + ::sse_encode(self.public_key, serializer); + ::sse_encode(self.certificate, serializer); + ::sse_encode(self.certificate_hash, serializer); + } +} + impl SseEncode for crate::api::server::SessionEndReasonV2 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {