feat(trust): verify release-root signature on the OTA manifest
check_for_updates now fetches the manifest as raw JSON and runs trust::verify_detached before parsing: a tampered or wrong-signer signature rejects the mirror outright, and unsigned manifests are offered for MANUAL apply only — the 3 AM auto-apply scheduler refuses them, closing the unattended remote-root hole (§A of the 1.8.0 hardening plan). UpdateState gains manifest_signed so the UI can surface authenticity. Publisher side: create-release.sh signs the manifest during the release (ceremony, mnemonic via TTY/env only), publish-release-assets hard-refuses to ship an unsigned manifest (grep + new 'ceremony verify' cryptographic gate), and scripts/sign-manifest.sh covers re-signing outside a release run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1977bdefb5
commit
51647b21cd
@ -19,6 +19,11 @@
|
|||||||
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
|
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
|
||||||
//! `signature` + `signed_by` over the canonical form, matching exactly
|
//! `signature` + `signed_by` over the canonical form, matching exactly
|
||||||
//! what `trust::verify_detached` recomputes on every node.
|
//! what `trust::verify_detached` recomputes on every node.
|
||||||
|
//!
|
||||||
|
//! archipelago ceremony verify <file.json>
|
||||||
|
//! Verify a signed JSON document against the compiled-in release-root
|
||||||
|
//! anchor. Exits non-zero unless the signature verifies AND the signer
|
||||||
|
//! is the pinned anchor. Needs no mnemonic — used as the publish gate.
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
@ -47,9 +52,15 @@ pub fn run() -> Result<()> {
|
|||||||
.context("usage: archipelago ceremony sign <file.json>")?;
|
.context("usage: archipelago ceremony sign <file.json>")?;
|
||||||
cmd_sign(&file)
|
cmd_sign(&file)
|
||||||
}
|
}
|
||||||
|
"verify" => {
|
||||||
|
let file = std::env::args()
|
||||||
|
.nth(3)
|
||||||
|
.context("usage: archipelago ceremony verify <file.json>")?;
|
||||||
|
cmd_verify(&file)
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
bail!(
|
bail!(
|
||||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
|
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file> | verify <file>",
|
||||||
other
|
other
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -107,6 +118,33 @@ fn cmd_sign(path: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_verify(path: &str) -> Result<()> {
|
||||||
|
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
|
||||||
|
match signed_doc::verify_detached(&value)? {
|
||||||
|
signed_doc::SignatureStatus::Verified {
|
||||||
|
signer_did,
|
||||||
|
anchored: true,
|
||||||
|
} => {
|
||||||
|
eprintln!("✓ {path} verified — signed by the pinned release root");
|
||||||
|
eprintln!(" signed_by: {signer_did}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
signed_doc::SignatureStatus::Verified {
|
||||||
|
signer_did,
|
||||||
|
anchored: false,
|
||||||
|
} => {
|
||||||
|
// Only reachable if no anchor is compiled in/overridden — the
|
||||||
|
// signature is self-consistent but proves nothing about identity.
|
||||||
|
bail!("{path} signed by {signer_did}, but no release-root anchor is pinned to compare against")
|
||||||
|
}
|
||||||
|
signed_doc::SignatureStatus::Unsigned => {
|
||||||
|
bail!("{path} is NOT signed (no `signature` field)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
/// Derive the release-root signing key from the mnemonic in env/stdin.
|
||||||
fn load_release_root_key() -> Result<SigningKey> {
|
fn load_release_root_key() -> Result<SigningKey> {
|
||||||
let phrase = read_mnemonic()?;
|
let phrase = read_mnemonic()?;
|
||||||
|
|||||||
@ -338,6 +338,26 @@ fn rewrite_manifest_origins(manifest: &mut UpdateManifest, manifest_url: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a fetched manifest body, verifying its detached release-root
|
||||||
|
/// signature if one is present. Returns the manifest plus whether it was
|
||||||
|
/// signed by the pinned release-root anchor.
|
||||||
|
///
|
||||||
|
/// * Present-but-invalid signature (tampered payload, wrong signer, bad
|
||||||
|
/// hex…) → `Err`; the caller treats the mirror as failed. This is the
|
||||||
|
/// teeth: a MITM can strip the signature but can never forge one.
|
||||||
|
/// * Unsigned → accepted with `signed == false` during the migration
|
||||||
|
/// window; the scheduler refuses to auto-apply such manifests and the
|
||||||
|
/// state surfaces `manifest_signed` to the UI.
|
||||||
|
fn parse_and_verify_manifest(raw: serde_json::Value) -> Result<(UpdateManifest, bool)> {
|
||||||
|
let signed = match crate::trust::verify_detached(&raw).context("manifest signature")? {
|
||||||
|
crate::trust::SignatureStatus::Verified { anchored, .. } => anchored,
|
||||||
|
crate::trust::SignatureStatus::Unsigned => false,
|
||||||
|
};
|
||||||
|
let manifest: UpdateManifest =
|
||||||
|
serde_json::from_value(raw).context("parse update manifest")?;
|
||||||
|
Ok((manifest, signed))
|
||||||
|
}
|
||||||
|
|
||||||
/// Which manifest URL to try FIRST — operator override via env wins,
|
/// Which manifest URL to try FIRST — operator override via env wins,
|
||||||
/// otherwise the first entry in the mirrors list, otherwise the hard
|
/// otherwise the first entry in the mirrors list, otherwise the hard
|
||||||
/// default. Callers that need the full mirror walk should use
|
/// default. Callers that need the full mirror walk should use
|
||||||
@ -394,6 +414,14 @@ pub struct UpdateState {
|
|||||||
/// their node actually hit (vs. just which is configured primary).
|
/// their node actually hit (vs. just which is configured primary).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manifest_mirror: Option<String>,
|
pub manifest_mirror: Option<String>,
|
||||||
|
/// True when `available_update` came from a manifest whose detached
|
||||||
|
/// Ed25519 signature verified against the pinned release-root anchor
|
||||||
|
/// (`trust::verify_detached`). Unsigned manifests are still offered for
|
||||||
|
/// MANUAL apply during the signing-migration window, but the scheduler
|
||||||
|
/// refuses to auto-apply them — otherwise a mirror/MITM could push an
|
||||||
|
/// arbitrary root binary to the fleet unattended.
|
||||||
|
#[serde(default)]
|
||||||
|
pub manifest_signed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UpdateState {
|
impl Default for UpdateState {
|
||||||
@ -406,6 +434,7 @@ impl Default for UpdateState {
|
|||||||
rollback_available: false,
|
rollback_available: false,
|
||||||
schedule: UpdateSchedule::DailyCheck,
|
schedule: UpdateSchedule::DailyCheck,
|
||||||
manifest_mirror: None,
|
manifest_mirror: None,
|
||||||
|
manifest_signed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -621,6 +650,7 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
|||||||
// if there's genuinely something newer.
|
// if there's genuinely something newer.
|
||||||
state.available_update = None;
|
state.available_update = None;
|
||||||
state.manifest_mirror = None;
|
state.manifest_mirror = None;
|
||||||
|
state.manifest_signed = false;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -710,19 +740,33 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
|||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
}
|
}
|
||||||
match client.get(manifest_url).send().await {
|
match client.get(manifest_url).send().await {
|
||||||
Ok(resp) if resp.status().is_success() => match resp.json::<UpdateManifest>().await
|
Ok(resp) if resp.status().is_success() => match resp
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::from)
|
||||||
|
.and_then(parse_and_verify_manifest)
|
||||||
{
|
{
|
||||||
Ok(mut manifest) => {
|
Ok((mut manifest, signed)) => {
|
||||||
rewrite_manifest_origins(&mut manifest, manifest_url);
|
rewrite_manifest_origins(&mut manifest, manifest_url);
|
||||||
if is_newer(&manifest.version, &state.current_version) {
|
if is_newer(&manifest.version, &state.current_version) {
|
||||||
|
if !signed {
|
||||||
|
warn!(
|
||||||
|
available = %manifest.version,
|
||||||
|
mirror = %manifest_url,
|
||||||
|
"Update manifest is NOT signed by the release root — \
|
||||||
|
offering for manual apply only; auto-apply is refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
info!(
|
info!(
|
||||||
current = %state.current_version,
|
current = %state.current_version,
|
||||||
available = %manifest.version,
|
available = %manifest.version,
|
||||||
mirror = %manifest_url,
|
mirror = %manifest_url,
|
||||||
|
signed,
|
||||||
"Update available"
|
"Update available"
|
||||||
);
|
);
|
||||||
state.available_update = Some(manifest);
|
state.available_update = Some(manifest);
|
||||||
state.manifest_mirror = Some(manifest_url.clone());
|
state.manifest_mirror = Some(manifest_url.clone());
|
||||||
|
state.manifest_signed = signed;
|
||||||
} else {
|
} else {
|
||||||
// Manifest version matches us or is behind
|
// Manifest version matches us or is behind
|
||||||
// us — either we're current, or this mirror
|
// us — either we're current, or this mirror
|
||||||
@ -737,6 +781,7 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
|||||||
);
|
);
|
||||||
state.manifest_mirror = None;
|
state.manifest_mirror = None;
|
||||||
state.available_update = None;
|
state.available_update = None;
|
||||||
|
state.manifest_signed = false;
|
||||||
handled = true;
|
handled = true;
|
||||||
continue 'mirrors;
|
continue 'mirrors;
|
||||||
}
|
}
|
||||||
@ -1846,6 +1891,23 @@ pub async fn run_update_scheduler(
|
|||||||
info!("Update scheduler: 3 AM auto-apply window");
|
info!("Update scheduler: 3 AM auto-apply window");
|
||||||
match check_for_updates(&data_dir).await {
|
match check_for_updates(&data_dir).await {
|
||||||
Ok(s) if s.available_update.is_some() => {
|
Ok(s) if s.available_update.is_some() => {
|
||||||
|
if !s.manifest_signed {
|
||||||
|
// Unattended apply of an unauthenticated manifest is
|
||||||
|
// the §A supply-chain hole: a mirror/MITM could ship
|
||||||
|
// an arbitrary root binary fleet-wide at 3 AM. Manual
|
||||||
|
// apply from the UI remains possible during the
|
||||||
|
// signing-migration window; auto-apply does not.
|
||||||
|
warn!(
|
||||||
|
available = %s
|
||||||
|
.available_update
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.version.as_str())
|
||||||
|
.unwrap_or("?"),
|
||||||
|
"Update scheduler: manifest is not signed by the \
|
||||||
|
release root — refusing unattended auto-apply"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
info!("Update scheduler: downloading update");
|
info!("Update scheduler: downloading update");
|
||||||
if let Err(e) = download_update(&data_dir).await {
|
if let Err(e) = download_update(&data_dir).await {
|
||||||
debug!("Update scheduler: download failed: {}", e);
|
debug!("Update scheduler: download failed: {}", e);
|
||||||
@ -1884,6 +1946,68 @@ pub async fn run_update_scheduler(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_manifest_value() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"version": "9.9.9",
|
||||||
|
"release_date": "2026-07-02",
|
||||||
|
"changelog": ["test release"],
|
||||||
|
"components": [{
|
||||||
|
"name": "archipelago",
|
||||||
|
"current_version": "0.0.0",
|
||||||
|
"new_version": "9.9.9",
|
||||||
|
"download_url": "http://example.invalid/archipelago",
|
||||||
|
"sha256": "00",
|
||||||
|
"size_bytes": 1
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same key + env-pin convention as `trust::signed_doc::tests` — every
|
||||||
|
/// caller pins the identical value, so parallel tests stay consistent.
|
||||||
|
fn test_release_key() -> ed25519_dalek::SigningKey {
|
||||||
|
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||||
|
std::env::set_var(
|
||||||
|
"ARCHY_RELEASE_ROOT_PUBKEY",
|
||||||
|
hex::encode(key.verifying_key().to_bytes()),
|
||||||
|
);
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_value(key: &ed25519_dalek::SigningKey, mut doc: serde_json::Value) -> serde_json::Value {
|
||||||
|
let (sig, did) = crate::trust::signed_doc::sign_detached(key, &doc).unwrap();
|
||||||
|
let obj = doc.as_object_mut().unwrap();
|
||||||
|
obj.insert("signed_by".into(), serde_json::json!(did));
|
||||||
|
obj.insert("signature".into(), serde_json::json!(sig));
|
||||||
|
doc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsigned_manifest_parses_but_reports_unsigned() {
|
||||||
|
let (manifest, signed) = parse_and_verify_manifest(sample_manifest_value()).unwrap();
|
||||||
|
assert_eq!(manifest.version, "9.9.9");
|
||||||
|
assert!(!signed, "unsigned manifest must not report as signed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchor_signed_manifest_reports_signed() {
|
||||||
|
let key = test_release_key();
|
||||||
|
let doc = sign_value(&key, sample_manifest_value());
|
||||||
|
let (manifest, signed) = parse_and_verify_manifest(doc).unwrap();
|
||||||
|
assert_eq!(manifest.version, "9.9.9");
|
||||||
|
assert!(signed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_signed_manifest_is_rejected() {
|
||||||
|
let key = test_release_key();
|
||||||
|
let mut doc = sign_value(&key, sample_manifest_value());
|
||||||
|
// Version swap after signing — exactly what a malicious mirror would do.
|
||||||
|
doc.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("version".into(), serde_json::json!("99.0.0"));
|
||||||
|
assert!(parse_and_verify_manifest(doc).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_update_schedule_default_is_daily_check() {
|
fn test_update_schedule_default_is_daily_check() {
|
||||||
let schedule = UpdateSchedule::default();
|
let schedule = UpdateSchedule::default();
|
||||||
@ -2043,6 +2167,7 @@ mod tests {
|
|||||||
rollback_available: true,
|
rollback_available: true,
|
||||||
schedule: UpdateSchedule::AutoApply,
|
schedule: UpdateSchedule::AutoApply,
|
||||||
manifest_mirror: None,
|
manifest_mirror: None,
|
||||||
|
manifest_signed: false,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&state).unwrap();
|
let json = serde_json::to_string(&state).unwrap();
|
||||||
let deserialized: UpdateState = serde_json::from_str(&json).unwrap();
|
let deserialized: UpdateState = serde_json::from_str(&json).unwrap();
|
||||||
@ -2170,6 +2295,7 @@ mod tests {
|
|||||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
|
manifest_signed: false,
|
||||||
};
|
};
|
||||||
save_state(dir.path(), &state).await.unwrap();
|
save_state(dir.path(), &state).await.unwrap();
|
||||||
let loaded = load_state(dir.path()).await.unwrap();
|
let loaded = load_state(dir.path()).await.unwrap();
|
||||||
|
|||||||
@ -48,12 +48,16 @@ arbitrary app catalog to the entire fleet — fully unattended under
|
|||||||
pinned-anchor binary must actually be built + shipped for enforcement to be live on nodes;
|
pinned-anchor binary must actually be built + shipped for enforcement to be live on nodes;
|
||||||
(b) flip "accept unsigned" → "reject unsigned" only after the whole fleet is on the pinned
|
(b) flip "accept unsigned" → "reject unsigned" only after the whole fleet is on the pinned
|
||||||
binary (`container/app_catalog.rs:397`, the `Unsigned` arm) — see the next item.
|
binary (`container/app_catalog.rs:397`, the `Unsigned` arm) — see the next item.
|
||||||
- [ ] 🔴 **Enforce a signature on the OTA manifest before trusting it.**
|
- [~] 🔴 **Enforce a signature on the OTA manifest before trusting it.** Signature
|
||||||
`update.rs:68` fetches `http://146.59.87.168:3000/.../manifest.json` over cleartext
|
verification LANDED 2026-07-02: `check_for_updates` now fetches raw JSON and runs
|
||||||
and parses/trusts it with no `trust::verify_detached` call; component sha256/blake3
|
`trust::verify_detached` — a present-but-invalid/wrong-signer signature hard-rejects
|
||||||
are only checked against that same unauthenticated manifest → remote root RCE.
|
the mirror; unsigned manifests are offered for MANUAL apply only (`manifest_signed`
|
||||||
Move to HTTPS + pinned cert, require an Ed25519 release-root signature, and
|
surfaced in `UpdateState`) and **auto-apply refuses them**. Publisher side:
|
||||||
**refuse `auto_apply` until the anchor is pinned.**
|
`create-release.sh` signs the manifest inline (ceremony), `publish-release-assets.sh`
|
||||||
|
hard-refuses to ship unsigned (grep + `ceremony verify` crypto gate), and
|
||||||
|
`scripts/sign-manifest.sh` exists for re-signs. **Still open:** move the mirror
|
||||||
|
to HTTPS + pinned cert (tracked with the next item); flip unsigned-manual-apply →
|
||||||
|
hard-reject once the fleet is on a pinned-anchor binary.
|
||||||
- [ ] 🔴 **Implement container image signature verification (cosign).**
|
- [ ] 🔴 **Implement container image signature verification (cosign).**
|
||||||
`container/src/podman_client.rs:255` — `pull_image(.., _signature)` silently discards
|
`container/src/podman_client.rs:255` — `pull_image(.., _signature)` silently discards
|
||||||
the signature that the manifest threads all the way down
|
the signature that the manifest threads all the way down
|
||||||
|
|||||||
@ -177,9 +177,28 @@ fi
|
|||||||
echo "[6/8] Creating release manifest..."
|
echo "[6/8] Creating release manifest..."
|
||||||
mkdir -p "$PROJECT_ROOT/releases"
|
mkdir -p "$PROJECT_ROOT/releases"
|
||||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
||||||
|
|
||||||
|
# §A supply-chain: the OTA manifest must carry the release-root signature.
|
||||||
|
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
|
||||||
|
# hard-refuses to ship one. The mnemonic is read interactively (or from
|
||||||
|
# RELEASE_MASTER_MNEMONIC) — it must never land in files or shell history.
|
||||||
|
SIGNER="$PROJECT_ROOT/core/target/release/archipelago"
|
||||||
|
if [ ! -x "$SIGNER" ]; then
|
||||||
|
echo "Error: release binary not found at $SIGNER — cannot sign manifest" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
|
||||||
|
echo "[6b/8] Signing release manifest (paste the release master mnemonic when prompted)..."
|
||||||
|
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
|
||||||
|
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
|
||||||
|
else
|
||||||
|
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
|
||||||
|
echo " Sign it before publishing: bash scripts/sign-manifest.sh"
|
||||||
|
echo " (publish-release-assets.sh refuses to ship an unsigned manifest)"
|
||||||
|
fi
|
||||||
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
||||||
|
|
||||||
echo "[6b/8] Staging release artifacts for validation..."
|
echo "[6c/8] Staging release artifacts for validation..."
|
||||||
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
||||||
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
|
||||||
mkdir -p "$VERSION_DIR"
|
mkdir -p "$VERSION_DIR"
|
||||||
|
|||||||
@ -25,6 +25,19 @@ fail() { echo "Error: $*" >&2; exit 1; }
|
|||||||
|
|
||||||
"$SCRIPT_DIR/check-release-manifest.sh"
|
"$SCRIPT_DIR/check-release-manifest.sh"
|
||||||
|
|
||||||
|
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
|
||||||
|
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
||||||
|
# and enforcement will tighten to hard-reject — an unsigned publish would
|
||||||
|
# strand them. Grep proves presence; ceremony verify proves the crypto.
|
||||||
|
EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur"
|
||||||
|
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
||||||
|
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|
||||||
|
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
|
||||||
|
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
||||||
|
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
||||||
|
|| fail "manifest signature failed cryptographic verification"
|
||||||
|
fi
|
||||||
|
|
||||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||||
case "$remote_url" in
|
case "$remote_url" in
|
||||||
http://*@*) ;;
|
http://*@*) ;;
|
||||||
|
|||||||
47
scripts/sign-manifest.sh
Executable file
47
scripts/sign-manifest.sh
Executable file
@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
|
||||||
|
#
|
||||||
|
# Run: bash scripts/sign-manifest.sh
|
||||||
|
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
|
||||||
|
#
|
||||||
|
# Signs releases/manifest.json in place and cryptographically verifies the
|
||||||
|
# result against the pinned release-root anchor. The mnemonic is read from the
|
||||||
|
# terminal only (never stored, never in shell history, never passed to Claude).
|
||||||
|
#
|
||||||
|
# Normally create-release.sh signs the manifest inline; this script exists for
|
||||||
|
# re-signing (e.g. a manifest edited after creation) or signing on a box where
|
||||||
|
# the release run was non-interactive.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
MANIFEST="$REPO/releases/manifest.json"
|
||||||
|
|
||||||
|
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
|
||||||
|
# the earlier catalog ceremony). Prefer the repo's release build.
|
||||||
|
BIN=""
|
||||||
|
for candidate in "$REPO/core/target/release/archipelago" /tmp/archy-sign-bin/release/archipelago; do
|
||||||
|
if [[ -x "$candidate" ]]; then BIN="$candidate"; break; fi
|
||||||
|
done
|
||||||
|
if [[ -z "$BIN" ]]; then
|
||||||
|
echo "⏳ No prebuilt signer found. Build one first:"
|
||||||
|
echo " (cd core && cargo build --release -p archipelago)"
|
||||||
|
echo " Nothing was changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
echo " Paste your 24-word release master mnemonic below, press Enter,"
|
||||||
|
echo " then press Ctrl-D on a new line."
|
||||||
|
echo "════════════════════════════════════════════════════════════════"
|
||||||
|
"$BIN" ceremony sign "$MANIFEST"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if "$BIN" ceremony verify "$MANIFEST"; then
|
||||||
|
echo "✅ SUCCESS — manifest signed by the pinned release root."
|
||||||
|
echo " Commit + push releases/manifest.json (and release-manifest.json if present)."
|
||||||
|
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "❌ Signature did NOT verify against the pinned release-root anchor."
|
||||||
|
echo " Do NOT commit. Check the mnemonic and re-run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Loading…
x
Reference in New Issue
Block a user