Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9998fef
fix(TC-1071): canonicalize CACAO recovery bytes before CID
samgbafa Oct 14, 2026
63b66ce
fix(ci): sync standalone Cargo locks for cacao k256 (TC-1071)
samgbafa Oct 14, 2026
2fad717
fix: canonicalize CACAO issuers to verified SIWE identity
samgbafa Oct 14, 2026
3464a04
fix(sdk): canonicalize wallet signatures before session CID calculation
samgbafa Oct 14, 2026
2f1faec
fix(TC-1071): preserve historical CACAO revocation identities
samgbafa Oct 14, 2026
5c1c248
fix(sdk): validate signature arrays without temporary allocations
samgbafa Oct 14, 2026
3af4788
fix(core): serialize historical CID bootstrap across database boots
samgbafa Oct 14, 2026
34caaaa
fix: enforce canonical revocations in policy admission
samgbafa Oct 14, 2026
e558f5c
Merge main into TC-1071 integration
samgbafa Oct 14, 2026
bed0cf9
test: register valid signed grants before W5 restart
samgbafa Oct 14, 2026
97a2b51
Merge main into delegation update
samgbafa Oct 15, 2026
8e1b3ff
Align short-lived grant fixture timing
samgbafa Oct 15, 2026
60e6f93
test: cover revocation cutoff across policy sessions
samgbafa Oct 15, 2026
95dc940
feat: add scoped key revocation and authority witnesses
samgbafa Oct 15, 2026
255ac89
fix: validate key revocation target DIDs
samgbafa Oct 15, 2026
827e907
fix: align policy root status with authority witness
samgbafa Oct 15, 2026
077595f
test: cover signed root cutoff at acknowledgement
samgbafa Oct 15, 2026
d5e6969
Harden key revoke receipts and witness selection
samgbafa Oct 15, 2026
36f34af
test: cover key revoke expiry after lock wait
samgbafa Oct 15, 2026
9c2a9d3
TC-1149: complete revocation checks
samgbafa Oct 15, 2026
9932929
TC-1149: preserve witness startup and canonical lock ordering
samgbafa Oct 15, 2026
92cccb0
TC-1149: Stabilize PostgreSQL budget fixture
samgbafa Oct 16, 2026
e6742a1
Merge main updates
samgbafa Oct 16, 2026
693f539
Merge current main updates
samgbafa Oct 17, 2026
1997a58
Merge #291 into TC-1149
samgbafa Oct 17, 2026
8e6e764
Merge #291 into TC-1149
samgbafa Oct 17, 2026
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
12 changes: 12 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,20 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 5
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -u root"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
TINYCLOUD_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/postgres
TINYCLOUD_TEST_MYSQL_URL: mysql://root@127.0.0.1:3306/mysql
# TC-1054: tests/postgres_pool_timeouts.rs black-holes chosen loopback
# connections with exact-4-tuple iptables DROP rules (the runner is a
# throwaway VM with passwordless sudo). Unset, they fail under CI.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [Unreleased]

- Add signed, scoped and global key-principal revocation with durable receipts, policy authority witnesses, and ordered admission/invocation checks (TC-1149). Existing natural session-expiry behavior is unchanged; legacy witness backfill requires a count-only snapshot dry-run before release.
- CACAO/SIWE delegation ingestion normalizes ECDSA recovery IDs 0/1 to 27/28 before hashing and persisting the canonical DAG-CBOR bytes (TC-1071). Equivalent low-s signatures now share one delegation CID, including the CID used for revocation lookup. High-s signatures are rejected during decoding, matching the existing EIP-191 verifier and EIP-2 low-s convention; UCAN/JWS bytes remain unchanged. Existing stored delegation IDs are not rewritten.
- Synchronize the standalone M1, W5 and load-signer Cargo locks with CACAO's `k256` dependency so `--locked` dependency resolution succeeds (TC-1071). Keep the load signer compatible with the SDK's default invocation lifetime.

- Historical CACAO revocations now apply to every stored encoding of the same delegation, in both directions (TC-1071). A persisted CID-alias index derives identities from current ingestion's canonical bytes, including recovery-ID, issuer-address/chain, and accepted CBOR representation normalization. Startup backfills aliases transactionally after loading the column encryption key and fails closed on undecodable or mismatched history or missing/wrong keys. Historical IDs, signed bytes, revocation targets, and signed child proof references remain unchanged; legacy list/chain responses retain their exact raw-header/CID relationship. This closes reuse of an already owner-signed grant under another accepted encoding; it does not involve forging new rights.
- CACAO issuer address casing and numeric chain spelling now serialize to the verifier's same EIP-55/u64 identity without changing the signed SIWE message. SDK session setup checks and normalizes wallet signatures before deriving its proof CID, including direct Rust sessions; low-v wallet signatures no longer produce `MissingParents` (TC-1071).
- Serialize concurrent PostgreSQL/MySQL historical backfills before their first history read; failed startup still rolls back aliases and preserves signed records. MySQL alias columns retain full variable-length multihashes, and encrypted-key validation avoids its unsupported limited `IN` subquery. Native alias checks do not waive the separately reproduced older MySQL 8.0.46 full-schema error 1074 (TC-1071).
- Policy v3 account-owner admission, its transactional recheck, and legacy Share v2 revocation consumers now use the same effective historical-CID revocation lookup. Account-owner proofs retain shared canonical chain guards through commit or rollback, so a same-registry sibling revocation cannot pass an in-flight admission; a separately committed sibling revocation fails the transaction recheck (TC-1071).
- Rust API cutover: pass encryption through `SpaceDatabase::new_with_encryption`; list/chain outcomes carry serialized delegation wrappers. SDK exhaustive error matches must handle the new `session::Error::InvalidSIWESignature` variant (TC-1071).
- Harden optional DuckDB statement and settings policy (TC-1304).
- Tighten revocation scope checks (TC-1302).
- Account delegation session discovery keyset-pages delegation rows independently of ability fan-out, using a composite delegator/id index so large grant sets are not truncated (TC-1195).
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.

1 change: 1 addition & 0 deletions dependencies/cacao/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ serde_ipld_dagcbor.workspace = true
serde_with.workspace = true
time.workspace = true
http.workspace = true
k256 = "0.13"
hex = { workspace = true, optional = true }
serde_bytes = "0.11"
serde_repr = "0.1"
Expand Down
73 changes: 64 additions & 9 deletions dependencies/cacao/src/siwe_cacao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub struct Payload {
pub scheme: Option<Scheme>,
#[serde_as(as = "DisplayFromStr")]
pub domain: Authority,
/// Serialized as the verifier's parsed chain ID and EIP-55 address.
/// This changes only the DID representation, never the signed SIWE text.
#[serde(serialize_with = "serialize_issuer")]
pub iss: UriAbsoluteString,
pub statement: Option<String>,
pub aud: UriString,
Expand Down Expand Up @@ -119,15 +122,40 @@ pub enum SIWEPayloadConversionError {
InvalidDID,
}

fn parse_issuer(issuer: &str) -> Result<(u64, [u8; 20]), SIWEPayloadConversionError> {
let mut parts = issuer.split(':');
match [
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
] {
[Some("did"), Some("pkh"), Some("eip155"), Some(chain), Some(address), None]
if address.starts_with("0x") =>
{
Ok((chain.parse()?, FromHex::from_hex(&address[2..])?))
}
_ => Err(SIWEPayloadConversionError::InvalidDID),
}
}

fn serialize_issuer<S>(issuer: &UriAbsoluteString, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let (chain, address) = parse_issuer(issuer.as_str()).map_err(serde::ser::Error::custom)?;
serializer.collect_str(&format_args!(
"did:pkh:eip155:{chain}:0x{}",
encode_eip55(&address)
))
}

impl TryInto<Message> for Payload {
type Error = SIWEPayloadConversionError;
fn try_into(self) -> Result<Message, Self::Error> {
let (chain_id, address) = match &self.iss.as_str().split(':').collect::<Vec<&str>>()[..] {
&["did", "pkh", "eip155", c, h] if h.get(..2) == Some("0x") => {
(c.parse()?, FromHex::from_hex(&h[2..])?)
}
_ => return Err(Self::Error::InvalidDID),
};
let (chain_id, address) = parse_issuer(self.iss.as_str())?;
Ok(Message {
scheme: self.scheme,
domain: self.domain,
Expand Down Expand Up @@ -193,10 +221,30 @@ impl AsRef<[u8]> for Signature {
}
}

impl Signature {
/// Validate a fixed-size EIP-191 signature and canonicalize its recovery ID.
///
/// Low-s signatures accept `0/1` and `27/28`; invalid scalars, high-s values
/// and other recovery IDs are rejected without allocating a byte buffer.
pub fn decode(mut bytes: [u8; 65]) -> Result<Self, SIWESignatureDecodeError> {
let signature = k256::ecdsa::Signature::from_slice(&bytes[..64])
.map_err(|_| SIWESignatureDecodeError::InvalidSignature)?;
if signature.normalize_s().is_some() {
return Err(SIWESignatureDecodeError::NonCanonicalS);
}
bytes[64] = match bytes[64] {
0 | 1 => bytes[64] + 27,
27 | 28 => bytes[64],
_ => return Err(SIWESignatureDecodeError::InvalidRecoveryId(bytes[64])),
};
Ok(Self(bytes))
}
}

impl TryFrom<Vec<u8>> for Signature {
type Error = SIWESignatureDecodeError;
fn try_from(s: Vec<u8>) -> Result<Self, Self::Error> {
Ok(Self(s.try_into().map_err(SIWESignatureDecodeError::from)?))
Self::decode(s.try_into().map_err(SIWESignatureDecodeError::from)?)
}
}

Expand All @@ -206,6 +254,12 @@ pub enum SIWESignatureDecodeError {
InvalidLength(usize),
#[error("Invalid Type, expected 'eip191', got {0}")]
InvalidType(String),
#[error("Invalid ECDSA signature")]
InvalidSignature,
#[error("Non-canonical high-s ECDSA signature")]
NonCanonicalS,
#[error("Invalid recovery ID: {0}")]
InvalidRecoveryId(u8),
}

impl From<Vec<u8>> for SIWESignatureDecodeError {
Expand Down Expand Up @@ -286,11 +340,12 @@ impl<'de> Deserialize<'de> for Signature {
SIWESignatureDecodeError::InvalidLength(l),
));
}
Ok(Signature(
Signature::decode(
ds.s.try_into()
.map_err(|_| SIWESignatureDecodeError::InvalidLength(l))
.map_err(serde::de::Error::custom)?,
))
)
.map_err(serde::de::Error::custom)
}
}

Expand Down
127 changes: 127 additions & 0 deletions docs/delegation-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,133 @@ export class Authenticator {
}
```

## Historical CACAO CIDs and Revocation

New CACAO imports hash the canonical bytes returned by
`TinyCloudDelegation::decode`: accepted recovery IDs, issuer address/chain
representations, and DAG-CBOR encodings share the same ingestion identity.
UCAN/JWS bytes remain exact.

At startup, `m20261014_000000_delegation_cid_alias` creates the persisted alias
index. The database then backfills missing entries in a transaction from each
stored delegation's exact plaintext serialization, using that same decoder.
Encrypted databases must open with `SpaceDatabase::new_with_encryption`; the
column key must be available before backfill. Missing/wrong keys, decoding
failures, and plaintext hash mismatches fail startup rather than silently
leaving an active alias. Completed backfills do not re-decode indexed history;
one indexed encrypted record is checked on each boot to validate the key.

Concurrent PostgreSQL and MySQL boot transactions lock this migration's history
row before their first history read. This also prevents a waiting MySQL boot
from establishing a stale repeatable-read snapshot. SQLite keeps its existing
`BEGIN IMMEDIATE` writer serialization. Failures roll back the boot transaction;
alias conflicts are not ignored. MySQL stores complete, unpadded multihashes in
indexed `VARBINARY(255)` columns, and the one-row encrypted-key check uses a join
with an outer limit rather than MySQL's unsupported limited `IN` subquery.

Stop old-version writers before upgrading the shared database. Older binaries
do not maintain the alias index; mixed-version writes are not a supported
security cutover. Do not accept requests until the new constructors complete.

Revocation status, invocation authorization, and ancestor checks union owner
revocations across equivalent stored CIDs through indexed lookups. Historical
delegation IDs, signed bytes, revocation targets, and child proof edges are
never rewritten. A child signed against an old CID remains valid until the
owner revokes that identity; revocation also denies descendants and new
redelegations citing any equivalent root.

Canonical chain guards use indexed alias joins in the existing bounded closure
query, so equivalent revocations serialize against in-flight ancestor
authorization without an extra request round trip. New delegations and their
alias commit together; PostgreSQL uses a dependent insert CTE to preserve the
existing registration round-trip budget.

Policy v3 account-owner credential admission, its locked transactional recheck,
and legacy Share v2 ancestry/root checks use the same effective revocation
predicate, not an exact stored-ID comparison. Account-owner authentication takes
shared canonical ancestor guards with `TinyCloud::guard_delegation_chains`
before per-space guards or the SQL transaction and retains them in the proof
through commit or rollback. These guards coordinate the database instance and
its clones through their shared graph-lock registry; they are not distributed
locks. The transaction still rechecks effective revocation, including a sibling
revocation committed by a separately constructed database instance.

Legacy capability list/chain responses advertise the stored CID and return
the exact stored raw header, not a re-encoding of the decoded CACAO. Their
parent CID fields retain the signed child references. Existing legacy list
ancestor-visibility behavior is unchanged; authorization always checks the
complete chain.

Rust callers must supply column encryption at construction through
`SpaceDatabase::new_with_encryption`, not a post-open setter.
`InvocationOutcome::OpenSessions` and `DelegationChain` retain serialized
delegation wrappers so callers can preserve the original headers rather than
re-encode them. SDK session setup validates and normalizes wallet signatures
before deriving its delegation CID, including direct Rust `SignedSession`
values that bypass JSON deserialization. Recovery IDs 0/1 and 27/28 therefore
produce the node's same proof CID; invalid encodings and high-s signatures are
rejected. Rust exhaustive matches must account for
`session::Error::InvalidSIWESignature`.

MySQL coverage remains bounded: native MySQL 8.0.46 alias-migration, backfill,
concurrent-boot, and key-validation checks do not establish full-constructor
support. On the same `utf8mb4`/default-strict configuration, unmodified
`f9d700bf` fails the older initial schema with error 1074 on `id`, before the
alias migration exists. That separate full-schema prerequisite must be resolved
and exercised before claiming end-to-end support for that configuration.

## Explicit Key Revocation and Policy Witness

`POST /keys/revoke` accepts JSON with `targetDid`, `scope` (`{"kind":"global"}`
or `{"kind":"space","spaceId":"..."}`), and `authorization`, a signed UCAN or
SIWE/CACAO addressed to this node. The signed capability names the same
target and scope. Global revocation requires the target key or its explicitly
enrolled global recovery delegate. A space owner or its scoped recovery
delegate may revoke a key only in that owner's space. Generic `/revoke`
continues to revoke a single delegation CID, not a whole key.

The signed policy-v3 JSON `/revoke` records one root's status, not a
key-wide cutoff. Fresh root events check the historical policy witness;
repeating the exact committed event remains idempotent.
Every witness-backed use checks the loaded roots' signed revocation status,
including an already-issued signed KV download ticket. This cutoff uses the
same root rows as provenance verification, without another query or lock.

`targetDid` must be a canonical DID with valid DID syntax, not a DID URL
or a delegation CID; an empty method-specific ID is rejected.

The committed response includes the target, scope, and original cutoff.
Repeating the exact signed request returns the same receipt even after the
signer is revoked or its recovery proof expires. Changed signed bytes with
the same nonce return `409` only for a still-authorized signer; a signer
without current target/scope authority receives `403`. First-use request
and recovery proof validity are checked after blocking authorization locks.

On a fresh recovery request, the node verifies the signed chain before
locking its persisted proof rows. It then locks every dependency principal
in canonical DID order, taking the target and request signer exclusively
and other chain principals shared on their first visit. No principal lock
is upgraded within the transaction; the locks cover the tombstone and
receipt commit.

The cutoff remains immutable; successful responses follow its durable commit.
Later policy mints, delegation admission, and invocations cannot use authority
derived from the revoked principal. Natural proof expiry is not an explicit
revoke.

Policy registration persists a versioned, signed-byte-bound witness for its
authority dependencies. The signed candidate limit counts relevant closures,
not unrelated incoming grants. Preexisting registrations receive a conservative
transactional backfill at startup; inconsistent or incomplete provenance
prevents serving rather than silently narrowing dependencies. Before release,
an operator runs `policy-witness-dry-run` with the node's configuration on a
production snapshot or read replica. It opens read-only and reports only
aggregate `ok`, `missing`, `ambiguous`, and `overflow` counts. Any unresolved
rows require a release decision; the startup backfill is not a substitute for
the dry-run gate. Owner-self classification remains `unknown/legacy` in this
phase; natural sign-in expiry retains existing behavior. Signed self-session
classification is tracked separately in TC-1213.

## Child Delegation (UCAN) Creation

For sharing, child UCANs are created that delegate from the session key to another DID.
Expand Down
1 change: 1 addition & 0 deletions test/load/signer/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/load/signer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ async fn new_user(wallet: LocalWallet, jwk: JWK) -> User {
jwk: Some(jwk),
delegate_uri: None,
nonce: None,
invocation_ttl_secs: None,
issued_at: TimeStamp::from_str("1985-04-12T23:20:50.52Z").unwrap(),
expiration_time: TimeStamp::from_str("2985-04-12T23:20:50.52Z").unwrap(),
};
Expand Down
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.

7 changes: 6 additions & 1 deletion test/m1-realdata-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ named-statement caveats, and the minimum/maximum TTL bounds. Producer and audit
cases remain explicitly assigned to m1-g-07 in `vendor/grant-output/
SKIP_MANIFEST.json`.

The one-second live grant is signed for the next whole Unix second and
submitted once its validity window opens, matching the emitter's integer
`nbf`/`exp` timestamps. Successful real-node use and denial after expiry are
both required; frozen vectors and production clocks are unchanged.

The pinned node has no production route for initial space creation. Following
the W5 recipe, this test directly provisions one space row and its storage
directory solely as a node-storage precondition, then proves delegation and
Expand All @@ -34,7 +39,7 @@ correspondence with every frozen case.
Run it explicitly with the pinned private git dependency:

```sh
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --manifest-path test/m1-realdata-e2e/Cargo.toml
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo test --locked --manifest-path test/m1-realdata-e2e/Cargo.toml
```

## Live M1 gate runner and independent verifier
Expand Down
12 changes: 11 additions & 1 deletion test/m1-realdata-e2e/tests/cross_layer_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1366,7 +1366,14 @@ async fn observe_grant_output_on_real_node(
];
for (case_name, capabilities, ttl_seconds) in interop_cases {
let issued_capabilities = capabilities.clone();
let issued_at = Utc::now();
// The issuer encodes nbf/exp as whole Unix seconds. Prepare the
// one-second grant before its window, not at an arbitrary fraction.
let issued_at = if ttl_seconds == 1 {
DateTime::from_timestamp(Utc::now().timestamp() + 1, 0)
.context("minimum-TTL whole-second issuance")?
} else {
Utc::now()
};
let mut engine_policy = policy(&space_id, &grant_issuer_did);
engine_policy.policy_id = format!("pol_m1_g08_{case_name}");
engine_policy.resource.permissions_ceiling = capabilities.clone();
Expand All @@ -1383,6 +1390,9 @@ async fn observe_grant_output_on_real_node(
evidence_provenance: Vec::new(),
policy: engine_policy,
})?;
if let Ok(until_valid) = (issued_at - Utc::now()).to_std() {
tokio::time::sleep(until_valid).await;
}
let emitted_response = client
.post("/delegate")
.header(Header::new("Authorization", emitted.encoded.clone()))
Expand Down
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.

Loading
Loading