1use crate::UpdatePackageInfo;
7use crate::error::UpdateError;
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
11use lingxia_app_context::{AppEnv, env};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::fs;
15use std::path::Path;
16
17const MAX_SIGNED_BYTES: usize = 8 * 1024;
18const MAX_SIGNATURES: usize = 2;
19const MAX_PUBLIC_KEYS: usize = 2;
20
21#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct UpdateAuthentication {
30 pub signed: String,
31 pub signatures: Vec<String>,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct SignRequest<'a> {
36 pub kind: &'a str,
37 pub target_id: &'a str,
38 pub channel: &'a str,
40 pub platform: &'a str,
41 pub version: &'a str,
42 pub sha256: &'a str,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct UpdateVerifyTarget {
47 pub kind: String,
48 pub target_id: String,
49 pub channel: String,
50 pub platform: String,
51 pub exact_version: Option<String>,
52}
53
54#[derive(Serialize, Deserialize)]
55struct ManifestWire {
56 v: u32,
57 kind: String,
58 #[serde(rename = "targetId")]
59 target_id: String,
60 channel: String,
61 platform: String,
62 version: String,
63 sha256: String,
64}
65
66pub fn archive_sha256_hex(data: &[u8]) -> String {
67 let digest = Sha256::digest(data);
68 let mut out = String::with_capacity(digest.len() * 2);
69 const HEX: &[u8; 16] = b"0123456789abcdef";
70 for byte in digest {
71 out.push(HEX[(byte >> 4) as usize] as char);
72 out.push(HEX[(byte & 0x0f) as usize] as char);
73 }
74 out
75}
76
77pub fn encode_base64url(bytes: &[u8]) -> String {
78 URL_SAFE_NO_PAD.encode(bytes)
79}
80
81pub fn decode_base64url(value: &str) -> Result<Vec<u8>, UpdateError> {
82 URL_SAFE_NO_PAD
83 .decode(value.trim().as_bytes())
84 .map_err(|e| UpdateError::invalid_parameter(format!("invalid base64url: {e}")))
85}
86
87pub fn compact_manifest(req: &SignRequest<'_>) -> Result<Vec<u8>, UpdateError> {
88 let wire = ManifestWire {
89 v: 1,
90 kind: req.kind.to_string(),
91 target_id: req.target_id.to_string(),
92 channel: req.channel.to_string(),
93 platform: req.platform.to_string(),
94 version: req.version.to_string(),
95 sha256: req.sha256.to_string(),
96 };
97 serde_json::to_vec(&wire)
98 .map_err(|e| UpdateError::runtime(format!("encode update manifest: {e}")))
99}
100
101pub fn public_key_base64url(seed: &[u8; 32]) -> String {
102 encode_base64url(SigningKey::from_bytes(seed).verifying_key().as_bytes())
103}
104
105pub fn load_signing_seed_file(path: &Path) -> Result<[u8; 32], UpdateError> {
106 #[cfg(unix)]
107 {
108 use std::os::unix::fs::PermissionsExt;
109 let mode = fs::metadata(path)
110 .map_err(|e| UpdateError::io(format!("read signing key {}: {e}", path.display())))?
111 .permissions()
112 .mode()
113 & 0o777;
114 if mode & 0o077 != 0 {
115 return Err(UpdateError::invalid_parameter(format!(
116 "signing key {} must not be group/other-accessible",
117 path.display()
118 )));
119 }
120 }
121 let text = fs::read_to_string(path)
122 .map_err(|e| UpdateError::io(format!("read signing key {}: {e}", path.display())))?;
123 let bytes = decode_base64url(text.trim())?;
124 bytes
125 .try_into()
126 .map_err(|_| UpdateError::invalid_parameter("signing key must be a 32-byte Ed25519 seed"))
127}
128
129pub fn sign_package(
130 seed: &[u8; 32],
131 req: &SignRequest<'_>,
132) -> Result<UpdateAuthentication, UpdateError> {
133 let manifest = compact_manifest(req)?;
134 let signature = SigningKey::from_bytes(seed).sign(&manifest).to_bytes();
135 Ok(UpdateAuthentication {
136 signed: encode_base64url(&manifest),
137 signatures: vec![encode_base64url(&signature)],
138 })
139}
140
141pub fn env_requires_signature(env: AppEnv) -> bool {
142 env == AppEnv::Prod
143}
144
145pub fn host_requires_signature() -> bool {
149 env_requires_signature(env())
150}
151
152pub fn check_update_enabled(trusted_public_keys: &[String]) -> bool {
155 !host_requires_signature() || !trusted_public_keys.is_empty()
156}
157
158pub fn sign_package_from_key_file(
159 env: AppEnv,
160 key_file: Option<&Path>,
161 req: &SignRequest<'_>,
162) -> Result<Option<UpdateAuthentication>, UpdateError> {
163 let key_file = key_file.filter(|path| !path.as_os_str().is_empty());
164 match (env_requires_signature(env), key_file) {
165 (true, None) => Err(UpdateError::invalid_parameter(format!(
166 "{env} publish requires --update-signing-key-file"
167 ))),
168 (false, None) => Ok(None),
169 (_, Some(path)) => {
170 let seed = load_signing_seed_file(path)?;
171 sign_package(&seed, req).map(Some)
172 }
173 }
174}
175
176pub fn verify_checked_update(
177 mut package: UpdatePackageInfo,
178 target: &UpdateVerifyTarget,
179 trusted_public_keys: &[String],
180) -> Result<UpdatePackageInfo, UpdateError> {
181 let signature_required = host_requires_signature();
191 if trusted_public_keys.is_empty() && !signature_required {
192 return Ok(package);
193 }
194
195 let Some(auth) = package.authentication.as_ref() else {
196 if signature_required {
197 return Err(UpdateError::invalid_parameter(format!(
198 "{} builds require signed updates ({} channel package is unsigned)",
199 env(),
200 target.channel
201 )));
202 }
203 return Ok(package);
204 };
205
206 if auth.signatures.is_empty() || auth.signatures.len() > MAX_SIGNATURES {
207 return Err(UpdateError::invalid_parameter(
208 "authentication must include 1 or 2 signatures",
209 ));
210 }
211 if trusted_public_keys.is_empty() {
212 return Err(UpdateError::invalid_parameter(
213 "no trusted update public keys are embedded in this build",
214 ));
215 }
216 if trusted_public_keys.len() > MAX_PUBLIC_KEYS {
217 return Err(UpdateError::invalid_parameter(
218 "at most two trusted update public keys are allowed",
219 ));
220 }
221
222 let signed = decode_base64url(&auth.signed)?;
223 if signed.len() > MAX_SIGNED_BYTES {
224 return Err(UpdateError::invalid_parameter(
225 "signed manifest is too large",
226 ));
227 }
228 let keys = decode_public_keys(trusted_public_keys)?;
229 let mut accepted = false;
230 for signature_b64 in &auth.signatures {
231 let signature = decode_signature(signature_b64)?;
232 if keys
233 .iter()
234 .any(|key| key.verify_strict(&signed, &signature).is_ok())
235 {
236 accepted = true;
237 break;
238 }
239 }
240 if !accepted {
241 return Err(UpdateError::invalid_parameter(
242 "update authentication signature is invalid",
243 ));
244 }
245
246 let manifest: ManifestWire = serde_json::from_slice(&signed)
247 .map_err(|e| UpdateError::invalid_parameter(format!("signed manifest is not JSON: {e}")))?;
248 bind_manifest(&manifest, target)?;
249
250 package.version = manifest.version;
251 package.checksum_sha256 = manifest.sha256;
252 Ok(package)
253}
254
255pub fn verify_archive_bytes(data: &[u8], expected_sha256: &str) -> Result<(), UpdateError> {
256 let actual = archive_sha256_hex(data);
257 if actual != expected_sha256 {
258 return Err(UpdateError::invalid_parameter(format!(
259 "archive sha256 mismatch: expected {expected_sha256}, got {actual}"
260 )));
261 }
262 Ok(())
263}
264
265pub fn embedded_update_public_keys() -> Vec<String> {
266 lingxia_app_context::app_config()
267 .map(|config| config.update_trusted_public_keys.clone())
268 .unwrap_or_default()
269}
270
271pub fn host_update_platform() -> &'static str {
272 if cfg!(target_os = "android") {
273 "android"
274 } else if cfg!(target_os = "macos") {
275 "macos"
276 } else if cfg!(target_os = "windows") {
277 "windows"
278 } else if cfg!(target_os = "ios") {
279 "ios"
280 } else if cfg!(all(target_os = "linux", target_env = "ohos")) {
281 "harmony"
282 } else {
283 "any"
284 }
285}
286
287fn bind_manifest(manifest: &ManifestWire, target: &UpdateVerifyTarget) -> Result<(), UpdateError> {
288 if manifest.v != 1 {
289 return Err(UpdateError::invalid_parameter(
290 "signed manifest v must be 1",
291 ));
292 }
293 if manifest.kind != target.kind {
294 return Err(UpdateError::invalid_parameter(
295 "signed kind does not match target",
296 ));
297 }
298 if manifest.target_id != target.target_id {
299 return Err(UpdateError::invalid_parameter(
300 "signed targetId does not match target",
301 ));
302 }
303 if manifest.channel != target.channel {
304 return Err(UpdateError::invalid_parameter(
305 "signed channel does not match target",
306 ));
307 }
308 if manifest.platform != target.platform {
309 return Err(UpdateError::invalid_parameter(
310 "signed platform does not match target",
311 ));
312 }
313 if let Some(expected) = target.exact_version.as_deref()
314 && manifest.version != expected
315 {
316 return Err(UpdateError::invalid_parameter(
317 "signed version does not match requested targetVersion",
318 ));
319 }
320 Ok(())
321}
322
323fn decode_public_keys(keys: &[String]) -> Result<Vec<VerifyingKey>, UpdateError> {
324 keys.iter()
325 .map(|key| {
326 let bytes = decode_fixed(key, 32, "public key")?;
327 let raw: [u8; 32] = bytes
328 .try_into()
329 .map_err(|_| UpdateError::invalid_parameter("public key must be 32 bytes"))?;
330 VerifyingKey::from_bytes(&raw).map_err(|e| {
331 UpdateError::invalid_parameter(format!("invalid Ed25519 public key: {e}"))
332 })
333 })
334 .collect()
335}
336
337fn decode_signature(value: &str) -> Result<Signature, UpdateError> {
338 let bytes = decode_fixed(value, 64, "signature")?;
339 let raw: [u8; 64] = bytes
340 .try_into()
341 .map_err(|_| UpdateError::invalid_parameter("signature must be 64 bytes"))?;
342 Ok(Signature::from_bytes(&raw))
343}
344
345fn decode_fixed(value: &str, expected_len: usize, label: &str) -> Result<Vec<u8>, UpdateError> {
346 let bytes = decode_base64url(value)?;
347 if bytes.len() != expected_len {
348 return Err(UpdateError::invalid_parameter(format!(
349 "{label} must be {expected_len} bytes"
350 )));
351 }
352 Ok(bytes)
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 const SEED: [u8; 32] = [7u8; 32];
360 const ARCHIVE: &[u8] = b"lingxia-update-golden-archive";
361
362 fn request(sha256: &str) -> SignRequest<'_> {
363 SignRequest {
364 kind: "lxapp",
365 target_id: "shop",
366 channel: "release",
367 platform: "any",
368 version: "1.2.3",
369 sha256,
370 }
371 }
372
373 fn target() -> UpdateVerifyTarget {
374 UpdateVerifyTarget {
375 kind: "lxapp".into(),
376 target_id: "shop".into(),
377 channel: "release".into(),
378 platform: "any".into(),
379 exact_version: None,
380 }
381 }
382
383 fn package(auth: Option<UpdateAuthentication>, sha256: &str, size: u64) -> UpdatePackageInfo {
384 UpdatePackageInfo {
385 version: "1.2.3".into(),
386 url: "https://cdn.example.com/pkg".into(),
387 checksum_sha256: sha256.into(),
388 size: Some(size),
389 release_notes: None,
390 min_runtime: None,
391 authentication: auth,
392 }
393 }
394
395 #[test]
396 fn matching_key_accepts_signed_artifact() {
397 let sha256 = archive_sha256_hex(ARCHIVE);
398 let size = ARCHIVE.len() as u64;
399 let auth = sign_package(&SEED, &request(&sha256)).unwrap();
400 let keys = [public_key_base64url(&SEED)];
401 let verified =
402 verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).unwrap();
403 assert_eq!(verified.checksum_sha256, sha256);
404 verify_archive_bytes(ARCHIVE, &verified.checksum_sha256).unwrap();
405 }
406
407 #[test]
408 fn test_seed_public_key_is_stable() {
409 assert_eq!(
410 public_key_base64url(&SEED),
411 "6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw"
412 );
413 }
414
415 #[test]
416 fn flipped_archive_byte_rejects() {
417 let sha256 = archive_sha256_hex(ARCHIVE);
418 let size = ARCHIVE.len() as u64;
419 let auth = sign_package(&SEED, &request(&sha256)).unwrap();
420 let keys = [public_key_base64url(&SEED)];
421 let verified =
422 verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).unwrap();
423 let mut tampered = ARCHIVE.to_vec();
424 tampered[0] ^= 0xff;
425 assert!(verify_archive_bytes(&tampered, &verified.checksum_sha256).is_err());
426 }
427
428 #[test]
429 fn flipped_signed_json_field_rejects() {
430 let sha256 = archive_sha256_hex(ARCHIVE);
431 let size = ARCHIVE.len() as u64;
432 let mut auth = sign_package(&SEED, &request(&sha256)).unwrap();
433 let original = decode_base64url(&auth.signed).unwrap();
434 let mut value: serde_json::Value = serde_json::from_slice(&original).unwrap();
435 value["version"] = serde_json::json!("9.9.9");
436 auth.signed = encode_base64url(&serde_json::to_vec(&value).unwrap());
437 let keys = [public_key_base64url(&SEED)];
438 assert!(
439 verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).is_err()
440 );
441 }
442
443 #[test]
444 fn flipped_signature_rejects() {
445 let sha256 = archive_sha256_hex(ARCHIVE);
446 let size = ARCHIVE.len() as u64;
447 let mut auth = sign_package(&SEED, &request(&sha256)).unwrap();
448 let mut sig = decode_base64url(&auth.signatures[0]).unwrap();
449 sig[0] ^= 0xff;
450 auth.signatures[0] = encode_base64url(&sig);
451 let keys = [public_key_base64url(&SEED)];
452 assert!(
453 verify_checked_update(package(Some(auth), &sha256, size), &target(), &keys).is_err()
454 );
455 }
456
457 #[test]
458 fn release_unsigned_rejects() {
459 let sha256 = archive_sha256_hex(ARCHIVE);
460 let size = ARCHIVE.len() as u64;
461 let err = verify_checked_update(package(None, &sha256, size), &target(), &[]).unwrap_err();
462 assert!(err.to_string().contains("require signed updates"), "{err}");
463 }
464
465 #[test]
466 fn publishing_requires_a_signature_in_prod() {
467 assert!(!env_requires_signature(AppEnv::Dev));
468 assert!(env_requires_signature(AppEnv::Prod));
469 }
470
471 #[test]
472 fn check_update_enabled_follows_this_build_and_its_keys() {
473 assert!(host_requires_signature());
477 assert!(!check_update_enabled(&[]));
478 assert!(check_update_enabled(&[public_key_base64url(&SEED)]));
479 }
480
481 #[test]
482 fn asking_for_the_draft_channel_does_not_waive_a_prod_build() {
483 let sha256 = archive_sha256_hex(ARCHIVE);
488 let size = ARCHIVE.len() as u64;
489 let mut dev = target();
490 dev.channel = "draft".into();
491
492 let err = verify_checked_update(package(None, &sha256, size), &dev, &[]).unwrap_err();
493 assert!(err.to_string().contains("require signed updates"), "{err}");
494
495 let keys = [public_key_base64url(&SEED)];
497 assert!(verify_checked_update(package(None, &sha256, size), &dev, &keys).is_err());
498 }
499
500 #[test]
501 fn a_prod_build_opens_a_draft_lxapp_only_when_it_is_signed_for_it() {
502 let sha256 = archive_sha256_hex(ARCHIVE);
506 let size = ARCHIVE.len() as u64;
507 let mut dev = target();
508 dev.channel = "draft".into();
509 let mut req = request(&sha256);
510 req.channel = "draft";
511 let auth = sign_package(&SEED, &req).unwrap();
512 let keys = [public_key_base64url(&SEED)];
513 verify_checked_update(package(Some(auth), &sha256, size), &dev, &keys).unwrap();
514
515 let release_auth = sign_package(&SEED, &request(&sha256)).unwrap();
517 assert!(
518 verify_checked_update(package(Some(release_auth), &sha256, size), &dev, &keys).is_err()
519 );
520 }
521
522 #[test]
523 fn dev_env_allows_unsigned_updates() {
524 assert!(!env_requires_signature(AppEnv::Dev));
525 }
526
527 #[test]
528 fn draft_invalid_envelope_rejects() {
529 let sha256 = archive_sha256_hex(ARCHIVE);
530 let size = ARCHIVE.len() as u64;
531 let mut auth = sign_package(&SEED, &request(&sha256)).unwrap();
532 auth.signatures[0] = encode_base64url(&[0u8; 64]);
533 let mut dev = target();
534 dev.channel = "draft".into();
535 let keys = [public_key_base64url(&SEED)];
536 assert!(verify_checked_update(package(Some(auth), &sha256, size), &dev, &keys).is_err());
537 }
538
539 #[test]
540 fn prod_publish_without_key_file_fails() {
541 let sha256 = archive_sha256_hex(ARCHIVE);
542 let err = sign_package_from_key_file(AppEnv::Prod, None, &request(&sha256)).unwrap_err();
543 assert!(err.to_string().contains("--update-signing-key-file"));
544 }
545
546 #[test]
547 fn prod_draft_publish_without_key_file_fails() {
548 let sha256 = archive_sha256_hex(ARCHIVE);
549 let mut req = request(&sha256);
550 req.channel = "draft";
551 let err = sign_package_from_key_file(AppEnv::Prod, None, &req).unwrap_err();
552 assert!(err.to_string().contains("prod publish"));
553 }
554
555 #[test]
556 fn dev_publish_without_key_file_is_unsigned() {
557 let sha256 = archive_sha256_hex(ARCHIVE);
558 assert!(
559 sign_package_from_key_file(AppEnv::Dev, None, &request(&sha256),)
560 .unwrap()
561 .is_none()
562 );
563 }
564}