feat(trust): pin release-root anchor + ship signed app-catalog

Pin RELEASE_ROOT_PUBKEY_HEX from the 2026-07-02 release-root signing ceremony
(signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur) so nodes verify
the publisher identity of the app-catalog. Sign releases/app-catalog.json in place.

Fix two floats that made the catalog unsignable: archy-btcpay-db manifest version
-> string, fedimint-clientd cpu_limit 0.25 -> 1 (u32). Add scripts/sign-catalog.sh
helper, the 1.8.0 release-hardening plan/tracker, and the commit-and-push project
rule in CLAUDE.md.

Backward-compatible: old binaries still accept the signed catalog; the pinned-anchor
binary ships in the next build/OTA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-02 09:15:43 -04:00
co-authored by Claude Opus 4.8
parent 8b6485078a
commit 1977bdefb5
6 changed files with 2743 additions and 2340 deletions
+13 -6
View File
@@ -16,9 +16,11 @@ use ed25519_dalek::VerifyingKey;
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
///
/// Pinned 2026-07-01 (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur).
/// The corresponding mnemonic is held offline by the publisher — see
/// `docs/workstream-b-signing-runbook.md` for the ceremony that produced this.
/// Pinned 2026-07-02 from the release-root signing ceremony
/// (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The
/// corresponding mnemonic is held offline by the publisher — see
/// `docs/workstream-b-signing-runbook.md`. Regenerate/verify with:
/// `RELEASE_MASTER_MNEMONIC=… archipelago ceremony pubkey`.
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> =
Some("5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951");
@@ -53,9 +55,14 @@ mod tests {
use super::*;
#[test]
fn unset_constant_is_none() {
// Default build ships no pinned anchor yet.
assert!(RELEASE_ROOT_PUBKEY_HEX.is_none());
fn pinned_constant_parses_to_a_valid_key() {
// The release-root anchor is pinned (ceremony 2026-07-02); it must be
// present and a well-formed 32-byte Ed25519 key.
let hex = RELEASE_ROOT_PUBKEY_HEX.expect("release-root anchor must be pinned");
assert!(
parse_pubkey_hex(hex).is_some(),
"pinned RELEASE_ROOT_PUBKEY_HEX is not a valid Ed25519 key"
);
}
#[test]
+31 -3
View File
@@ -149,6 +149,18 @@ mod tests {
doc
}
/// Pin `test_key` as the release-root anchor for this process via the env
/// override. Needed because the baked-in `RELEASE_ROOT_PUBKEY_HEX` is the
/// real ceremony key, which no unit test can produce signatures for — so to
/// exercise the anchored-verification path we pin a key we can sign with.
/// Every call sets the same value, so parallel tests stay consistent.
fn pin_test_key_as_anchor() {
std::env::set_var(
"ARCHY_RELEASE_ROOT_PUBKEY",
hex::encode(test_key().verifying_key().to_bytes()),
);
}
#[test]
fn unsigned_document_reports_unsigned() {
let doc = json!({"schema": 1, "apps": {}});
@@ -156,18 +168,31 @@ mod tests {
}
#[test]
fn roundtrip_verifies() {
fn roundtrip_verifies_and_anchors_to_pinned_key() {
// With the anchor pinned to the signer, verification succeeds AND
// reports anchored == true (signer identity confirmed).
pin_test_key_as_anchor();
let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
match verify_detached(&signed).unwrap() {
// No anchor pinned in the default test build → anchored == false.
SignatureStatus::Verified { anchored, .. } => assert!(!anchored),
SignatureStatus::Verified { anchored, .. } => assert!(anchored),
other => panic!("expected Verified, got {:?}", other),
}
}
#[test]
fn signature_from_non_anchor_key_is_rejected() {
// A self-consistent signature from a key that is NOT the pinned anchor
// must hard-reject — this is what stops a mirror swapping in its own key.
pin_test_key_as_anchor();
let other_key = SigningKey::from_bytes(&[11u8; 32]);
let signed = sign_into(&other_key, json!({"schema": 1, "n": 42}));
assert!(verify_detached(&signed).is_err());
}
#[test]
fn signature_survives_key_reordering() {
// Re-emitting the document with shuffled keys must not break the sig.
pin_test_key_as_anchor();
let signed = sign_into(&test_key(), json!({"b": 2, "a": 1}));
let reparsed: Value =
serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap();
@@ -179,6 +204,9 @@ mod tests {
#[test]
fn tampered_payload_is_rejected() {
// Pin the signer so verification reaches the signature check (not an
// anchor-identity short-circuit), proving tamper detection itself.
pin_test_key_as_anchor();
let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
signed
.as_object_mut()