mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Signing: Ed25519 detached manifest signatures, release key, keygen/sign/verify tools; record owner decisions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e8803a067
commit
19024a3162
10 files changed
+283
-9
No files matched your search
Generated
+144
@@ -100,6 +100,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -332,10 +338,13 @@ dependencies = [
|
||||
name = "coa-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dunce",
|
||||
"ed25519-dalek",
|
||||
"fs4",
|
||||
"hex",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -370,6 +379,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
@@ -492,6 +507,33 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.24.1"
|
||||
@@ -568,6 +610,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -726,6 +778,31 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.11"
|
||||
@@ -788,6 +865,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1053,6 +1136,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -2269,6 +2363,16 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -2442,6 +2546,15 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2888,6 +3001,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -2970,6 +3092,16 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3006,6 +3138,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
@@ -4614,6 +4752,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -14,10 +14,14 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "serd
|
||||
dunce = "1"
|
||||
fs4 = "0.13"
|
||||
tracing = "0.1"
|
||||
ed25519-dalek = "2"
|
||||
base64 = "0.22"
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! One-time release-key generation. Writes the private seed to a user-only folder OUTSIDE the repository
|
||||
//! and the public key to keys/manifest-signing.pub. Refuses to overwrite an existing key.
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let private = PathBuf::from(std::env::var("USERPROFILE").expect("USERPROFILE")).join(".coa-manager/signing/manifest-signing.key");
|
||||
let public = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "keys/manifest-signing.pub".into()));
|
||||
if private.exists() || public.exists() {
|
||||
panic!("a key already exists; refusing to overwrite");
|
||||
}
|
||||
fs::create_dir_all(private.parent().unwrap()).unwrap();
|
||||
let sk = SigningKey::generate(&mut rand_core::OsRng);
|
||||
fs::write(&private, STANDARD.encode(sk.to_bytes())).unwrap();
|
||||
fs::write(&public, format!("{}\n", STANDARD.encode(sk.verifying_key().to_bytes()))).unwrap();
|
||||
println!("private key: {}\npublic key: {}", private.display(), public.display());
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Sign a manifest: `sign <manifest.json> [key-file]` -> writes <manifest.json>.sig. CI passes the key via COA_SIGNING_KEY.
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use std::fs;
|
||||
|
||||
fn main() {
|
||||
let mut a = std::env::args().skip(1);
|
||||
let manifest = a.next().expect("usage: sign <manifest.json> [key-file]");
|
||||
let seed_b64 = match std::env::var("COA_SIGNING_KEY") {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
let path = a.next().unwrap_or_else(|| format!("{}/.coa-manager/signing/manifest-signing.key", std::env::var("USERPROFILE").unwrap()));
|
||||
fs::read_to_string(path).expect("cannot read signing key")
|
||||
}
|
||||
};
|
||||
let seed: [u8; 32] = STANDARD.decode(seed_b64.trim()).unwrap().try_into().expect("key length");
|
||||
let bytes = fs::read(&manifest).unwrap();
|
||||
let sig = SigningKey::from_bytes(&seed).sign(&bytes);
|
||||
fs::write(format!("{manifest}.sig"), format!("{}\n", STANDARD.encode(sig.to_bytes()))).unwrap();
|
||||
println!("signed {manifest}");
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
fn main() {
|
||||
let m = std::env::args().nth(1).expect("usage: verify <manifest.json>");
|
||||
let bytes = std::fs::read(&m).unwrap();
|
||||
let sig = std::fs::read_to_string(format!("{m}.sig")).unwrap();
|
||||
match coa_core::signing::verify_embedded(&bytes, &sig) {
|
||||
Ok(()) => println!("signature OK"),
|
||||
Err(e) => {
|
||||
println!("FAILED: {e}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod logging;
|
||||
pub mod process;
|
||||
pub mod manifest;
|
||||
pub mod registry;
|
||||
pub mod signing;
|
||||
|
||||
pub use error::{Error, ErrorCode, Result};
|
||||
|
||||
|
||||
@@ -60,13 +60,6 @@ pub struct Revision {
|
||||
pub commit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Signature {
|
||||
pub alg: String,
|
||||
pub key_id: String,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Manifest {
|
||||
@@ -82,8 +75,6 @@ pub struct Manifest {
|
||||
pub files: Vec<FileEntry>,
|
||||
#[serde(default)]
|
||||
pub migrations: Vec<Migration>,
|
||||
#[serde(default)]
|
||||
pub signature: Option<Signature>,
|
||||
}
|
||||
|
||||
fn is_sha256(s: &str) -> bool {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Detached Ed25519 signatures over the exact bytes of `manifest.json` (`manifest.json.sig`, base64).
|
||||
//! No canonicalisation step exists to get wrong: what is downloaded is what is verified.
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Public key baked into the application (private half lives only with the release maintainer / CI secret).
|
||||
pub const EMBEDDED_PUBLIC_KEY: &str = include_str!("../../../keys/manifest-signing.pub");
|
||||
|
||||
pub fn verify(manifest_bytes: &[u8], sig_b64: &str, public_key_b64: &str) -> Result<()> {
|
||||
let bad = |m: &str| Error::InvalidManifest(format!("signature: {m}"));
|
||||
let key: [u8; 32] = STANDARD
|
||||
.decode(public_key_b64.trim())
|
||||
.map_err(|_| bad("public key is not base64"))?
|
||||
.try_into()
|
||||
.map_err(|_| bad("public key has wrong length"))?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| bad("public key is invalid"))?;
|
||||
let sig: [u8; 64] = STANDARD
|
||||
.decode(sig_b64.trim())
|
||||
.map_err(|_| bad("not base64"))?
|
||||
.try_into()
|
||||
.map_err(|_| bad("wrong length"))?;
|
||||
key.verify(manifest_bytes, &Signature::from_bytes(&sig)).map_err(|_| bad("does not match the manifest"))
|
||||
}
|
||||
|
||||
pub fn verify_embedded(manifest_bytes: &[u8], sig_b64: &str) -> Result<()> {
|
||||
verify(manifest_bytes, sig_b64, EMBEDDED_PUBLIC_KEY)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn pair() -> (SigningKey, String) {
|
||||
let sk = SigningKey::generate(&mut rand_core::OsRng);
|
||||
let pk = STANDARD.encode(sk.verifying_key().to_bytes());
|
||||
(sk, pk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_and_rejects_tampered_or_foreign() {
|
||||
let (sk, pk) = pair();
|
||||
let msg = br#"{"schema":1}"#;
|
||||
let sig = STANDARD.encode(sk.sign(msg).to_bytes());
|
||||
verify(msg, &sig, &pk).unwrap();
|
||||
assert!(verify(br#"{"schema":2}"#, &sig, &pk).is_err(), "tampered manifest");
|
||||
let (_, other_pk) = pair();
|
||||
assert!(verify(msg, &sig, &other_pk).is_err(), "different key");
|
||||
assert!(verify(msg, "not-base64!!", &pk).is_err());
|
||||
assert!(verify(msg, &sig, "AAAA").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_key_is_a_valid_public_key() {
|
||||
let raw = STANDARD.decode(EMBEDDED_PUBLIC_KEY.trim()).unwrap();
|
||||
assert_eq!(raw.len(), 32);
|
||||
VerifyingKey::from_bytes(&raw.try_into().unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -428,3 +428,23 @@ The reference install classifies as **Healthy (customised)**: repack shape, bina
|
||||
| 8 | Client detection, addon, realmlist, PLAY | Client tree hash unchanged except addon dir + realmlist |
|
||||
| 9 | Friends: net diagnostics, firewall service, Tailscale | Idempotent rule add/remove test |
|
||||
| 10 | Error translation, diagnostics, repair, a11y, self-update | Spec §61 checklist |
|
||||
|
||||
|
||||
---------------------------------------------------------------------------------------------------------
|
||||
|
||||
## 8. Decisions recorded after owner review (2026-09-30)
|
||||
|
||||
* **Core changes go through our own fork.** We are not maintainers of `jealous-sound/azerothcore-wotlk-coa`, so
|
||||
the bot-enabling core patches (and the S1/S5/S6/S7 style changes) live in a fork owned by the bot/manager
|
||||
project; the build repo pins exact fork SHAs. Upstream is tracked as a remote and merged periodically; the
|
||||
compatibility matrix is keyed by fork commit. The fork is created when Phase 6/7 needs it (nothing is pushed
|
||||
before then). This supersedes S2 (no upstream commits required) and removes risk R1.
|
||||
* **Base package is compressed and split.** Payloads are zstd/LZMA-compressed and cut into parts below the
|
||||
2 GiB GitHub asset limit (target 1.9 GB) with a parts list in the manifest; the downloader verifies each part
|
||||
and the reassembled archive. Actual ratios will be measured in Phase 5 (the packaged database already shrinks
|
||||
3.3 GB -> 141 MB; `Data\` maps/vmaps/mmaps are expected to compress far less).
|
||||
* **Manifest signing (R3 resolved).** Ed25519 key generated. Public key: `keys/manifest-signing.pub`, embedded
|
||||
in the app (`signing::EMBEDDED_PUBLIC_KEY`). Private key: `%USERPROFILE%\.coa-manager\signing\` (outside the
|
||||
repo, never committed); CI receives it as the `COA_SIGNING_KEY` secret, which the owner must add manually.
|
||||
Signatures are detached (`manifest.json.sig`, over the exact manifest bytes), so the embedded `signature`
|
||||
field in the manifest was removed. **Back up the private key** — losing it means shipping a new app to rotate.
|
||||
@@ -0,0 +1 @@
|
||||
9KFH62xKz+/EJ4lOni7FAc+w1xIIB5AZ/g2uAUp/q/k=
|
||||
Reference in new issue
Block a user