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