Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

- A SIWE (CACAO) delegation whose ReCap gives a caveat a non-null value on any ability is refused as an unsupported caveat; empty caveat entries and entries whose values are all null are accepted as no restriction. Details: TC-1844.
- Named-key routes and key resources are served only when `[global.encryption_network] named_keys_v1` is enabled. Details: TC-1777.
- Encryption: named keys under the path segment `apps/` are deletable keys. A key path whose first segment is exactly `apps` must be `apps/<app id>/<name>` (a prefix `apps/<app id>/`); `apps`, `apps/<app id>` and `apps/` are refused in delegations and invocations. The create body is `{ keyPath, deletable }` and `deletable` must equal what the path implies (400 `key_path_invalid` otherwise). A deletable key is derived from the network secret, the path and a per-key random seed sealed with the column key; other keys use the deterministic derivation. Details: TC-1777.
- Encryption: create, block and unblock run under the network row lock. On a `deleted` key block and unblock answer 403 `key_deleted`; create on a `deleted` deletable key reuses the row with a new seed and the next `keyVersion`, and a decrypt for another `keyVersion` answers 403 `key_deleted`. Per `apps/<app id>/` prefix at most 100 key rows count towards `key_limit_exceeded`. Details: TC-1777.
- Encryption: the unauthenticated key GET answers 404 `key_not_found` for every path under `apps/`; the authenticated GET returns the key with its `kind` and `state`. Details: TC-1777.
- Encryption: a SIWE delegation issued by the key owner that grants any ability other than `key.block` under `apps/<app id>` is refused with `app_id_not_bound` unless the app id is bound to the SIWE domain and the SIWE scheme is absent or `https`. Details: TC-1777.
- Encryption: the node does not start while a key row under `apps/` has a kind other than `deletable`; the error carries the query that lists them. Docs: `docs/encryption-named-keys.md`. 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`).
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions docs/encryption-named-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Named encryption keys

Named keys are served only while `[global.encryption_network] named_keys_v1` is
on (default off). With it off the key routes are not mounted and key resources
are refused.

A key is named by `urn:tinycloud:encryption:<owner>:<network>/<path>`. A prefix
grant is the same URN with one trailing `/`.

## Key paths

A path is 1 to 8 segments of `[a-z0-9][a-z0-9._-]{0,63}` joined by `/`, at most
255 bytes.

The first segment `apps` is reserved. A path whose first segment is exactly
`apps` must be `apps/<app id>/<name>[/<more>]` for a key (at least three
segments) and `apps/<app id>/[<more>/]` for a prefix (at least two segments
before the trailing `/`). `apps`, `apps/<app id>` and the prefix `apps/` are
refused in delegations and invocations. `appsx/...` and `apps.x/...` are
ordinary paths. An app id matches `^[a-z0-9][a-z0-9._-]{0,63}$` as a whole
string; it is validated, never changed.

## Key kinds

The kind follows from the path.

| Path | Kind | Secret |
| --- | --- | --- |
| first segment `apps` | `deletable` | HKDF-SHA256(ikm = HKDF-Extract(salt = seed, ikm = network secret), salt = `tinycloud.encryption.named-key/v1`, info = `seeded\n` + key URN) |
| any other | `deterministic` | HKDF-SHA256(ikm = network secret, salt = `tinycloud.encryption.named-key/v1`, info = `deterministic\n` + key URN) |

The seed of a deletable key is 32 bytes from the operating system's random
source, made at `key.create`, sealed with the same column key as the network
secret and stored in `encryption_network_key.sealed_seed`. A seed is opened only
with the strict unseal. A deletable row that is not `deleted` and has no
seed, and a row whose kind the path does not imply, is a corrupt row (500).

## Create

`POST /encryption/networks/<network>/keys` with `{ "keyPath": "...", "deletable": bool }`.
`deletable` must equal the kind the path implies (`true` under `apps/`, `false`
elsewhere), otherwise 400 `key_path_invalid`. Unknown fields are refused with
400. Per network at most 1000 key rows, and per `apps/<app id>/` prefix at most
100, count towards `key_limit_exceeded`; `deleted` rows count until they are
reused.

- A new path creates the row (`keyVersion` 1, state `active`).
- An existing `active` deterministic key answers with its descriptor.
- Any other existing row answers `key_exists` (409). A `blocked` row is not
changed.
- A `deleted` deletable row is reused: new seed, state `active`, `keyVersion` + 1.
- A `deleted` deterministic row answers `key_deleted` (403).

## State guards

Create, block and unblock run under the network row lock. Block and unblock
write only while the stored state is `active` or `blocked`; on a `deleted` row
they answer 403 `key_deleted` and change nothing.

## Public key GET

`GET /encryption/networks/<network>/keys/<path>` without `Authorization`:

- Under `apps/` every path answers 404 `key_not_found`, with the same body and
headers whether the key exists, is blocked or deleted, is missing, or
belongs to another owner or network.
- Elsewhere it answers the derived public key for every valid path on an active
network.

The authenticated GET (a `key-describe/v1` invocation) returns the full
descriptor, including `kind` (`deterministic` or `deletable`) and `state`
(`active`, `blocked` or `deleted`).

## Decrypt

`POST /invoke` with `decrypt` on the exact key. For a deletable key the
request's `keyVersion` must equal the row's version, otherwise 403
`key_deleted`; a `deleted` row answers 403 `key_deleted`.

## SIWE roots under `apps/<app id>`

A SIWE (CACAO) delegation issued by the key owner that carries any ability
other than `key.block` on a key or prefix under `apps/<app id>` requires the
app id to be bound to the SIWE `domain`. The SIWE `scheme` is absent or
`https`. The domain has no port (and no empty port or user info), is a DNS name
of at least two labels (no IP literal, no `localhost`, no trailing dot), and the
app id equals its labels reversed and joined with `.`, or starts with that and
`.` (the domain is compared in its lowercase ASCII form). UCAN delegations are
not checked. A refusal answers `app_id_not_bound`.

## Startup check

The node does not start while a row in `encryption_network_key` under `apps/`
(or at the path `apps`) has a kind other than `deletable`. List such rows with:

```sql
SELECT network_id, key_path, kind FROM encryption_network_key
WHERE (key_path = 'apps' OR substr(key_path, 1, 5) = 'apps/') AND kind <> 'deletable';
```

The node deletes no data; remove or move the rows, then start the node.
1 change: 1 addition & 0 deletions test/m1-realdata-e2e/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/w5-policy-runtime-node-e2e/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions tinycloud-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ ryu-js = "0.2.2"
# canonicalization. Replaces the hand-coded subset that only composed a
# narrow Latin precomposed-pair table.
unicode-normalization = "0.1"
# App ids are bound to the ASCII (punycode) form of a host name.
idna = "1"

[dev-dependencies]
sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-sqlite"] }
Expand Down
140 changes: 140 additions & 0 deletions tinycloud-core/src/encryption_network/app_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! App ids under the reserved `apps/` key segment.
//!
//! A key path that starts with `apps/` is `apps/<app id>/<name>[/<more>]`. The
//! app id is validated, never transformed, and must match
//! `^[a-z0-9][a-z0-9._-]{0,63}$` as a whole string (ASCII only; a trailing
//! newline fails).
//!
//! An app id is bound to a host when it equals the host's labels reversed and
//! joined with `.`, or starts with that string followed by `.`. The host is
//! compared in its lowercase ASCII (punycode) form and needs at least two
//! labels.

use super::key_id::valid_segment;

/// Whether `id` is a valid app id.
pub fn app_id_valid(id: &str) -> bool {
valid_segment(id)
}

/// Whether `app_id` is bound to `host`. `host` is a bare host name: no scheme,
/// no port. IP literals, `localhost` names, a trailing dot and single-label
/// hosts are never bound.
pub fn app_id_bound_to_host(host: &str, app_id: &str) -> bool {
if !app_id_valid(app_id) {
return false;
}
let Some(reversed) = reversed_ascii_host(host) else {
return false;
};
app_id == reversed
|| app_id
.strip_prefix(reversed.as_str())
.is_some_and(|rest| rest.starts_with('.'))
}

/// The host in lowercase ASCII form, labels reversed and joined with `.`, or
/// `None` when the host is not an eligible DNS name.
fn reversed_ascii_host(host: &str) -> Option<String> {
if host.is_empty() || host.ends_with('.') || host.starts_with('[') {
return None;
}
let ascii = idna::domain_to_ascii(host).ok()?;
let labels: Vec<&str> = ascii.split('.').collect();
if labels.len() < 2 || !labels.iter().all(|label| dns_label(label)) {
return None;
}
let last = labels.last().copied().unwrap_or_default();
// An IPv4 form (`1.0.0.127`, `127.1`, `0x7f.1`) is not a DNS name.
if last.bytes().all(|b| b.is_ascii_digit()) || last.starts_with("0x") {
return None;
}
if last == "localhost" {
return None;
}
let mut reversed = String::with_capacity(ascii.len());
for (index, label) in labels.iter().rev().enumerate() {
if index > 0 {
reversed.push('.');
}
reversed.push_str(label);
}
Some(reversed)
}

fn dns_label(label: &str) -> bool {
let bytes = label.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 63
&& bytes[0] != b'-'
&& bytes[bytes.len() - 1] != b'-'
&& bytes
.iter()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;

fn vectors() -> Value {
serde_json::from_str(include_str!(
"../../tests/fixtures/encryption/app-key-vectors.json"
))
.expect("vector file parses")
}

/// Host of an `https://<host>` origin with no port, as the shared vectors
/// phrase it. Any other origin shape is not bound.
fn origin_host(origin: &str) -> Option<&str> {
let host = origin.strip_prefix("https://")?;
if host.contains(['/', ':', '@', '?', '#']) {
return None;
}
Some(host)
}

#[test]
fn shared_app_id_vectors() {
let vectors = vectors();
let cases = vectors["appIdValid"].as_array().unwrap();
assert_eq!(cases.len(), 39);
for case in cases {
let id = case["id"].as_str().unwrap();
let valid = case["valid"].as_bool().unwrap();
assert_eq!(app_id_valid(id), valid, "app id {id:?}");
}
}

#[test]
fn shared_host_binding_vectors() {
let vectors = vectors();
let cases = vectors["hostBinding"].as_array().unwrap();
assert_eq!(cases.len(), 23);
for case in cases {
let origin = case["origin"].as_str().unwrap();
let id = case["id"].as_str().unwrap();
let bound = case["bound"].as_bool().unwrap();
let actual = origin_host(origin).is_some_and(|host| app_id_bound_to_host(host, id));
assert_eq!(actual, bound, "origin {origin:?} id {id:?}");
}
}

#[test]
fn bare_hosts_with_a_port_or_scheme_are_not_bound() {
assert!(!app_id_bound_to_host(
"listen.tinycloud.xyz:8443",
"xyz.tinycloud.listen"
));
assert!(!app_id_bound_to_host(
"https://listen.tinycloud.xyz",
"xyz.tinycloud.listen"
));
assert!(!app_id_bound_to_host("", "a"));
assert!(app_id_bound_to_host(
"LISTEN.tinycloud.xyz",
"xyz.tinycloud.listen"
));
}
}
Loading
Loading