swarm-nats-auth: open sealed callout requests and seal the replies
When nats-server has an auth_callout xkey it seals each request to that key and names its own public xkey in the Nats-Server-Xkey header. The responder now loads the callout xkey seed from the secret store before it connects, opens a sealed request with it, and seals its reply (grant or deny) back to the header's key. A request with no header is answered in plaintext, as before. A new `ensure-xkey` verb creates the pair when the store holds no seed: the seed at swarm/services/swarm-nats/callout/xkey-seed, the public half at swarm/public/swarm-nats/callout-xkey. Each write is a create-only (cas=0) write, and a run whose create fails reads the stored value back, so two concurrent runs end up with one key. The responder itself only reads the seed and refuses to start without it. swarm-secret-client gains SecretStore::create, the cas=0 write.
This commit is contained in:
parent
4a289c62e7
commit
1e0b10bb7d
6 changed files with 669 additions and 19 deletions
|
|
@ -237,6 +237,23 @@ impl SecretStore {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `value` at `path` only if nothing is stored there yet.
|
||||
///
|
||||
/// KV v2's check-and-set with `cas=0`, so of two writers racing on an
|
||||
/// empty path exactly one lands. The other gets an error, and a caller
|
||||
/// that only needs *a* value there reads the winner's back.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Vault`] when something is already stored at `path`, or the
|
||||
/// token's policy does not cover it. Under a grant of `create` without
|
||||
/// `update` the first of those is a 403, not a check-and-set mismatch:
|
||||
/// the store checks the capability for an existing path before it gets
|
||||
/// to `cas`.
|
||||
pub async fn create<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
|
||||
vaultrs::kv2::set_with_options(&self.inner, MOUNT, path, value, create_only()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete `path` outright: every version of it, and the metadata that
|
||||
/// lists them.
|
||||
///
|
||||
|
|
@ -395,6 +412,12 @@ struct WriteAclPolicy {
|
|||
policy: String,
|
||||
}
|
||||
|
||||
/// The options [`SecretStore::create`] writes with: version 0, which only a
|
||||
/// path holding nothing is at.
|
||||
fn create_only() -> vaultrs::api::kv2::requests::SetSecretRequestOptions {
|
||||
vaultrs::api::kv2::requests::SetSecretRequestOptions { cas: 0 }
|
||||
}
|
||||
|
||||
/// The request [`SecretStore::issue_client_certificate`] sends.
|
||||
///
|
||||
/// The name stays out of the SANs so the certificate carries exactly one name,
|
||||
|
|
@ -627,6 +650,30 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A create is a write with `cas=0` in the body: without it the store
|
||||
/// takes the write as an overwrite of whatever is there, and the two
|
||||
/// racing writers [`SecretStore::create`] exists for would both land.
|
||||
#[test]
|
||||
fn a_create_writes_the_data_path_only_while_it_holds_nothing() {
|
||||
use rustify::endpoint::Endpoint as _;
|
||||
|
||||
let request = vaultrs::api::kv2::requests::SetSecretRequest::builder()
|
||||
.mount(MOUNT)
|
||||
.path("swarm/public/x")
|
||||
.data(serde_json::json!({ "value": "v" }))
|
||||
.options(create_only())
|
||||
.build()
|
||||
.expect("the request builds");
|
||||
assert_eq!(request.path(), "secret/data/swarm/public/x");
|
||||
let body = request
|
||||
.body()
|
||||
.expect("the body serialises")
|
||||
.expect("a write sends one");
|
||||
let sent: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
|
||||
assert_eq!(sent["options"]["cas"], 0);
|
||||
assert_eq!(sent["data"]["value"], "v");
|
||||
}
|
||||
|
||||
/// What makes [`SecretStore::delete_all_versions`] safe to run twice: the
|
||||
/// second run finds nothing and must still succeed. A teardown that
|
||||
/// hard-errors on an already-revoked credential is a worse fault than the
|
||||
|
|
|
|||
Loading…
Reference in a new issue