Skip to content
Draft
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 @@ -9,6 +9,7 @@
- SQL local durability and reservation before commit (`docs/sql-local-durability.md`). Postgres stays the master copy. File-backed SQL databases run `PRAGMA synchronous = FULL` (`[global.storage.sql] synchronous = "full"|"normal"`, env `TINYCLOUD_STORAGE__SQL__SYNCHRONOUS`; `normal` selects the earlier fsync behaviour), and the directories and renames that make a database file or a hydrated cache visible are fsynced. A write is checked before COMMIT and rolled back whole if it would pass the owner's remaining storage budget (`402`), and, when configured, the per-database cap (`max_database_bytes`, `507 sql_database_size_limit`) or the disk headroom (`min_free_bytes`, bounded by the WAL and checkpoint growth the transaction can cause and shared by all databases on the node, `507 sql_disk_headroom`). `max_database_bytes` and `min_free_bytes` default to `0` (off) and are set by config. A COMMIT that fails on disk answers `500 sql_commit_failed` and the node re-hydrates the database from Postgres. A write that fails because the disk is full answers `507 sql_disk_headroom`, whatever `min_free_bytes` is, not the owner's quota or the size limit. Every write request runs in one transaction (a single statement, a batch, a nested write (`WITH … INSERT`) and a request that carries a `PRAGMA` included), so such a request counts two more native SQL calls in request telemetry, and all of them get the same owner budget, size cap and disk gate. A request that wrote nothing is not gated and reserves nothing. The growth of the main file that the next checkpoint will cause stays held in the disk reservation from the commit until a checkpoint runs or the database's connection closes. No migration. Details: TC-1491.
- `MemorySink` caps its size hint at 64 MiB and treats a failed reservation as no hint. Details: TC-1491.
- A local SQL commit failure (answered 500) records the same diagnostic failure note as other SQL 500 responses. Details: TC-1491.
- UCAN DID resolution uses one fixed allow-list: `did:key` (Ed25519 keys only, exact Ed25519 identifier length), `did:pkh` (`eip155` namespace only, identifier of at most 70 characters) and `did:jwk` (Ed25519 OKP keys only), all offline. Other `did:jwk` key types are refused, matching `did:key`. Every other DID method, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, is refused with `did_method_disabled:` before any resolution or network lookup. EIP-191 issuers are parsed as `did:pkh:eip155` before signature recovery. The `did_resolution.network_methods` setting and `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS` have no effect: a configuration that sets them logs a startup warning, and neither enables any method. `GET /info` reports `did_resolution.network_methods` as a constant `false`. Details: TC-1500.

## [1.22.0] - 2026-10-23

Expand Down
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,24 +381,24 @@ TinyCloud Protocol is most easily used via the TinyCloud Protocol SDK. See the e

### UCAN DID resolution (TC-1080)

UCAN signature verification resolves only `did:key`, `did:pkh` and `did:jwk`
by default. Other methods fail before resolution with `did_method_disabled:`.
UCAN signature verification resolves only `did:key` (Ed25519 keys), `did:pkh`
(EVM, `eip155` namespace) and `did:jwk` (Ed25519 OKP keys). Other
methods, and other key types or namespaces, fail before resolution with
`did_method_disabled:`.
Issuer DID URLs carrying a query fail with `did_url_query_rejected:` under every
setting. DID parsing and string comparisons, including share-email `did:web`
identifiers, are unchanged. Existing route status codes and policy machine codes
are preserved.

Keep `[global.did_resolution] network_methods = false` in `tinycloud.toml`
or the canonical environment variable
`TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS=false`. Network DID methods, including
`did:web`, remain disabled under the current policy.

**Full TC-1080 Part B hardening is REQUIRED before any future re-enable**,
including setting the existing `network_methods` option to `true`. The
unhardened SSI client is not an acceptable rollback path. Part B is not
implemented here, and there is no Part B development bypass. The required
node-side fetcher, destination enforcement, resource limits and local-listener
acceptance checks are described in the
Network DID methods, including `did:web`, `did:tz`, `did:ethr` and `did:ion`, are
never resolved for UCAN verification. No setting or environment variable enables
them.

Network DID resolution requires the hardening described in TC-1080 Part B. This node
does not implement Part B, and no setting or development flag provides it. The
required node-side
fetcher, destination enforcement, resource limits and local-listener acceptance checks
are described in the
[developer gate](docs/delegation-flow.md#network-did-method-future-enable-gate-tc-1132).

The startup log records the effective policy. Refusals increment
Expand Down
20 changes: 10 additions & 10 deletions docs/delegation-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ The delegation hierarchy enables:

### Network DID method future-enable gate (TC-1132)

The node's UCAN issuer resolver currently permits only offline `did:key`,
`did:pkh` and `did:jwk` resolution by default. Network methods, including
`did:web`, remain disabled with `network_methods = false`. DID identifiers used
for parsing or identity comparisons (for example share-email `did:web` values)
are unaffected; this gate concerns network resolution during verification.

**Full TC-1080 plan Part B is REQUIRED before any future re-enable**, including
the existing `network_methods = true` setting. Do not restore the unhardened SSI
fetcher as a rollback. Part B is not implemented by TC-1132 Parts C/D, and no
Part B development flag or bypass is provided.
The node's UCAN issuer resolver resolves only the fixed allow-list: `did:key` (Ed25519
keys only), `did:pkh` (`eip155` namespace only) and `did:jwk`, all offline. Every other
DID method, including `did:web`, is refused before any resolution, and no setting enables
another method. DID identifiers used for parsing or identity comparisons (for example
share-email `did:web` values) are unaffected; this gate concerns network resolution
during verification.

Enabling a network DID method requires all of TC-1080 plan Part B. Part B is not
implemented by TC-1132 Parts C/D, and no development flag provides it. The SSI
fetcher is not an acceptable resolver.

Before a future change enables `did:web`, it must implement and verify all of
Part B:
Expand Down
34 changes: 33 additions & 1 deletion tinycloud-auth/src/share_email_evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,10 +567,23 @@ pub fn verify_detached_ed25519(
verify_ed25519(&public_key, message, signature)
}

fn did_key_public_key(did: &str) -> Result<[u8; 32], EvidenceError> {
/// Length of an Ed25519 `did:key` string: the `did:key:` prefix plus a 48-character identifier.
const ED25519_DID_KEY_LEN: usize = "did:key:".len() + 48;
#[cfg(test)]
thread_local! {
static DID_KEY_DECODE_ATTEMPTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Decodes an Ed25519 `did:key` into its 32-byte public key. The length is checked before base58
/// decoding, and only base58btc multibase over the `0xed 0x01` multicodec is accepted.
pub fn did_key_public_key(did: &str) -> Result<[u8; 32], EvidenceError> {
if did.len() != ED25519_DID_KEY_LEN {
return Err(EvidenceError::Invalid);
}
let encoded = did
.strip_prefix("did:key:z")
.ok_or(EvidenceError::Invalid)?;
#[cfg(test)]
DID_KEY_DECODE_ATTEMPTS.with(|c| c.set(c.get() + 1));
let (_, bytes) = crate::ipld_core::cid::multibase::decode(format!("z{encoded}"))
.map_err(|_| EvidenceError::Invalid)?;
if bytes.len() != 34 || bytes[..2] != [0xed, 0x01] {
Expand Down Expand Up @@ -801,6 +814,25 @@ fn utf16_cmp(left: &str, right: &str) -> Ordering {
mod tests {
use super::*;

#[test]
fn did_key_public_key_refuses_wrong_lengths_without_decoding() {
let key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
let short = format!("did:key:{}", &key[..47]);
let long = format!("did:key:{key}1");
let oversized = format!("did:key:z6Mk{}", "1".repeat(65_532));
let before = DID_KEY_DECODE_ATTEMPTS.with(std::cell::Cell::get);
for did in [&short, &long, &oversized] {
assert!(did_key_public_key(did).is_err());
}
assert_eq!(DID_KEY_DECODE_ATTEMPTS.with(std::cell::Cell::get), before);
// Positive control: the canonical identifier reaches the decoder exactly once.
assert!(did_key_public_key(&format!("did:key:{key}")).is_ok());
assert_eq!(
DID_KEY_DECODE_ATTEMPTS.with(std::cell::Cell::get),
before + 1
);
}

#[test]
fn email_normalization_preserves_local_and_only_lowercases_domain() {
assert_eq!(
Expand Down
Loading
Loading