mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
fix(admin): create-admin-wallet fully bootstraps a fresh instance (latch + compose)
create-admin-wallet registered a pending admin key but never latched setup, so on a fresh instance it left the bootstrap half-open and a later `setup` collided with it (setup's guard only checked for an ACTIVE admin; the cli key was pending). - New atomic Database::create_admin_key_bootstrapping: under the admin-keys advisory lock, insert the pending generated key AND latch `locked` (unless already validly locked), append audit, commit — one transaction. Fails CLOSED on a tampered latch (defers to reset-setup) instead of silently healing it. - bootstrap_admin (setup) now refuses on ANY live admin key (active OR pending), not just active, so setup composes with create-admin-wallet. The adopt-existing- deployment path (users but no admin keys) is unaffected: no live key -> proceeds. - reset_setup now takes the same advisory lock (all latch writers serialize) and documents the live-key recovery path. - CLI prints bootstrapped-vs-added and a terminating lost-secret recovery hint. - Docs (README + OPERATOR_SETUP section 5): both mutually-exclusive bootstrap paths. Adversarially reviewed (atomicity / lockout / regression — all ship). L1 test extended: fresh-latch, no-relatch-when-locked, pending semantics, composition guard, and tamper-refuse (fails closed + rolls back the key insert).
This commit is contained in:
+154
-2
@@ -9,8 +9,8 @@
|
||||
|
||||
mod common;
|
||||
|
||||
use smirk_backend_core::infra::db::SetupState;
|
||||
use smirk_backend_core::models::db::NewAdminKey;
|
||||
use smirk_backend_core::infra::db::{AddKeyOutcome, SetupState};
|
||||
use smirk_backend_core::models::db::{NewAdminAudit, NewAdminKey};
|
||||
use uuid::Uuid;
|
||||
|
||||
const SECRET: &str = "server-config-integrity-secret-0123456789ab";
|
||||
@@ -84,4 +84,156 @@ async fn latch_state_machine_and_tamper_detection() {
|
||||
"bootstrap refused while an active admin exists"
|
||||
);
|
||||
let _ = db.revoke_admin_key(k.id, SECRET).await;
|
||||
|
||||
// ── create_admin_key_bootstrapping: a generated-key first-run (fix B) ──────
|
||||
// `create-admin-wallet` on a FRESH instance must be a COMPLETE bootstrap:
|
||||
// register the (pending) generated key AND latch `locked`, atomically.
|
||||
sqlx::query("DELETE FROM server_config WHERE id = 1")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.read_setup_state(SECRET).await.unwrap(),
|
||||
SetupState::Fresh
|
||||
);
|
||||
|
||||
let genpk = format!("{:0>64}", Uuid::new_v4().simple());
|
||||
let audit = NewAdminAudit {
|
||||
action: "admin_wallet_created".into(),
|
||||
actor_kind: "cli".into(),
|
||||
actor_pubkey_prefix: None,
|
||||
target: Some(genpk.clone()),
|
||||
details: None,
|
||||
ip_address: None,
|
||||
};
|
||||
let (outcome, latched) = db
|
||||
.create_admin_key_bootstrapping(
|
||||
NewAdminKey {
|
||||
pubkey: genpk.clone(),
|
||||
label: Some("cli-generated".into()),
|
||||
scope: "admin".into(),
|
||||
created_by_kind: "cli".into(),
|
||||
activation_deadline: None,
|
||||
},
|
||||
&audit,
|
||||
SECRET,
|
||||
i64::MAX,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
latched,
|
||||
"a fresh instance is latched by create-admin-wallet"
|
||||
);
|
||||
assert_eq!(
|
||||
db.read_setup_state(SECRET).await.unwrap(),
|
||||
SetupState::Locked,
|
||||
"the generated-key bootstrap latches locked"
|
||||
);
|
||||
let key1 = match outcome {
|
||||
AddKeyOutcome::Created(k) => k,
|
||||
AddKeyOutcome::CapReached => panic!("unexpected cap"),
|
||||
};
|
||||
assert!(
|
||||
key1.activated_at.is_none(),
|
||||
"the generated key stays pending (activates on first login)"
|
||||
);
|
||||
|
||||
// A SECOND generated key on the now-locked instance is a plain pending add —
|
||||
// it must NOT re-latch (no re-stamping bootstrap_completed_at).
|
||||
let genpk2 = format!("{:0>64}", Uuid::new_v4().simple());
|
||||
let audit2 = NewAdminAudit {
|
||||
target: Some(genpk2.clone()),
|
||||
..audit.clone()
|
||||
};
|
||||
let (outcome2, latched2) = db
|
||||
.create_admin_key_bootstrapping(
|
||||
NewAdminKey {
|
||||
pubkey: genpk2.clone(),
|
||||
label: Some("cli-generated".into()),
|
||||
scope: "admin".into(),
|
||||
created_by_kind: "cli".into(),
|
||||
activation_deadline: None,
|
||||
},
|
||||
&audit2,
|
||||
SECRET,
|
||||
i64::MAX,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!latched2, "an already-locked instance is not re-latched");
|
||||
assert_eq!(
|
||||
db.read_setup_state(SECRET).await.unwrap(),
|
||||
SetupState::Locked
|
||||
);
|
||||
let key2 = match outcome2 {
|
||||
AddKeyOutcome::Created(k) => k,
|
||||
AddKeyOutcome::CapReached => panic!("unexpected cap"),
|
||||
};
|
||||
|
||||
// Composition guard: with a live (even PENDING) key present, `setup` refuses
|
||||
// rather than seeding a confusing second admin. NOTE: on the shared test DB this
|
||||
// assertion is non-discriminating — sibling test binaries leave ACTIVE keys, so
|
||||
// it would pass under the old active-only guard too. The any-live widen is proven
|
||||
// hermetically at the query level in the db unit tests; here it documents intent.
|
||||
assert!(
|
||||
db.bootstrap_admin(&"22".repeat(32), SECRET).await.is_err(),
|
||||
"setup refuses once any live admin key exists (even pending)"
|
||||
);
|
||||
|
||||
// create-admin-wallet FAILS CLOSED on a tampered latch (defers to reset-setup),
|
||||
// rather than silently re-stamping a valid `locked` latch over the tamper. We own
|
||||
// the singleton, so this is isolable: raw-flip the state without recomputing the
|
||||
// MAC (the restore-to-pre-bootstrap shape), then assert the call errors, the latch
|
||||
// is NOT healed, and the key insert was rolled back.
|
||||
sqlx::query(
|
||||
"UPDATE server_config SET setup_state = 'uninitialized', bootstrap_completed_at = NULL WHERE id = 1",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.read_setup_state(SECRET).await.unwrap(),
|
||||
SetupState::Tampered
|
||||
);
|
||||
let genpk3 = format!("{:0>64}", Uuid::new_v4().simple());
|
||||
let audit3 = NewAdminAudit {
|
||||
target: Some(genpk3.clone()),
|
||||
..audit.clone()
|
||||
};
|
||||
let tampered_res = db
|
||||
.create_admin_key_bootstrapping(
|
||||
NewAdminKey {
|
||||
pubkey: genpk3.clone(),
|
||||
label: Some("cli-generated".into()),
|
||||
scope: "admin".into(),
|
||||
created_by_kind: "cli".into(),
|
||||
activation_deadline: None,
|
||||
},
|
||||
&audit3,
|
||||
SECRET,
|
||||
i64::MAX,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
tampered_res.is_err(),
|
||||
"create-admin-wallet fails closed on a tampered latch"
|
||||
);
|
||||
assert_eq!(
|
||||
db.read_setup_state(SECRET).await.unwrap(),
|
||||
SetupState::Tampered,
|
||||
"the tampered latch is NOT silently healed"
|
||||
);
|
||||
let keys = db.list_admin_keys().await.unwrap();
|
||||
assert!(
|
||||
!keys.iter().any(|k| k.pubkey == genpk3),
|
||||
"the refused call rolled back its pending key insert"
|
||||
);
|
||||
|
||||
let _ = db.revoke_admin_key(key1.id, SECRET).await;
|
||||
let _ = db.revoke_admin_key(key2.id, SECRET).await;
|
||||
|
||||
// Leave the owned singleton in a clean, MAC-valid state (not raw-tampered) for
|
||||
// the next run / any reader.
|
||||
db.reset_setup(SECRET).await.unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user