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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

- 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
75 changes: 75 additions & 0 deletions docs/delegation-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,81 @@ 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.

## 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