Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]
### Changes
- Deploys: the workflow supports an explicit, reason-recorded rollback to an earlier released dstack image on the unchanged N0 compose. It checks image labels, release tag, ancestry, and identical migration sources while preserving the normal forward-release guard.
- A new Policy/v3 registration records the share key its content source names (`share/<shareId>` on the owner's `default` network) in the registration's transaction, so publishing a share needs no `tinycloud.encryption/key.create` grant: the publisher encrypts to the key's public key and registers. A missing key is created at `keyVersion` 1 and counts toward the network's key limit; at the limit the registration answers 403 `key_limit_exceeded` and stores nothing. An existing key keeps its state. The registration now also requires the source's network to exist and be active. Docs: `docs/encryption-named-keys.md`. Details: TC-2525

## [1.25.17] - 2026-11-03

Expand Down
20 changes: 20 additions & 0 deletions docs/encryption-named-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ reused.
- A `deleted` deletable row is reused: new seed, state `active`, `keyVersion` + 1.
- A `deleted` deterministic row answers `key_deleted` (403).

### Share keys recorded by a policy registration

A Policy/v3 registration (`POST /policy/v3/policies`) whose `contentSource`
names `encryptionKeyPath` `share/<shareId>` records that key on the source's
`default` network in the registration's own transaction, under the network
row lock, so a publisher encrypts to the public key and never calls create.
The registration already requires its owner to hold `decrypt` on that exact
key as the network owner or through the owner's own delegations (a policy
session is never that authority).

- A missing key is created as `create` would (`keyVersion` 1, state
`active`) and counts toward the same per-network limit. At the limit the
registration answers 403 `key_limit_exceeded` and stores nothing.
- An existing key is left as it is, whatever its state: a `blocked` key stays
blocked.
- The network must exist and be active (`network_not_found` 404,
`network_not_active` 409); otherwise nothing is stored.
- A registration the node already stores answers as before and records
nothing.

## State guards

Create, block and unblock run under the network row lock. Block and unblock
Expand Down
28 changes: 28 additions & 0 deletions tinycloud-core/src/encryption_network/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,34 @@ impl EncryptionService {
}
}

/// Records the deterministic key at `loc` inside the caller's write
/// transaction, as `key.create` would, for a caller that has already
/// authorized it. A missing row is created under the network row lock and
/// the key caps; an existing row is left as it is, whatever its state, so a
/// blocked key stays blocked. The network must be active. A deletable key
/// path is refused: its seed is made only by `key.create`.
pub async fn record_deterministic_key_in(
&self,
tx: &DatabaseTransaction,
loc: &KeyLocation,
) -> Result<(), EncryptionServiceError> {
if loc.kind() != KeyKind::Deterministic {
return Err(EncryptionServiceError::KeyPathInvalid(
"only a deterministic key is recorded here".to_string(),
));
}
let network = network_of(loc)?;
match self.create_key_in(tx, &network, loc).await? {
CreateStep::Inserted(_) | CreateStep::Existing(_) => Ok(()),
// A deterministic row is never reinstalled. The network row lock
// (PostgreSQL) or the writer lock (SQLite) serializes key writes,
// so a lost insert race means another writer bypassed them.
CreateStep::Reinstalled(_) | CreateStep::Raced => {
Err(EncryptionServiceError::KeyExists)
}
}
}

async fn create_key_in(
&self,
tx: &DatabaseTransaction,
Expand Down
44 changes: 41 additions & 3 deletions tinycloud-node-server/src/policy_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ use tinycloud_auth::{
resource::{ResourceId, SpaceId},
};
use tinycloud_core::{
encryption_network::key_id::{self, EncryptionResource},
encryption_network::NetworkId,
encryption_network::key_id::{self, EncryptionResource, KeyLocation},
encryption_network::{EncryptionService, EncryptionServiceError, NetworkId},
events::{Delegation, SerializedEvent},
hash::hash,
keys::StaticSecret,
Expand Down Expand Up @@ -2048,6 +2048,7 @@ pub async fn register_policy(
request: Json<RegisterRequest>,
runtime: &State<PolicyV3Runtime>,
tinycloud: &State<crate::TinyCloud>,
encryption: &State<EncryptionService>,
) -> Result<Json<RegisterResponse>, (Status, String)> {
let request = request.into_inner();
let policy_bytes = canonical_json(&request.policy)?;
Expand Down Expand Up @@ -2142,8 +2143,10 @@ pub async fn register_policy(
return Err((Status::Conflict, "policy-registration-conflict".into()));
}
// A registration the node already stores answered above. A new one must
// bind its content source to one share key.
// bind its content source to one share key, which the node records in
// the registration's transaction.
require_bound_content_source(&request.policy)?;
let share_key = share_key_location(&request.policy)?;

let now = OffsetDateTime::now_utc();
let policy_status = initial_status_checkpoint(
Expand Down Expand Up @@ -2225,6 +2228,15 @@ pub async fn register_policy(
roots,
)
.await;
// The owner was shown above to hold decrypt on this exact key, so the
// registration records it; a full key limit refuses the registration.
let written = match written {
Ok(()) => encryption
.record_deterministic_key_in(&txn, &share_key)
.await
.map_err(share_key_error),
Err(err) => Err(err),
};
match written {
Ok(()) => txn.commit().await.map_err(db_error)?,
Err(err) => {
Expand Down Expand Up @@ -4668,6 +4680,28 @@ fn require_bound_content_source(policy: &Value) -> Result<(), (Status, String)>
Ok(())
}

/// The share key a new registration's content source names, as
/// `registration_projections` validated it: `<encryptionNetwork>/<encryptionKeyPath>`.
fn share_key_location(policy: &Value) -> Result<KeyLocation, (Status, String)> {
let source = policy.get("contentSource");
let field = |name| source.and_then(|s| s.get(name)).and_then(Value::as_str);
let (Some(network), Some(path)) = (field("encryptionNetwork"), field("encryptionKeyPath"))
else {
return Err((Status::BadRequest, "content-source-invalid".into()));
};
match key_id::parse(&format!("{network}/{path}")) {
Ok(EncryptionResource::Key(loc)) => Ok(loc),
_ => Err((Status::BadRequest, "content-source-invalid".into())),
}
}

fn share_key_error(err: EncryptionServiceError) -> (Status, String) {
match err {
EncryptionServiceError::Db(err) => db_error(err),
other => crate::routes::encryption::map_service_err(other),
}
}

fn registration_projections(policy: &Value) -> Result<RegistrationProjections, (Status, String)> {
let object = policy
.as_object()
Expand Down Expand Up @@ -12066,3 +12100,7 @@ mod delivery_admission_tests;
#[cfg(test)]
#[path = "policy_v3/unbound_source_tests.rs"]
mod unbound_source_tests;

#[cfg(test)]
#[path = "policy_v3/share_key_record_tests.rs"]
mod share_key_record_tests;
6 changes: 4 additions & 2 deletions tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,7 @@ pub(super) struct ShareDecrypt {
}

impl ShareDecrypt {
async fn new() -> anyhow::Result<Self> {
pub(super) async fn new() -> anyhow::Result<Self> {
Self::with_source(Some(TC2192_KEY_PATH), "immutable").await
}

Expand Down Expand Up @@ -925,9 +925,11 @@ impl ShareDecrypt {
threshold: Threshold::one_of_one(),
})
.await?;
// A share key is never created by the publisher: it encrypts to the
// public key, and the registration below records the key.
let public_key = if let Some(path) = key_path {
service
.create_key(&KeyLocation::new(&network.network_id, path)?, false)
.public_key(&KeyLocation::new(&network.network_id, path)?)
.await?
.public_encryption_key
} else {
Expand Down
Loading
Loading