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
13 changes: 7 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@

### Changes

- Named-key routes and key resources are served only when `[global.encryption_network] named_keys_v1` is enabled. Details: TC-1777.
- The node logs one structured diagnostic line (status, code, service, route, elapsed time, failure kind, a keyed database pseudonym `db_hash`) when a request ends in a database failure or a busy/timeout 5xx. Details: TC-1796.
- Log sidecar: the node's one warning line per database-failure 5xx (`database failure answered with a 5xx`) ships to Better Stack as an individual `node_warning` event with `event_code` `db_failure_5xx` and exactly eight fields: `status`, `code`, `service`, `route`, `elapsed_ms`, `failure_kind`, `write_may_have_applied` and, when known, `db_hash`. They are fixed vocabulary, the route template, a duration, a flag and a keyed database pseudonym; no SQL, ids, DIDs, paths or messages. A line with any other field or an unexpected value is counted as `unclassified` as before. Details: TC-1840.
- **N0 compose.** The embedded Vector configuration is regenerated for the new event. The reviewed N0 compose template changes to `7c3456dc…` (raw `d0e72bde…`; `docs/releasing.md`).
- Policy registration, policy status changes, SQL store manifest publication and share-email state writes now end their database transaction on every exit, so locks are released when the call returns. Details: TC-1836.
- Creating a named key now ends its database transaction on every exit, so the network row lock is released when the call returns. Details: TC-1834.
- Encryption: clearer error for percent-encoded key route URLs. The public-key and describe routes for named keys read the network id and key path as sent; a percent-escape returns 400 `key_path_invalid` with a message naming the escape. Details: TC-1777.
- Encryption: clearer error for percent-encoded key route URLs. The public-key and describe routes for named keys read the network id and key path as sent; a percent-escape returns 400 `key_path_invalid` with a message naming the escape. These routes are served when `named_keys_v1` is on. Details: TC-1777.
- SQL, DuckDB and encryption: 413 responses for oversized requests and responses start with a stable code: `sql_request_too_large`, `duckdb_request_too_large`, `request_too_large` (encryption decrypt on `/invoke`), `sql_response_too_large` or `duckdb_response_too_large`. The detail after the code states the limit applied. A SQL `maxRows` refusal now reads `sql_response_too_large: Response too large: row limit exceeded (limit N rows)` instead of reporting bytes. Docs: added `docs/sql-duckdb-size-limits.md`. Details: TC-1823.
- Encryption: parsing and containment for key resource identifiers; owners are root authority for their keys; key prefixes are refused as invocation targets, and key.delete is refused in delegations. Named keys can be created, blocked and unblocked, and their public key is derived; the routes are listed in the next entry. Root-key decrypt on both live routes now uses strict unwrap: wrapped keys must carry the 0x01 version byte, and non-contributory key exchanges are refused. On a non-active network, named-key decrypt on `POST /invoke` returns 403 (`network_not_active`, refused before replay); the direct root-key decrypt route returns 409 for a non-active network and 401 for a revoked one; create and public key return 409. Describe, block and unblock do not check network state. Details: TC-1777.
- Encryption: key create, public key, describe, block and unblock routes. Create is `POST /encryption/networks/<network>/keys`; public key and describe are `GET …/keys/<key path>`, and block and unblock are `POST …/key-actions/block|unblock`. The public key read needs no authorization and returns the same shape for missing, blocked and active keys. Describe takes a `key-describe/v1` invocation, and create, block and unblock take a `key-admin/v1` invocation, each on the exact key. Details: TC-1777.
- Encryption: parsing and containment for key resource identifiers; owners are root authority for their keys; key prefixes are refused as invocation targets, and key.delete is refused in delegations. Key resources are accepted only when `named_keys_v1` is on. When it is on, named keys can be created, blocked and unblocked, and their public key is derived; the routes are listed in the next entry. Root-key decrypt on both live routes now uses strict unwrap: wrapped keys must carry the 0x01 version byte, and non-contributory key exchanges are refused. On a non-active network, named-key decrypt on `POST /invoke` returns 403 (`network_not_active`, refused before replay); the direct root-key decrypt route returns 409 for a non-active network and 401 for a revoked one; create and public key return 409. Describe, block and unblock do not check network state. Details: TC-1777.
- Encryption: when `named_keys_v1` is on, key create, public key, describe, block and unblock routes. Create is `POST /encryption/networks/<network>/keys`; public key and describe are `GET …/keys/<key path>`, and block and unblock are `POST …/key-actions/block|unblock`. The public key read needs no authorization and returns the same shape for missing, blocked and active keys. Describe takes a `key-describe/v1` invocation, and create, block and unblock take a `key-admin/v1` invocation, each on the exact key. Details: TC-1777.
- Tests that read `TINYCLOUD_TEST_POSTGRES_URL` connect only on GitHub Actions (`GITHUB_ACTIONS=true`) or with `TINYCLOUD_ALLOW_LOCAL_PG=1` set for an isolated server you started yourself, and skip otherwise. `CI=true` alone no longer connects, since automation shells set it by default. `tinycloud-node-server/tests/at_rest_harness.rs` (reads `TC887_PG_URL`) is excluded. Details: TC-1748.
- Encryption: key and prefix network names must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`; bare network resources keep the existing network name rule. Details: TC-1777.
- Encryption: key route errors start with a stable code: `network_not_found:`, `unauthorized:`, `key_not_found:` or `key_path_invalid:`. Request bodies that fail to parse on the key routes return 400 with the text `request body is malformed: <reason>`. Key paths are read from the request as sent, so a path with encoded or relative segments is refused rather than normalized. Network ids are accepted only in canonical form. Details: TC-1777.
- Encryption: named-key decrypt is served on `POST /invoke` (a key capability plus `keyPath` in the body); the direct network decrypt route refuses key resources and `keyPath`. A root-key unwrap failure on the direct route returns 403 `key_unwrap_failed`. Decrypt requests whose receiver key is not a valid 32-byte X25519 public key, or is a low-order point, are refused with 400 and the code `receiver_key_invalid`. Details: TC-1777.
- Encryption: key and prefix network names must match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`; bare network resources keep the existing network name rule. Key and prefix resources are accepted when `named_keys_v1` is on. Details: TC-1777.
- Encryption: key route errors (served when `named_keys_v1` is on) start with a stable code: `network_not_found:`, `unauthorized:`, `key_not_found:` or `key_path_invalid:`. Request bodies that fail to parse on the key routes return 400 with the text `request body is malformed: <reason>`. Key paths are read from the request as sent, so a path with encoded or relative segments is refused rather than normalized. Network ids are accepted only in canonical form. Details: TC-1777.
- Encryption: when `named_keys_v1` is on, named-key decrypt is served on `POST /invoke` (a key capability plus `keyPath` in the body); the direct network decrypt route refuses key resources and `keyPath`. A root-key unwrap failure on the direct route returns 403 `key_unwrap_failed`. Decrypt requests whose receiver key is not a valid 32-byte X25519 public key, or is a low-order point, are refused with 400 and the code `receiver_key_invalid`. Details: TC-1777.
- Configuration: adds `[encryption_network] named_keys_v1`, off by default. Details: TC-1777.
- SQL: a `tinycloud.sql/*` delegation can carry `tables`, `columns` and `readOnly` caveats (names match ASCII-case-insensitively; each hop must be at least as narrow as its parent). A request is judged by the intersection of these caveats over every stored delegation on the chain it cites, and a request they do not allow fails with 403 `sql_caveat_violation: write`, `table` or `column`, the whole request refused. Under a `tables` or `columns` limit the PRAGMA schema reports and checks (`sql_caveat_violation: table`), the PRAGMAs that set `user_version` or `application_id` (`sql_caveat_violation: write`) and the database export are refused; DDL on a table needs that table listed. A chain whose caveat cannot be read fails with `sql_caveat_violation: chain`. Behind `[global.sql] delegated_caveats_enforced` (env `TINYCLOUD_SQL__DELEGATED_CAVEATS_ENFORCED`), off by default; when on, `GET /info` lists `sql-delegated-caveats-v1`. With the setting off, these delegations are refused as an unsupported caveat. DuckDB takes empty caveats only. Docs: added `docs/sql-delegated-caveats.md`. Details: TC-1791.

Expand Down
18 changes: 17 additions & 1 deletion tinycloud-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ pub struct SpaceDatabase<C, B, S> {
encryption: Option<ColumnEncryption>,
sql_sizes: SqlSizes,
delegated_sql_caveats: bool,
named_keys: bool,
revocation_chain_locks: ChainLockRegistry,
kv_object_locks: KvObjectLockRegistry,
space_sequence_locks: SpaceSequenceLockRegistry,
Expand Down Expand Up @@ -425,6 +426,7 @@ where
encryption,
sql_sizes: SqlSizes::default(),
delegated_sql_caveats: false,
named_keys: false,
revocation_chain_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
kv_object_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
space_sequence_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
Expand All @@ -444,6 +446,14 @@ where
self
}

/// Serve named-key resources (keys and key prefixes): delegations that
/// grant on them are admitted. Off by default: such a delegation is
/// refused with `named_keys_unavailable`.
pub fn with_named_keys(mut self, enabled: bool) -> Self {
self.named_keys = enabled;
self
}

pub fn with_sql_sizes(mut self, sql_sizes: SqlSizes) -> Self {
self.sql_sizes = sql_sizes;
self
Expand Down Expand Up @@ -1681,6 +1691,7 @@ where
None,
&[],
self.delegated_sql_caveats,
self.named_keys,
)
.await?;

Expand Down Expand Up @@ -1972,6 +1983,7 @@ where
None,
&[],
self.delegated_sql_caveats,
self.named_keys,
)
.await
}
Expand Down Expand Up @@ -2447,6 +2459,7 @@ where
// Taken on this `tx` right after BEGIN, above.
&sequence_spaces,
self.delegated_sql_caveats,
self.named_keys,
)
.await?;

Expand Down Expand Up @@ -3335,6 +3348,7 @@ pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>,
sequence_locks_held: &[SpaceId],
delegated_sql_caveats: bool,
named_keys: bool,
) -> Result<TransactResult, TxError<S, K>> {
// for each event, get the hash and the relevent space(s)
let event_hashes = events
Expand All @@ -3359,7 +3373,9 @@ pub(crate) async fn transact<C: ConnectionTrait, S: StorageSetup, K: Secrets>(
let mut registered: HashMap<Hash, delegation::DelegationRegistration> = HashMap::new();
for (hash, event) in &event_hashes {
if let Event::Delegation(d) = event {
let reg = delegation::process_with(db, d, encryption, delegated_sql_caveats).await?;
let reg =
delegation::process_with(db, d, encryption, delegated_sql_caveats, named_keys)
.await?;
registered.insert(*hash, reg);
}
}
Expand Down
9 changes: 9 additions & 0 deletions tinycloud-core/src/encryption_network/key_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ pub(crate) fn owned_by(uri: &str, principal: &str) -> bool {
parse(uri).is_ok_and(|resource| did_principal_matches(resource.owner_did(), principal))
}

/// Whether `uri` names a key or a key prefix (not a bare network). A string
/// that does not parse as an encryption resource is not one.
pub fn is_named_key(uri: &str) -> bool {
matches!(
parse(uri),
Ok(EncryptionResource::Key(_) | EncryptionResource::KeyPrefix(_))
)
}

fn is_below(ancestor: &str, path: &str) -> bool {
path.strip_prefix(ancestor)
.is_some_and(|rest| rest.starts_with('/'))
Expand Down
64 changes: 60 additions & 4 deletions tinycloud-core/src/models/delegation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ pub enum DelegationError {
/// Key deletion is never delegated. Code: `key-delete-not-delegable`.
#[error("key-delete-not-delegable")]
KeyDeleteNotDelegable,
/// Named-key resources (keys and key prefixes) are served only while the
/// node's named-keys setting is on.
#[error("named_keys_unavailable: named keys are not enabled on this node")]
NamedKeysUnavailable,
}

/// Verify, validate, and register a delegation inside the caller's transaction.
Expand All @@ -200,22 +204,24 @@ pub(crate) async fn process<C: ConnectionTrait>(
delegation: &Delegation,
encryption: Option<&ColumnEncryption>,
) -> Result<DelegationRegistration, Error> {
process_with(db, delegation, encryption, false).await
process_with(db, delegation, encryption, false, true).await
}

/// As `process`; with `delegated_sql_caveats` a SQL capability may carry the
/// delegated `tables` / `columns` / `readOnly` caveat form (TC-1791).
/// delegated `tables` / `columns` / `readOnly` caveat form (TC-1791). Without
/// `named_keys` a capability on a key or key prefix is refused.
pub(crate) async fn process_with<C: ConnectionTrait>(
db: &C,
delegation: &Delegation,
encryption: Option<&ColumnEncryption>,
delegated_sql_caveats: bool,
named_keys: bool,
) -> Result<DelegationRegistration, Error> {
verify(&delegation.0.delegation).await?;
for space in delegation.0.spaces() {
crate::request_telemetry::observe_space(space);
}
validate_with(db, &delegation.0, delegated_sql_caveats).await?;
validate_with(db, &delegation.0, delegated_sql_caveats, named_keys).await?;
save(db, delegation.0.clone(), delegation.1.clone(), encryption).await
}

Expand Down Expand Up @@ -337,14 +343,26 @@ async fn validate<C: ConnectionTrait>(
db: &C,
delegation: &util::DelegationInfo,
) -> Result<(), Error> {
validate_with(db, delegation, false).await
validate_with(db, delegation, false, true).await
}

async fn validate_with<C: ConnectionTrait>(
db: &C,
delegation: &util::DelegationInfo,
delegated_sql_caveats: bool,
named_keys: bool,
) -> Result<(), Error> {
// Without named keys, a capability on a key or key prefix is refused for
// every delegation, before the root-authority filter, so a delegation
// issued by the key's owner is covered too.
if !named_keys
&& delegation
.capabilities
.iter()
.any(|cap| cap.resource.is_named_key())
{
return Err(DelegationError::NamedKeysUnavailable.into());
}
// TC-1791: SQL and DuckDB caveats are checked for every delegation,
// before the root-authority filter.
for cap in &delegation.capabilities {
Expand Down Expand Up @@ -1692,6 +1710,44 @@ mod tests {
));
}

/// Without named keys, a grant on a key or a key prefix is refused for
/// the key's owner and for anyone else, before any parent is looked up;
/// a grant on the bare network is not affected.
#[tokio::test]
async fn named_key_grants_are_refused_when_named_keys_are_off() {
let db = database().await;
let key = KEY_RESOURCE.to_string();
let prefix = format!("urn:tinycloud:encryption:{KEY_OWNER}:default/apps/");
let network = format!("urn:tinycloud:encryption:{KEY_OWNER}:default");
for delegator in [KEY_OWNER, OTHER_DID] {
for resource in [&key, &prefix] {
let info = key_grant_info(delegator, resource, KEY_DECRYPT, &[]);
let refused = validate_with(&db, &info, false, false).await;
assert!(
matches!(
refused,
Err(Error::InvalidDelegation(
DelegationError::NamedKeysUnavailable
))
),
"{delegator} on {resource}: {refused:?}"
);
}
}
assert_eq!(
DelegationError::NamedKeysUnavailable.to_string(),
"named_keys_unavailable: named keys are not enabled on this node"
);
let info = key_grant_info(KEY_OWNER, &network, KEY_DECRYPT, &[]);
validate_with(&db, &info, false, false)
.await
.expect("the owner's bare network grant is not a named-key grant");
let info = key_grant_info(KEY_OWNER, &key, KEY_DECRYPT, &[]);
validate_with(&db, &info, false, true)
.await
.expect("the same grant is admitted with named keys on");
}

/// key.delete is refused even when the owner issues it.
#[tokio::test]
async fn owner_issued_key_delete_is_refused() {
Expand Down
6 changes: 6 additions & 0 deletions tinycloud-core/src/types/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ impl Resource {
}
}

/// Whether this resource names a key or a key prefix under an encryption
/// network; a bare network is not one.
pub fn is_named_key(&self) -> bool {
matches!(self, Resource::Other(uri) if key_id::is_named_key(uri.as_str()))
}

/// `child.extends(&parent)` is true when `parent` covers `child`.
/// The receiver is the requested (child) resource; the argument is the grant.
pub fn extends(&self, other: &Self) -> bool {
Expand Down
8 changes: 6 additions & 2 deletions tinycloud-node-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ pub struct Config {
}

/// Release gates for the encryption network routes. `named_keys_v1`
/// advertises the `encryption-named-keys-v1` feature in `/info`; it stays off
/// until named keys are live on OpenKey.
/// advertises the `encryption-named-keys-v1` feature in `/info` and turns on
/// named keys: the key create, describe, public key, block and unblock
/// routes are mounted, delegations may grant on key and key-prefix
/// resources, and named-key decrypt is served on `POST /invoke`. It stays off
/// until named keys are live on OpenKey; while off, those routes answer 404
/// and key resources are refused with `named_keys_unavailable`.
#[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)]
pub struct EncryptionNetworkConfig {
#[serde(default)]
Expand Down
Loading
Loading