refactor: extract cert generation to core/
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-07-30 13:53:22 +02:00
parent 83e666c723
commit fb17512bdc
19 changed files with 414 additions and 223 deletions
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -30,7 +30,7 @@ class SecurityService extends ReduxNotifier<StoredSecurityContext> {
class ResetSecurityContextAction extends AsyncReduxAction<SecurityService, StoredSecurityContext> {
@override
Future<StoredSecurityContext> reduce() async {
final securityContext = generateSecurityContext();
final securityContext = await generateSecurityContext();
await notifier._persistence.setSecurityContext(securityContext);
return securityContext;
}
+7 -57
View File
@@ -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<StoredSecurityContext> 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<void> verifyCertificate({
+1 -17
View File
@@ -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:
-2
View File
@@ -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
@@ -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');
});
});
}
+1
View File
@@ -1812,6 +1812,7 @@ dependencies = [
"pem 3.0.6",
"percent-encoding",
"rand 0.9.5",
"rcgen 0.14.8",
"reqwest",
"rsa",
"rustls",
+4 -10
View File
@@ -86,20 +86,14 @@ impl Identity {
}
fn generate(alias: String, port: u16) -> anyhow::Result<Self> {
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,
})
}
+37 -4
View File
@@ -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"
+10 -4
View File
@@ -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
+85
View File
@@ -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<SelfSignedCert> {
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();
+4 -9
View File
@@ -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,
}
}
@@ -12,6 +12,10 @@ Future<void> verifyCert({required String cert, required String publicKey}) =>
Future<KeyPair> 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<SecurityContext> 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;
}
@@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
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<KeyPair> crateApiCryptoGenerateKeyPair();
Future<SecurityContext> crateApiCryptoGenerateSecurityContext();
Future<String> crateApiCryptoHashFile({String? path, int? fileDescriptor, Uint8List? bytes, required RsCancellationToken cancelToken});
Future<RsMulticast> crateApiMulticastStartMulticast({
@@ -1670,6 +1672,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [],
);
@override
Future<SecurityContext> 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<String> 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<dynamic>;
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
@@ -435,6 +435,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@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<RustLibWire> {
@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<RustLibWire> {
@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);
@@ -437,6 +437,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@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<RustLibWire> {
@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<RustLibWire> {
@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);
+37 -3
View File
@@ -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"
@@ -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<SecurityContext> {
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.
@@ -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::SseCodec, _, _>(
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 = <String>::sse_decode(deserializer);
let mut var_publicKey = <String>::sse_decode(deserializer);
let mut var_certificate = <String>::sse_decode(deserializer);
let mut var_certificateHash = <String>::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<FrbWrapper<crate::api::webrtc::RTCStatus>
}
}
// 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<crate::api::crypto::SecurityContext>
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<crate::api::server::SessionEndReasonV2> {
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) {
<String>::sse_encode(self.private_key, serializer);
<String>::sse_encode(self.public_key, serializer);
<String>::sse_encode(self.certificate, serializer);
<String>::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) {