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:
co-authored by
Claude Opus 4.8
parent
8b6485078a
commit
1977bdefb5
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user