From c3622ba4e48466d1a5becd70ecd71f3d3ad9e73b Mon Sep 17 00:00:00 2001 From: rsvistel <34888369+rsvistel@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:28:45 +0100 Subject: [PATCH 1/5] chore(release): prepare tinycloud-node 1.17.0 --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- tinycloud-node-server/Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19c1145..d37215ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.17.0] - 2026-09-15 + +- Add the fixed TinyChat meeting publication v3 boundary: conditional reservation and publication, immutable digest-verified snapshots, retained aliases, and idempotent cleanup. Activation is explicit and fences legacy catalog writes; it does not automatically convert old records. +- Add a per-space pause for legacy meeting artifact writes with generation-checked freeze/release controls. The pause drains earlier KV commits, persists across restart, preserves ordinary chat and native snapshot publication, and remains releasable when content storage is full. This adds the central `meeting_legacy_write_guard` migration. Older binaries that do not recognize that migration cannot be used as a direct rollback; activated catalogs also require the compatible publication protocol. +- Preserve integral, fractional and null legacy REAL durations during meeting reservation and publication. +- Serialize SQLite graph transactions for invocation replay and SQL artifact persistence to avoid competing local writers. + ## [1.16.0] - 2026-08-21 - Embed Policy v3 admission and control in the Node and move its routes off the Share namespace: `/share/v3/{policy/challenges,policy/delegations,policies,enforcer-bindings,deliveries/authorize,policy/status}` are now Node-owned `/policy/v3/{challenges,delegations,policies,enforcer-bindings,deliveries/authorize,status}`. Browser holder-bound exact-email credentials are admitted there, and the delegation the Node mints is then exercised over the ordinary `/delegate` and `/invoke` data plane, so no Share-specific data path remains on the Node (TC-500). diff --git a/Cargo.lock b/Cargo.lock index 1d61142d..7f8d1d07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9679,7 +9679,7 @@ dependencies = [ [[package]] name = "tinycloud-node" -version = "1.16.0" +version = "1.17.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/tinycloud-node-server/Cargo.toml b/tinycloud-node-server/Cargo.toml index 3f009558..8344b7a7 100644 --- a/tinycloud-node-server/Cargo.toml +++ b/tinycloud-node-server/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tinycloud-node" build = "build.rs" -version = "1.16.0" +version = "1.17.0" authors = ["TinyCloud Protocol"] edition = "2021" description = "TinyCloud Protocol Node" From a99e2fa793c32c0e5040a185fc52aca0b0a74944 Mon Sep 17 00:00:00 2001 From: rsvistel <34888369+rsvistel@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:40:44 +0100 Subject: [PATCH 2/5] Prepare digest-pinned native release promotion --- .github/workflows/docker.yml | 98 +++++++++++++++++++-------- docs/meeting-publication-migration.md | 57 ++++++++++++++++ 2 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 docs/meeting-publication-migration.md diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f62eac69..4b368505 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -43,6 +43,11 @@ on: required: false type: string default: '' + deploy_image_digest: + description: 'Promote an existing sha256 digest without rebuilding. Requires deploy_phala, matching image_version, and default dstack features.' + required: false + type: string + default: '' env: REGISTRY: ghcr.io @@ -68,6 +73,10 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} INPUT_IMAGE_VERSION: ${{ inputs.image_version }} + INPUT_DEPLOY_DIGEST: ${{ inputs.deploy_image_digest }} + INPUT_DEPLOY_PHALA: ${{ inputs.deploy_phala }} + INCLUDE_DUCKDB: ${{ inputs.include_duckdb }} + INCLUDE_TC_BENCH_V1: ${{ inputs.include_tc_bench_v1 }} RELEASE_TAG: ${{ github.event.release.tag_name }} run: | set -euo pipefail @@ -101,10 +110,19 @@ jobs: if [ -n "${INPUT_IMAGE_VERSION}" ]; then check_matches "image_version input" "${INPUT_IMAGE_VERSION}" fi + if [ -n "${INPUT_DEPLOY_DIGEST}" ]; then + if [ "${EVENT_NAME}" != "workflow_dispatch" ] || [ "${INPUT_DEPLOY_PHALA}" != "true" ] || [ -z "${INPUT_IMAGE_VERSION}" ] \ + || [ "${INCLUDE_DUCKDB}" = "true" ] || [ "${INCLUDE_TC_BENCH_V1}" = "true" ] \ + || ! [[ "${INPUT_DEPLOY_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Digest promotion requires deploy_phala=true, matching image_version, default dstack features and a sha256 digest." + exit 1 + fi + fi build: runs-on: ubuntu-latest needs: [version-guard] + if: inputs.deploy_image_digest == '' permissions: contents: read packages: write @@ -166,7 +184,9 @@ jobs: build-dstack: runs-on: ubuntu-latest needs: [version-guard] - if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref == 'refs/heads/main') + if: inputs.deploy_image_digest == '' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref == 'refs/heads/main')) + outputs: + digest: ${{ steps.build.outputs.digest }} permissions: contents: read packages: write @@ -218,6 +238,7 @@ jobs: type=raw,value=dstack${{ steps.build_features.outputs.image_suffix }},enable={{is_default_branch}} - name: Build and push dstack Docker image + id: build uses: docker/build-push-action@v5 with: context: . @@ -230,11 +251,26 @@ jobs: deploy-phala: runs-on: ubuntu-latest - needs: [build, build-dstack] - if: github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.deploy_phala) + needs: [version-guard, build, build-dstack] + if: >- + ${{ !cancelled() && needs.version-guard.result == 'success' && + (github.event_name == 'release' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || + (github.event_name == 'workflow_dispatch' && inputs.deploy_phala)) && + ((inputs.deploy_image_digest != '' && needs.build.result == 'skipped' && needs.build-dstack.result == 'skipped') || + (inputs.deploy_image_digest == '' && needs.build.result == 'success' && needs.build-dstack.result == 'success')) }} + permissions: + contents: read + packages: read steps: - uses: actions/checkout@v4 + - name: Log in to GHCR for digest validation + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -248,34 +284,38 @@ jobs: - name: Install Phala CLI run: npm install -g phala@1.1.19 - - name: Update compose with release tag + - name: Validate and pin deployment image + env: + INPUT_DEPLOY_DIGEST: ${{ inputs.deploy_image_digest }} + BUILD_DIGEST: ${{ needs.build-dstack.outputs.digest }} + CARGO_VERSION: ${{ needs.version-guard.outputs.cargo_version }} + INCLUDE_DUCKDB: ${{ inputs.include_duckdb }} + INCLUDE_TC_BENCH_V1: ${{ inputs.include_tc_bench_v1 }} run: | - if [ "${{ github.event_name }}" = "release" ]; then - TAG="${{ github.event.release.tag_name }}" - else - TAG="${{ inputs.image_version }}" - if [ -z "${TAG}" ]; then - if [ "${GITHUB_REF_TYPE}" != "tag" ]; then - echo "::error::workflow_dispatch deploy_phala requires image_version unless the workflow is run from a tag" - exit 1 - fi - TAG="${GITHUB_REF_NAME}" - fi - fi - # metadata-action emits {{version}} without the leading v (e.g. 1.3.0). - # Strip a leading v from the tag to match the pushed image tag. - VERSION="${TAG#v}" - DUCKDB_SUFFIX="" - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.include_duckdb }}" = "true" ]; then - DUCKDB_SUFFIX="-duckdb" + set -euo pipefail + DIGEST="${INPUT_DEPLOY_DIGEST:-${BUILD_DIGEST}}" + [[ "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::Invalid deployment digest"; exit 1; } + export RESOLVED_IMAGE="${REGISTRY}/${IMAGE_NAME}@${DIGEST}" + docker pull "${RESOLVED_IMAGE}" + REVISION="$(docker image inspect "${RESOLVED_IMAGE}" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" + VERSION="$(docker image inspect "${RESOLVED_IMAGE}" --format '{{ index .Config.Labels "org.opencontainers.image.version" }}')" + EXPECTED="${CARGO_VERSION}-dstack" + if [ "${INCLUDE_DUCKDB}" = "true" ]; then EXPECTED="${EXPECTED}-duckdb"; fi + if [ "${INCLUDE_TC_BENCH_V1}" = "true" ]; then EXPECTED="${EXPECTED}-tc-bench-v1"; fi + if [ "${REVISION}" != "$(git rev-parse HEAD)" ] || [ "${VERSION}" != "${EXPECTED}" ]; then + echo "::error::Image revision/version labels do not match the selected checkout and requested features" + exit 1 fi - # The prod CVM uses the dstack-suffixed image. Replace the floating - # ":dstack" tag in the checked-in compose with the versioned tag - # built by the build-dstack job (e.g. ":1.3.0-dstack" or - # ":1.3.0-dstack-duckdb"). - sed -i "s|ghcr.io/tinycloudlabs/tinycloud-node:dstack|ghcr.io/tinycloudlabs/tinycloud-node:${VERSION}-dstack${DUCKDB_SUFFIX}|g" docker-compose.dstack-postgres.yaml - echo "Resolved image tag: ghcr.io/tinycloudlabs/tinycloud-node:${VERSION}-dstack${DUCKDB_SUFFIX}" - cat docker-compose.dstack-postgres.yaml + python3 - <<'PY' + import os + from pathlib import Path + path = Path('docker-compose.dstack-postgres.yaml') + content = path.read_text() + original = 'ghcr.io/tinycloudlabs/tinycloud-node:dstack' + assert content.count(original) == 1, 'Expected exactly one native image reference' + path.write_text(content.replace(original, os.environ['RESOLVED_IMAGE'])) + PY + echo "Resolved image: ${RESOLVED_IMAGE} (revision ${REVISION}, version ${VERSION})" - name: Deploy to Phala Cloud env: diff --git a/docs/meeting-publication-migration.md b/docs/meeting-publication-migration.md new file mode 100644 index 00000000..7bd7baef --- /dev/null +++ b/docs/meeting-publication-migration.md @@ -0,0 +1,57 @@ +# Migrating legacy meeting artifacts + +Native meeting publication v3 publishes immutable, digest-verified snapshots through the existing authenticated SQL service. Activation adds the connector publication schema and rejects generic writes to protected catalog tables. It does not verify or convert legacy bodies automatically. + +A migration should inventory and validate the existing catalog and raw KV bodies, preserve a private copy and resumable operation plan, pause legacy artifact writes, activate the SQL fence, and revalidate the plan before publication. Verify every published copy before releasing the legacy KV pause. Keep the original capture extent and provenance unknown unless independently established. + +## Temporary write barrier + +These controls use the existing `tinycloud.meetingPublication.v3` statement at the exact SQL path `xyz.tinycloud.tinychat/connectors`. They require `tinycloud.sql/write` and the existing unconstrained ancestor-chain authority. Their SQL result has a `receipt` column containing one JSON string. + +```json +{"contractVersion":3,"operation":"legacy_freeze_status"} +``` + +A fresh space reports: + +```json +{"contractVersion":3,"legacyWritesFrozen":false,"legacyFreezeGeneration":0} +``` + +Persist the expected generation before issuing a control request: + +```json +{"contractVersion":3,"operation":"freeze_legacy","expectedGeneration":0} +``` + +The successful receipt reports `legacyWritesFrozen:true` and `legacyFreezeGeneration:1`. After publication and verification, release that generation: + +```json +{"contractVersion":3,"operation":"unfreeze_legacy","expectedGeneration":1} +``` + +Release reports `legacyWritesFrozen:false` and `legacyFreezeGeneration:2`. Each actual transition increments the generation. An immediate identical retry returns the same result; a stale request from an older cycle fails with `legacy_freeze_generation_conflict` (HTTP 400). Invalid generation inputs also return 400. Generations distinguish migration cycles, not independent operators issuing identical simultaneous requests; coordinate one operator per migration. + +The native route advertises `legacyWriteFreeze:true` in its publication capabilities. Status, freeze and release remain available when content quota is exhausted; content-growing publication operations remain quota checked. + +## Protected scope and guarantees + +The pause applies only to these paths under `xyz.tinycloud.tinychat/connectors/{fireflies,google-meet,tinycloud-transcriber}/`: + +- `transcript/…` +- `meeting/…` +- `archive-copy/transcript/…` + +Reads, chat keys, cursors, credentials, other spaces and native snapshot publication remain available. Frozen ordinary KV put/delete returns HTTP 409. Frozen native delete/purge returns HTTP 403 before known catalog mutation. The core guard also rejects internal legacy cleanup. + +The durable guard is locked by the protected KV mutation transaction before its first database read. PostgreSQL row locking and SQLite writer serialization hold that lock through storage persistence and commit. A freeze acknowledgement therefore drains earlier protected KV commits. Failed guard reads fail closed, and restarting the node does not release a pause. + +The early native delete/purge check does not make the separate SQL publication and KV cleanup transactions globally atomic across multiple instances. Deploy compatible code to every traffic-serving node and coordinate migration writers. Freeze alone does not fence generic SQL; activation supplies that separate catalog fence. + +Release allows later legacy artifact mutations. It does not deactivate the SQL fence or weaken immutable snapshots, which retain the verified original body independently. Older writers cannot publish to an activated catalog and should be replaced by compatible clients. + +## Rollout and recovery + +The central `meeting_legacy_write_guard` migration creates the table without automatically freezing a space. Old binaries that do not recognize its migration name may reject startup against the upgraded database. Its down operation refuses to silently discard the guard generation. A rollback build must recognize the migration; after catalog activation it must also retain the publication protocol and writer fences. + +Keep a compatible node running for inspection, repair and resume. Retain the original private plan and operation IDs after interruption or a lost acknowledgement. Do not invent new operations to recover uncertain publications, edit migration history, or restore a shared database wholesale to recover one space. From 0b8ef1c78d1cc4b86556b3fcd0ee973e4df3c9c6 Mon Sep 17 00:00:00 2001 From: rsvistel <34888369+rsvistel@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:01:45 +0100 Subject: [PATCH 3/5] Document Rust error compatibility in the 1.17.0 release --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d37215ec..e6fbd811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [1.17.0] - 2026-09-15 +- Box large unauthorized resource payloads for current Rust Clippy checks; the two Rust error constructors now take `Box`, with unchanged authorization decisions and error messages. - Add the fixed TinyChat meeting publication v3 boundary: conditional reservation and publication, immutable digest-verified snapshots, retained aliases, and idempotent cleanup. Activation is explicit and fences legacy catalog writes; it does not automatically convert old records. - Add a per-space pause for legacy meeting artifact writes with generation-checked freeze/release controls. The pause drains earlier KV commits, persists across restart, preserves ordinary chat and native snapshot publication, and remains releasable when content storage is full. This adds the central `meeting_legacy_write_guard` migration. Older binaries that do not recognize that migration cannot be used as a direct rollback; activated catalogs also require the compatible publication protocol. - Preserve integral, fractional and null legacy REAL durations during meeting reservation and publication. From bc2aae77cfda7fded03ef8fc2c0b5f94f516a62a Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Mon, 14 Sep 2026 22:32:29 -0400 Subject: [PATCH 4/5] feat(TC-500): integrate native sharing with TinyChat release (#234) --- .github/workflows/docker.yml | 1 + .../export-share-invitation-descriptor.yml | 2 +- .github/workflows/release-plz.yml | 8 +- .github/workflows/rust.yml | 30 +- Dockerfile | 5 +- README.md | 4 +- deploy/share-email/README.md | 119 +- .../production-trust-bundle-contract.md | 68 + deploy/share-email/tinycloud.toml.example | 5 +- rust-toolchain.toml | 8 + scripts/check-deployment-policy-probes.mjs | 1 + tinycloud-node-server/src/config.rs | 101 +- tinycloud-node-server/src/policy_v3.rs | 1425 ++++++++++++++--- tinycloud-node-server/src/routes/mod.rs | 8 +- 14 files changed, 1405 insertions(+), 380 deletions(-) create mode 100644 deploy/share-email/production-trust-bundle-contract.md create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4b368505..885e4a49 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -448,6 +448,7 @@ jobs: /policy/v3/policies /policy/v3/challenges /policy/v3/delegations + /policy/v3/deliveries/authorize ) # The direct TEE ingress obtains/loads its certificate after the diff --git a/.github/workflows/export-share-invitation-descriptor.yml b/.github/workflows/export-share-invitation-descriptor.yml index e22d7462..bec31851 100644 --- a/.github/workflows/export-share-invitation-descriptor.yml +++ b/.github/workflows/export-share-invitation-descriptor.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - name: Test the public descriptor exporter run: cargo test -p tinycloud-node --bin export-share-invitation-descriptor diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index f74ec741..7785f415 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -20,8 +20,8 @@ jobs: fetch-depth: 0 token: ${{ secrets.RELEASE_PLZ_TOKEN }} - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 - name: Run release-plz id: release_pr @@ -80,8 +80,8 @@ jobs: fetch-depth: 0 token: ${{ secrets.RELEASE_PLZ_TOKEN }} - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 - name: Run release-plz release uses: release-plz/action@v0.5 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 29993f4c..5a7a53c2 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,6 +32,11 @@ jobs: - name: Checkout TinyCloud repository uses: actions/checkout@v4 + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + with: + components: clippy, rustfmt + - name: Add the wasm32 target run: rustup target add wasm32-unknown-unknown @@ -65,6 +70,11 @@ jobs: - name: Checkout TinyCloud repository uses: actions/checkout@v4 + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + with: + components: clippy, rustfmt + # TC-381: this job used to run `cargo test -p tinycloud-core postgres_`. # libtest exits 0 when a name filter matches zero tests, and all four # targets returned early when TINYCLOUD_TEST_POSTGRES_URL was unset — so @@ -106,6 +116,11 @@ jobs: - name: Checkout TinyCloud repository uses: actions/checkout@v4 + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + with: + components: clippy, rustfmt + - name: Add the wasm32 target run: rustup target add wasm32-unknown-unknown @@ -121,6 +136,11 @@ jobs: - name: Checkout TinyCloud repository uses: actions/checkout@v4 + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + with: + components: clippy, rustfmt + - name: Fmt run: cargo fmt --all -- --check @@ -129,12 +149,14 @@ jobs: steps: - name: Checkout TinyCloud repository uses: actions/checkout@v4 + - name: Install reviewed Rust toolchain + uses: dtolnay/rust-toolchain@1.97.1 + with: + components: clippy, rustfmt - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 - - name: Ensure rustfmt is available - run: rustup component add rustfmt - name: Verify generated capability artifacts match capabilities.json run: node scripts/gen-capabilities.mjs --check - name: Verify deployed Policy/v3 probe contract @@ -151,9 +173,9 @@ jobs: steps: - name: Checkout TinyCloud repository uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 with: - components: rustfmt + components: clippy, rustfmt - uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/Dockerfile b/Dockerfile index 9fcd03a1..c6b4514a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,14 +3,16 @@ ARG RUNTIME_BASE=scratch # Optional: pass "dstack", "duckdb", or "dstack duckdb" to enable build features. ARG CARGO_FEATURES="" -FROM rust:alpine AS chef +FROM rust:1.97.1-alpine AS chef RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static g++ perl make +RUN test "$(rustc -V | awk '{print $2}')" = "1.97.1" RUN cargo install cargo-chef WORKDIR /app FROM chef AS planner COPY ./Cargo.lock ./ COPY ./Cargo.toml ./ +COPY ./rust-toolchain.toml ./ COPY ./tinycloud-node-server/ ./tinycloud-node-server/ COPY ./tinycloud-auth/ ./tinycloud-auth/ COPY ./tinycloud-core/ ./tinycloud-core/ @@ -27,6 +29,7 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder ARG CARGO_FEATURES="" COPY --from=planner /app/recipe.json recipe.json +COPY --from=planner /app/rust-toolchain.toml ./ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ if [ -n "$CARGO_FEATURES" ]; then \ diff --git a/README.md b/README.md index df011aff..04c3863a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,9 @@ With telemetry enabled, backend time-to-first-byte is recorded under the `server ## Quickstart -To run TinyCloud Protocol locally you will need the latest version of [rust](https://rustup.rs). +To run TinyCloud Protocol locally, install [Rustup](https://rustup.rs). The +repository selects the reviewed Rust `1.97.1` toolchain automatically through +`rust-toolchain.toml`; do not substitute a floating `stable` compiler. You will need to create a directory for TinyCloud Protocol to store data in: diff --git a/deploy/share-email/README.md b/deploy/share-email/README.md index e3db84dd..0946415a 100644 --- a/deploy/share-email/README.md +++ b/deploy/share-email/README.md @@ -1,79 +1,40 @@ -# TinyCloud Node share-email deployment - -This deployment consumes Share contract commit -`36f6c4303eca3bee917692c77237c264b4dfa342` and manifest digest -`pl8-1Rpx_DYCBjOpK3hRrLfrSVDINNFssZDfFw6BMTs`. A different digest or an -ancestor-only pin is a release failure. - -tinycloud.toml.example is the checked-in, non-secret configuration shape for -an enabled exact-email node. Copy it out of the repository, fill in the -operator-delivered paths and mount it with TINYCLOUD_CONFIG_FILE. The single -mounted trust-bundle path is the only production source for the public trust -tuple; missing or inconsistent legacy field overrides fail closed. Never put a -private key, database password, claim, credential, or token in the file. - -## Delivering the trust bundle (TC-397) - -The trust document has two interchangeable delivery forms, and exactly one may -be configured — setting both is a startup error, because two sources for one -document is the divergence the shared bundle exists to prevent. - -- `trust_bundle_path` / `TINYCLOUD_SHARE_EMAIL__TRUST_BUNDLE_PATH` — a - read-only mounted file. Use this wherever a host filesystem exists. -- `trust_bundle_base64` / `TINYCLOUD_SHARE_EMAIL__TRUST_BUNDLE_BASE64` — the - same bytes, base64-encoded, inline in the environment. - -The inline form exists because the dstack/Phala target admits nothing else. -The release image's runtime stage is `FROM scratch`, so there is no shell and -no `base64` binary — the decode-to-tmpfs entrypoint `share-api`'s compose file -uses cannot be reproduced here — and a Phala deployment uploads only a compose -file, so there is no host path to bind-mount a bundle from. An opaque -environment variable is the one channel that reaches the container. It is -base64 rather than raw JSON because Figment's `Env` provider interprets brace- -and bracket-delimited values as structured data; a base64 token passes through -Figment, YAML and dstack's sealed environment storage byte-for-byte. - -Produce it from the reviewed document without a trailing newline or line -wrapping: - -```sh -SHARE_TRUST_BUNDLE_BASE64="$(base64 < trust-bundle.production.json | tr -d '\n')" -``` - -`share-api` reads the same document from a variable of the same name and in the -same encoding, so a single sealed value can feed both services and cannot drift -between them. - -### `emailOrigin` - -The document carries an `emailOrigin` field that Share's schema requires (it -feeds the CSP `connect-src` without which the browser blocks the send). The -node validates it — canonical HTTPS origin, no path, query, fragment, port or -credentials, and covered by the production placeholder scan — but does not -consume it, exactly like `shareOrigin` and `registryOrigin`. It is optional on -this side so that adding it did not become a breaking change to an unchanged -document version; the requirement is enforced by Share, its only consumer. -Unknown fields are still rejected. - -The staging compose file consumes that mounted config and has no development -or test fallback. It requires an immutable image reference, a PostgreSQL URL, -the CA bundle, issuer and invitation public keys, the signed authority bundle, -and the node key source. The node then refuses startup when any of these are -partial or inconsistent: - -- `allowed_origins` is exactly `https://share.tinycloud.xyz`; wildcard CORS is - never accepted for the share routes. -- issuer DID, `opencredentials.email/v1`, issuer `kid`, key version, and - public key form one pinned trust tuple. -- invitation `kid` and public key match the node signer derived from - `TINYCLOUD_KEYS_SECRET`. -- the authority bundle contains cryptographically verified policy and - enforcement material, enrollment, two fresh status observations, and a - current runtime attestation. -- PostgreSQL uses `sslmode=verify-full` and the configured CA bundle exists. -- the database transaction and all signed evidence pass the startup readiness - probe before `/info` advertises `share-email-claim`. - -The mounted fixture uses the same production composition and derives its node -signer from the configured key secret. Its generated authority artifacts are -test data only and are never accepted by this deployment template. +# TinyCloud Policy/v3 delivery trust + +This directory documents the node-side trust configuration used by native +sharing. The node stores and serves the owner's content, registers Policy/v3, +authorizes recipient invocations, and signs a narrowly scoped email-delivery +receipt. It does not upload share blobs or delegate content authority to an +email service. + +`tinycloud.toml.example` is a non-secret configuration shape. Supply exactly +one copy of the reviewed trust bundle either as a read-only file through +`trust_bundle_path` or as base64 through `trust_bundle_base64`. Configuring +both is a startup error. Private keys, database passwords, credentials, and +delivery tokens do not belong in this file. + +The durable production hand-off is the +[production trust-bundle contract](production-trust-bundle-contract.md). Share +owns the reviewed bundle after Share#102 removes its former copy; Node owns the +fail-closed reader and must receive the same bytes through the documented +environment contract before a release can boot. + +The trust bundle must bind these production origins: + +- `shareOrigin`: `https://share.tinycloud.xyz` +- `registryOrigin`: `https://registry.tinycloud.xyz` (node discovery only) +- `emailOrigin`: `https://witness.credentials.org` +- the exact owner-node origin and node/enforcer identities + +`emailOrigin` is the separately validated audience of the short-lived, +single-use generic credential-invitation authorization. It currently equals +the OpenCredentials issuer origin because that origin receives +`POST /v1/credential-invitations`; the Node does not infer it from credential +issuance metadata. The node validates the requested recipient, +share URL, label, issuer, audience, expiry, and JTI against the registered +delegation before it signs. The email delivery service can then send that exact invitation; +it cannot mint policy, read content, proxy an invocation, or receive a bearer +fragment. + +The production node still fails closed on inconsistent origins, keys, +attestation, authority material, or PostgreSQL TLS configuration. The mounted +fixture uses the same validation with test-only authority artifacts. diff --git a/deploy/share-email/production-trust-bundle-contract.md b/deploy/share-email/production-trust-bundle-contract.md new file mode 100644 index 00000000..b9a65cf8 --- /dev/null +++ b/deploy/share-email/production-trust-bundle-contract.md @@ -0,0 +1,68 @@ +# Production share-email trust-bundle contract + +This is the durable hand-off contract for the reviewed public trust bundle. It +contains no private key material. Share owns the reviewed JSON artifact after +Share#102; TinyCloud Node consumes exactly one copy and refuses to start the +share-email capability if its fields do not meet this contract. + +## Required fields + +The JSON version is `tinycloud.share-email-trust-bundle/v1`. Its production +origins are exact strings: + +- `shareOrigin` and `returnOrigin`: `https://share.tinycloud.xyz` +- `registryOrigin`: `https://registry.tinycloud.xyz` +- `credentialsOrigin`: `https://witness.credentials.org` +- `emailOrigin`: `https://witness.credentials.org` + +`emailOrigin` is the independently checked generic invitation-delivery +audience, currently co-located with credential issuance at +`https://witness.credentials.org`. It is not a route served by Node and must +match the origin receiving `POST /v1/credential-invitations`. +Node exposes Policy/v3 admission/control and ordinary `/delegate` and `/invoke` +data-plane routes only; it does not expose `/share` routes or proxy delivery. + +For addressed delivery, Share sends the SDK's `sealedEnvelope` and +`envelopeKey` request fields. The recipient-bearing, owner-signed envelope is +AES-256-GCM sealed; its CID addresses `/s/` and its envelope key is kept +only in `#k=`. This key unwraps share-envelope metadata, not document +content. Node rejects plaintext recipient envelopes in a query or path and +checks that the sealed CID, decrypted canonical envelope, exact recipient, and +delivery authorization all agree. + +The node identity must be internally exact, not merely a canonical DID: + +- `nodeAudience` is `did:web:`; +- `nodeInvitationKid` is + `#invitation-key-` and the version is positive; +- `nodeInvitationPublicKey` exactly equals the public descriptor derived by the + production Node `TINYCLOUD_KEYS_SECRET`; and +- `nodeEnabled` is `true`. + +The issuer identity is exact: `issuerDid` is +`did:web:issuer.credentials.org`, `issuerVct` is +`opencredentials.email/v1`, `issuerKid` belongs to that DID, its key version is +positive, its public key is canonical, and `issuerEnabled` is `true`. + +The separately owner-signed authority material must bind every Policy/v3 +`enforcerDid` to the same Node `nodeAudience`; the enforcer binding signature +is checked against that Node's derived attestation key at registration. This +keeps the concrete enforcer identity coupled to the concrete Node identity +without copying a deployment-specific DID into source control. + +## Delivery and release hand-off + +Share serializes the reviewed JSON compactly and base64-encodes it without +line wrapping. Before a Node release, the release owner places that exact value +in the GitHub secret `PROD_TINYCLOUD_SHARE_TRUST_BUNDLE_BASE64`. The deploy +workflow passes it as `SHARE_TRUST_BUNDLE_BASE64`; the Node runtime consumes it +as `TINYCLOUD_SHARE_EMAIL__TRUST_BUNDLE_BASE64`. A mounted deployment instead +sets `TINYCLOUD_SHARE_EMAIL_TRUST_BUNDLE` to the reviewed JSON file and uses +`trust_bundle_path`. + +Do not set both sources. Do not create a substitute `api.share.tinycloud.xyz` +or `email.tinycloud.xyz` audience: Node rejects it in non-fixture builds. +Share must emit the generic OpenCredentials origin before it removes or +rotates its legacy bundle. Node validates this contract before +advertising share-email readiness, so a missing, malformed, mismatched, or +fixture bundle fails closed. diff --git a/deploy/share-email/tinycloud.toml.example b/deploy/share-email/tinycloud.toml.example index fa94089e..3214070b 100644 --- a/deploy/share-email/tinycloud.toml.example +++ b/deploy/share-email/tinycloud.toml.example @@ -15,9 +15,8 @@ enabled = true # version, and public-key fields below. No split trust environment variables # are accepted by the production compose file. trust_bundle_path = "/run/tinycloud/share-email-trust-bundle.json" -# TC-397: where a file cannot be mounted at all — the release image is -# `FROM scratch` and a dstack/Phala deployment uploads only a compose file — -# the same document arrives inline instead, base64-encoded, through +# Where a file cannot be mounted, the same document can arrive inline, +# base64-encoded, through # TINYCLOUD_SHARE_EMAIL__TRUST_BUNDLE_BASE64 (config key `trust_bundle_base64`). # Set exactly one of the two. Configuring both is a startup error: two sources # for one document is precisely the divergence this bundle exists to prevent. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..7bfdcd50 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,8 @@ +[toolchain] +# Keep local development, CI, and release builds on the reviewed compiler +# version. Do not use a floating `stable`: new deny-by-default lints can turn +# an otherwise unchanged release head red. +channel = "1.97.1" +profile = "minimal" +components = ["clippy", "rustfmt"] +targets = ["wasm32-unknown-unknown"] diff --git a/scripts/check-deployment-policy-probes.mjs b/scripts/check-deployment-policy-probes.mjs index 7c9072d5..af5c3faf 100644 --- a/scripts/check-deployment-policy-probes.mjs +++ b/scripts/check-deployment-policy-probes.mjs @@ -5,6 +5,7 @@ const expectedRoutes = [ "/policy/v3/policies", "/policy/v3/challenges", "/policy/v3/delegations", + "/policy/v3/deliveries/authorize", ]; const workflow = readFileSync(".github/workflows/docker.yml", "utf8"); const source = readFileSync("tinycloud-node-server/src/policy_v3.rs", "utf8"); diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index faefbd6a..1ab7ce97 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -14,6 +14,11 @@ use serde_with::{ use std::{fs, path::PathBuf}; use tinycloud_core::keys::StaticSecret; +/// The OpenCredentials service that consumes a node-authorized generic +/// credential-invitation receipt in production. Keep this exact origin pinned: +/// a syntactically valid alternate host is a different audience. +const PRODUCTION_SHARE_EMAIL_ORIGIN: &str = "https://witness.credentials.org"; + #[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct Config { pub log: Logging, @@ -91,12 +96,9 @@ pub struct ShareEmailConfig { /// instead of as a mounted file. Canonical env form is /// `TINYCLOUD_SHARE_EMAIL__TRUST_BUNDLE_BASE64`. /// - /// The release image is `FROM scratch` — no shell, no `base64` — so the - /// decode-to-tmpfs entrypoint that `share-api`'s compose file uses cannot - /// work here, and a dstack/Phala deployment uploads only a compose file, - /// so there is no host path to bind-mount a file from either. An opaque - /// environment variable is the one channel that reaches this container in - /// production. It is base64 rather than raw JSON because Figment's `Env` + /// The release image is `FROM scratch`, so an inline environment value is + /// supported where a read-only trust-bundle mount is unavailable. It is + /// base64 rather than raw JSON because Figment's `Env` /// provider interprets brace- and bracket-delimited values as structured /// data; a base64 token passes through Figment, YAML and dstack's sealed /// environment storage byte-for-byte. @@ -115,6 +117,9 @@ pub struct ShareEmailConfig { pub registry_origin: Option, #[serde(default)] pub credentials_origin: Option, + /** Audience allowed to consume a Node-authorized email delivery. */ + #[serde(default)] + pub email_origin: Option, #[serde(default)] pub postgres_tls: ShareEmailPostgresTlsConfig, #[serde(default = "default_share_readiness_max_age")] @@ -221,6 +226,7 @@ impl Default for ShareEmailConfig { share_origin: None, registry_origin: None, credentials_origin: None, + email_origin: None, postgres_tls: ShareEmailPostgresTlsConfig::default(), readiness_max_age_seconds: default_share_readiness_max_age(), clock_skew_seconds: default_share_clock_skew(), @@ -251,6 +257,7 @@ impl ShareEmailConfig { resolved.return_origin = bundle.return_origin.clone(); resolved.allowed_origins = vec![bundle.return_origin]; resolved.credentials_origin = Some(bundle.credentials_origin.clone()); + resolved.email_origin = Some(bundle.email_origin.clone()); resolved.node_signing_kid = bundle.node_invitation_kid.clone(); resolved.invitation_kid = bundle.node_invitation_kid; resolved.invitation_public_key = Some(bundle.node_invitation_public_key); @@ -354,6 +361,11 @@ impl ShareEmailConfig { }) || self.credentials_origin.as_deref() == Some(self.target_origin.as_str()) || self.credentials_origin.as_deref() == Some(self.return_origin.as_str()) + || self.email_origin.as_deref().is_none_or(|origin| { + tinycloud_core::share_email::TargetOrigin::parse(origin.to_owned()).is_err() + }) + || self.email_origin.as_deref() == Some(self.target_origin.as_str()) + || self.email_origin.as_deref() == Some(self.return_origin.as_str()) { return Err("share email configuration is incomplete"); } @@ -441,26 +453,10 @@ struct ShareEmailTrustBundle { return_origin: String, registry_origin: String, credentials_origin: String, - /// TC-397: the origin the Share host puts in its CSP `connect-src` so the - /// browser is allowed to reach the email service. Share's schema is - /// closed and *requires* this field; the node's is closed and, until now, - /// rejected it — one committed document could not satisfy both, and - /// `resolve_trust_bundle` is `?`-propagated at startup, so the mismatch - /// was boot-fatal. - /// - /// It is optional here on purpose. Nothing in the node reads it: the CSP - /// it feeds is emitted by Share, and the node already declines to - /// propagate the two other origins it does not consume (`shareOrigin`, - /// `registryOrigin` are validated and matched, never resolved into - /// config). Requiring a newly added field inside an unchanged document - /// version would also be a breaking schema change without a version bump, - /// making every bundle in flight boot-fatal and coupling the node and - /// Share deploy order. `deny_unknown_fields` still rejects genuinely - /// unknown keys, so the schema stays closed; `emailOrigin` simply becomes - /// a known field that is fully validated whenever it is present. The - /// requirement itself is enforced by Share, which is its only consumer. - #[serde(default)] - email_origin: Option, + /// Exact audience allowed to consume a node-authorized delivery receipt. + /// This is required because the email delivery service must never accept a receipt minted + /// for another service. + email_origin: String, node_origin: String, node_audience: String, node_invitation_kid: String, @@ -486,10 +482,9 @@ impl ShareEmailTrustBundle { || self.return_origin != self.share_origin || !canonical_https_origin(&self.registry_origin) || self.credentials_origin != "https://witness.credentials.org" - || self - .email_origin - .as_deref() - .is_some_and(|origin| !canonical_https_origin(origin)) + || !canonical_https_origin(&self.email_origin) + || (!allows_hermetic_fixture() && self.email_origin != PRODUCTION_SHARE_EMAIL_ORIGIN) + || (!allows_hermetic_fixture() && self.email_origin != self.credentials_origin) || (!canonical_https_origin(&self.node_origin) && !fixture_node_origin) || self.node_audience != format!( @@ -550,7 +545,11 @@ impl ShareEmailTrustBundle { && config .credentials_origin .as_deref() - .is_none_or(|value| value == self.credentials_origin); + .is_none_or(|value| value == self.credentials_origin) + && config + .email_origin + .as_deref() + .is_none_or(|value| value == self.email_origin); if matches { Ok(()) } else { @@ -573,10 +572,7 @@ impl ShareEmailTrustBundle { ] .into_iter() .any(|value| contains_placeholder(value)) - || self - .email_origin - .as_deref() - .is_some_and(contains_placeholder) + || contains_placeholder(&self.email_origin) || is_fixture_public_key(&self.node_invitation_public_key) || is_fixture_public_key(&self.issuer_public_key) } @@ -1283,9 +1279,10 @@ mod tests { } } - /// The exact `emailOrigin` the committed production document carries. - /// Share's schema requires the field; the node's must accept it. - const PRODUCTION_EMAIL_ORIGIN: &str = "https://email.tinycloud.xyz"; + /// The exact `emailOrigin` (generic invitation audience) the committed + /// production document carries. Share's schema requires the field; the + /// node's must accept it. + const PRODUCTION_EMAIL_ORIGIN: &str = PRODUCTION_SHARE_EMAIL_ORIGIN; fn bundle_document(config: &ShareEmailConfig) -> serde_json::Value { serde_json::json!({ @@ -1318,6 +1315,7 @@ mod tests { "returnOrigin": config.return_origin.clone(), "registryOrigin": "https://registry.tinycloud.xyz", "credentialsOrigin": "https://witness.credentials.org", + "emailOrigin": PRODUCTION_EMAIL_ORIGIN, "nodeOrigin": config.target_origin.clone(), "nodeAudience": config.node_audience.clone(), "nodeInvitationKid": config.invitation_kid.clone(), @@ -1398,6 +1396,7 @@ mod tests { "returnOrigin": "https://share.tinycloud.xyz", "registryOrigin": "https://registry.tinycloud.xyz", "credentialsOrigin": "https://witness.credentials.org", + "emailOrigin": PRODUCTION_EMAIL_ORIGIN, "nodeOrigin": "https://node.example", "nodeAudience": "did:web:node.example", "nodeInvitationKid": "did:web:node.example#invitation-key-1", @@ -1424,16 +1423,10 @@ mod tests { ); } - /// TC-397. Share's trust-bundle schema is closed and *requires* - /// `emailOrigin` — it feeds the CSP `connect-src` without which the - /// browser blocks the send. The node's schema is closed too and rejected - /// the key outright, so one committed document could not satisfy both - /// sides; because `resolve_trust_bundle` is `?`-propagated out of - /// `app_with_control`, that mismatch killed the node at boot with - /// "share email trust bundle is invalid". + /// The same required invitation origin is consumed by the delivery runtime. #[cfg(not(feature = "mounted-fixture"))] #[tokio::test] - async fn a_trust_bundle_carrying_the_production_email_origin_is_accepted() { + async fn a_trust_bundle_carrying_the_production_invitation_origin_is_accepted() { let mut config = enabled_config(); let file = NamedTempFile::new().expect("temporary trust bundle"); fs::write( @@ -1447,19 +1440,16 @@ mod tests { .resolve_trust_bundle() .expect("a bundle carrying emailOrigin must be accepted"); - // Validated, but deliberately not consumed — the same treatment - // `shareOrigin` and `registryOrigin` already get, because the node - // has no use for them either. + // The delivery runtime consumes this as the exact receipt audience. assert_eq!( - resolved.credentials_origin.as_deref(), - Some("https://witness.credentials.org") + resolved.email_origin.as_deref(), + Some(PRODUCTION_EMAIL_ORIGIN) ); assert!(config.validate().is_ok()); } - /// The field is optional, not ignored: whenever it is present it has to - /// be a canonical HTTPS origin with no path, query, fragment, port or - /// credentials, and it is covered by the production placeholder scan. + /// The required field must be a canonical HTTPS origin with no path, + /// query, fragment, port, or credentials. #[cfg(not(feature = "mounted-fixture"))] #[tokio::test] async fn a_malformed_email_origin_is_rejected() { @@ -1471,6 +1461,9 @@ mod tests { "https://operator:secret@email.tinycloud.xyz", "https://email.tinycloud.xyz:8443", "email.tinycloud.xyz", + // Correct shape, but an unreviewed production audience. + "https://email.tinycloud.xyz", + "https://api.share.tinycloud.xyz", "", // Caught by the placeholder scan rather than the origin shape. "https://email.localhost", diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index f799d3f7..1db55826 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -6,7 +6,7 @@ //! challenge/claim replay boundary, and the first-admission gate. use aes_gcm::{ - aead::{Aead, KeyInit}, + aead::{Aead, KeyInit, Payload as AeadPayload}, Aes256Gcm, Nonce, }; use base64::{decode_config, encode_config, URL_SAFE_NO_PAD}; @@ -39,8 +39,8 @@ use tinycloud_core::{ }, relationships::parent_delegations, sea_orm::{ - sea_query::Expr, ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, - EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait, + sea_query::Expr, ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, + DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait, }, types::SpaceIdWrap, util::{DelegationInfo, InvocationInfo}, @@ -70,18 +70,25 @@ const STATUS_DOMAIN: &[u8] = b"xyz.tinycloud.policy/RootStatusCheckpoint/v1\0"; const CONTENT_SOURCE_DOMAIN: &[u8] = b"xyz.tinycloud.policy/ContentSource/v1\0"; const CAPABILITY_CEILING_DOMAIN: &[u8] = b"xyz.tinycloud.policy/PolicyCapability/v1\0"; const NATIVE_PROJECTION_DOMAIN: &[u8] = b"xyz.tinycloud.policy/NativeProjection/v1\0"; -const MAX_STATUS_AGE_SECONDS: i64 = 300; const MAX_SESSION_TTL_SECONDS: i64 = 60; const DELIVERY_ADMISSION_DOMAIN: &[u8] = b"xyz.tinycloud.policy/delivery-admission/v0\0"; +const SEALED_ENVELOPE_AAD: &[u8] = b"tinycloud-share-envelope-v1"; +const SEALED_ENVELOPE_VERSION: u8 = 1; +const SEALED_ENVELOPE_NONCE_BYTES: usize = 12; +const SEALED_ENVELOPE_TAG_BYTES: usize = 16; +const MAX_SEALED_ENVELOPE_BYTES: usize = 4 * 1024 * 1024; +// The reviewed `tinycloud.email-proof/v1` descriptor defines a 300-second +// freshness bound for its non-revocable status. This is pinned here rather +// than trusting the unsigned transport envelope around the SD-JWT. +const EMAIL_PROOF_STATUS_FRESHNESS_SECONDS: i64 = 300; const INVITATION_REQUEST_SCHEMA: &str = "xyz.tinycloud.credentials/invitation-request/v1"; const DELIVERY_ADMISSION_SCHEMA: &str = "xyz.tinycloud.policy/delivery-admission/v0"; #[derive(Clone)] struct DeliveryRuntime { target_origin: String, - enforcer_did: String, return_origin: String, - credentials_origin: String, + invitation_origin: String, } #[derive(Clone)] @@ -129,10 +136,10 @@ impl PolicyV3Runtime { if !config.enabled { return Ok(self); } - let credentials_origin = config - .credentials_origin + let invitation_origin = config + .email_origin .clone() - .ok_or_else(|| anyhow::anyhow!("v3 delivery credentials origin is missing"))?; + .ok_or_else(|| anyhow::anyhow!("v3 credential-invitation origin is missing"))?; let configured = config .invitation_public_key .as_deref() @@ -144,9 +151,8 @@ impl PolicyV3Runtime { } self.delivery = Some(DeliveryRuntime { target_origin: config.target_origin.clone(), - enforcer_did: self.node_did.clone(), return_origin: config.return_origin.clone(), - credentials_origin, + invitation_origin, }); Ok(self) } @@ -241,13 +247,15 @@ impl PolicyV3Runtime { if validate_persisted_root(&root, cid, ®istration.policy_cid, &self.node_did) .await .is_err() - || validate_stored_root_status( + || validate_root_liveness( + &self.conn, &root, cid, &self.node_did, OffsetDateTime::now_utc(), true, ) + .await .is_err() { return Err("policy-session-root-status-invalid"); @@ -487,7 +495,7 @@ impl PolicyV3Runtime { if graph_root.serialized_bytes() != root.authorization_bytes { return Err("policy-root-graph-mismatch"); } - validate_stored_root_status(&root, &root_cid, &self.node_did, now, true)?; + validate_root_liveness(&self.conn, &root, &root_cid, &self.node_did, now, true).await?; } Ok(true) } @@ -778,7 +786,7 @@ pub struct RegisterResponse { pub enforcement_root_cid: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DeliveryAuthorizationRequest { pub envelope: Value, @@ -802,6 +810,24 @@ fn delivery_request_digest(request: &DeliveryAuthorizationRequest) -> Result bool { + existing.binding_json.get("version").and_then(Value::as_u64) == Some(3) + && existing + .binding_json + .get("requestBodyDigest") + .and_then(Value::as_str) + == Some(request.request_body_digest.as_str()) + && existing + .binding_json + .get("senderKeyDid") + .and_then(Value::as_str) + == Some(sender_key_did) +} + fn delivery_email(value: &str) -> Option { let (local, domain) = value.rsplit_once('@')?; if local.is_empty() @@ -821,16 +847,92 @@ fn delivery_email(value: &str) -> Option { )) } -fn v3_delivery_url_matches(url: &str, origin: &str, share_cid: &str, envelope_key: &str) -> bool { - let prefix = format!("{origin}/s/{share_cid}#k="); - let Some(key) = url.strip_prefix(&prefix) else { +/// The Share SDK stores the signed recipient envelope as a versioned, +/// AES-256-GCM sealed blob. The public URL addresses only that ciphertext; +/// its decryption key is a fragment and must never be sent in a query string. +fn v3_delivery_url_matches( + url: &str, + origin: &str, + share_cid: &str, + sealed_envelope: &str, + envelope_key: &str, + envelope: &Value, +) -> bool { + let Ok(sealed) = decode_config(sealed_envelope, URL_SAFE_NO_PAD) else { return false; }; - key == envelope_key - && key.len() == 43 - && key - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + let Ok(key) = decode_config(envelope_key, URL_SAFE_NO_PAD) else { + return false; + }; + if encode_config(&sealed, URL_SAFE_NO_PAD) != sealed_envelope + || sealed.len() < 1 + SEALED_ENVELOPE_NONCE_BYTES + SEALED_ENVELOPE_TAG_BYTES + || sealed.len() > MAX_SEALED_ENVELOPE_BYTES + || sealed[0] != SEALED_ENVELOPE_VERSION + || encode_config(&key, URL_SAFE_NO_PAD) != envelope_key + || key.len() != 32 + { + return false; + } + let sealed_cid = tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Code::Sha2_256.digest(&sealed), + ) + .to_string(); + if sealed_cid != share_cid { + return false; + } + let nonce = Nonce::from( + <[u8; SEALED_ENVELOPE_NONCE_BYTES]>::try_from(&sealed[1..1 + SEALED_ENVELOPE_NONCE_BYTES]) + .expect("sealed envelope nonce length is fixed"), + ); + let Ok(cipher) = Aes256Gcm::new_from_slice(&key) else { + return false; + }; + let Ok(plaintext) = cipher.decrypt( + &nonce, + AeadPayload { + msg: &sealed[1 + SEALED_ENVELOPE_NONCE_BYTES..], + aad: SEALED_ENVELOPE_AAD, + }, + ) else { + return false; + }; + if plaintext != canonical_json_value(envelope) { + return false; + } + + // Compact links are the normal Share SDK form. The path is the ciphertext + // CID; only the fragment carries the decryption key. + if url == format!("{origin}/s/{share_cid}#k={envelope_key}") { + return true; + } + + // The SDK also supports a sealed inline form. Its complete payload stays + // in the fragment, so no recipient material reaches HTTP logs either. + let prefix = format!("{origin}/s/inline#v=2&p="); + let Some(encoded) = url.strip_prefix(&prefix) else { + return false; + }; + let Ok(bytes) = decode_config(encoded, URL_SAFE_NO_PAD) else { + return false; + }; + if encode_config(&bytes, URL_SAFE_NO_PAD) != encoded + || bytes.len() > MAX_SEALED_ENVELOPE_BYTES * 2 + { + return false; + } + let Ok(value) = serde_json::from_slice::(&bytes) else { + return false; + }; + let Some(payload) = value.as_object() else { + return false; + }; + payload.len() == 4 + && payload.get("v").and_then(Value::as_u64) == Some(2) + && payload.get("c").and_then(Value::as_str) == Some(sealed_envelope) + && payload.get("cid").and_then(Value::as_str) == Some(share_cid) + && payload.get("k").and_then(Value::as_str) == Some(envelope_key) + && canonical_json_value(&value) == bytes } fn v3_registration_is_live( @@ -905,13 +1007,15 @@ fn v3_envelope_delivery_projection<'a>( .get("attestedEnforcerBinding") .and_then(Value::as_object) .ok_or(())?; + let registered_binding: Value = + serde_json::from_slice(®istration.attested_enforcer_binding_bytes).map_err(|_| ())?; + let node_audience = binding + .get("nodeAudience") + .and_then(Value::as_str) + .ok_or(())?; if target.get("origin").and_then(Value::as_str) != Some(delivery.target_origin.as_str()) - || target.get("nodeAudience").and_then(Value::as_str) - != Some(delivery.enforcer_did.as_str()) - || binding.get("enforcerDid").and_then(Value::as_str) - != Some(delivery.enforcer_did.as_str()) - || binding.get("nodeAudience").and_then(Value::as_str) - != Some(delivery.enforcer_did.as_str()) + || target.get("nodeAudience").and_then(Value::as_str) != Some(node_audience) + || object.get("attestedEnforcerBinding") != Some(®istered_binding) { return Err(()); } @@ -953,39 +1057,6 @@ fn v3_envelope_delivery_projection<'a>( Ok((object, display_name, actions)) } -fn verify_v3_sealed_envelope( - envelope: &Value, - request: &DeliveryAuthorizationRequest, -) -> Result<(), ()> { - let sealed = decode_config(&request.sealed_envelope, URL_SAFE_NO_PAD).map_err(|_| ())?; - let key = decode_config(&request.envelope_key, URL_SAFE_NO_PAD).map_err(|_| ())?; - if sealed.len() < 29 || sealed.len() > 2 * 1024 * 1024 || sealed[0] != 1 || key.len() != 32 { - return Err(()); - } - let expected_cid = tinycloud_auth::ipld_core::cid::Cid::new_v1( - 0x55, - tinycloud_auth::multihash_codetable::Code::Sha2_256.digest(&sealed), - ) - .to_string(); - if expected_cid != request.share_cid { - return Err(()); - } - let nonce = Nonce::from(<[u8; 12]>::try_from(&sealed[1..13]).map_err(|_| ())?); - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| ())?; - let plaintext = cipher - .decrypt( - &nonce, - aes_gcm::aead::Payload { - msg: &sealed[13..], - aad: b"tinycloud-share-envelope-v1", - }, - ) - .map_err(|_| ())?; - (plaintext == canonical_json_value(envelope)) - .then_some(()) - .ok_or(()) -} - fn normal_invocation_allows_v3_delivery( invocation: &InvocationInfo, envelope: &serde_json::Map, @@ -1008,6 +1079,80 @@ fn normal_invocation_allows_v3_delivery( }) } +struct DeliveryAuthorizationReceipt { + value: Value, + authorization_digest: String, +} + +#[allow(clippy::too_many_arguments)] +fn build_delivery_authorization_receipt( + request: &DeliveryAuthorizationRequest, + registration: &policy_v3_registration::Model, + resource: &str, + credential_type: &str, + sender_key_did: &str, + audience: &str, + issued_at: &str, + runtime: &PolicyV3Runtime, +) -> Result { + let invitation_request = serde_json::json!({ + "schema": INVITATION_REQUEST_SCHEMA, + "policyId": registration.policy_cid, + "recipient": request.recipient_email, + "resource": resource, + "credentialType": credential_type, + "returnLink": request.share_url, + "envelopeRef": request.share_cid, + "label": request.document_name, + "shareExpiresAt": registration.expires_at, + "audience": audience, + "issuedAt": issued_at, + "expiresAt": request.expires_at, + "nonce": request.jti, + }); + let mut admission = serde_json::json!({ + "schema": DELIVERY_ADMISSION_SCHEMA, + "policyId": registration.policy_cid, + "ownerDid": registration.owner_did, + "recipient": request.recipient_email, + "resource": resource, + "actions": ["tinycloud.kv/get"], + "credentialType": credential_type, + "returnLink": request.share_url, + "envelopeRef": request.share_cid, + "label": request.document_name, + "shareExpiresAt": registration.expires_at, + "senderKeyDid": sender_key_did, + "audience": audience, + "issuedAt": issued_at, + "expiresAt": request.expires_at, + "nonce": request.jti, + }); + let authorization_digest = encode_config( + Sha256::digest(canonical_json_value(&admission)), + URL_SAFE_NO_PAD, + ); + let mut signed = DELIVERY_ADMISSION_DOMAIN.to_vec(); + signed.extend_from_slice(&canonical_json_value(&admission)); + let signature = runtime + .signer + .node_keypair() + .sign(&Sha256::digest(signed)) + .map_err(|error| (Status::InternalServerError, error.to_string()))?; + admission["signature"] = serde_json::json!({ + "suite": "eddsa-ed25519-sha256-jcs-v1", + "signerDid": runtime.node_did, + "value": encode_config(signature, URL_SAFE_NO_PAD), + }); + Ok(DeliveryAuthorizationReceipt { + value: serde_json::json!({ + "request": invitation_request, + "admission": admission, + }), + authorization_digest, + }) +} + #[post("/policy/v3/deliveries/authorize", format = "json", data = "")] pub async fn authorize_delivery( request: Json, @@ -1039,12 +1184,24 @@ pub async fn authorize_delivery( &request.share_url, &delivery.return_origin, &request.share_cid, + &request.sealed_envelope, &request.envelope_key, + &request.envelope, ) || tinycloud_auth::ipld_core::cid::Cid::try_from(request.share_cid.as_str()).is_err() { return Err((Status::BadRequest, "delivery-authorization-invalid".into())); } + let sender_key_did = invocation.0 .0.invoker.as_str(); + let replay = share_invitation_authorization_jti::Entity::find_by_id(request.jti.clone()) + .one(&runtime.conn) + .await + .map_err(db_error)?; + if replay.as_ref().is_some_and(|existing| { + !delivery_replay_request_matches(existing, &request, sender_key_did) + }) { + return Err((Status::Conflict, "delivery-authorization-replayed".into())); + } let policy_cid = request .envelope .get("policyCid") @@ -1070,8 +1227,6 @@ pub async fn authorize_delivery( let (envelope, _, actions) = v3_envelope_delivery_projection(&request.envelope, ®istration, delivery, &request) .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; - verify_v3_sealed_envelope(&request.envelope, &request) - .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; if !normal_invocation_allows_v3_delivery(&invocation.0 .0, envelope) { return Err(( Status::Unauthorized, @@ -1087,7 +1242,8 @@ pub async fn authorize_delivery( .await .map_err(db_error)? .ok_or((Status::Forbidden, "delivery-authorization-invalid".into()))?; - validate_stored_root_status(&root, cid, &runtime.node_did, now, true) + validate_root_liveness(&runtime.conn, &root, cid, &runtime.node_did, now, true) + .await .map_err(|_| (Status::Forbidden, "delivery-authorization-invalid".into()))?; } let resource = envelope @@ -1100,7 +1256,10 @@ pub async fn authorize_delivery( .get("policy") .and_then(Value::as_object) .ok_or((Status::Forbidden, "delivery-authorization-invalid".into()))?; - let policy_id = registration.policy_cid.clone(); + let share_expires_at = envelope + .get("expiry") + .and_then(Value::as_str) + .ok_or((Status::Forbidden, "delivery-authorization-invalid".into()))?; let credential_type = policy .get("credentialRequirement") .and_then(Value::as_object) @@ -1112,74 +1271,108 @@ pub async fn authorize_delivery( if actions.as_slice() != ["read"] || credential_type != "opencredentials.email/v1" { return Err((Status::Forbidden, "delivery-authorization-invalid".into())); } - let invitation_request = serde_json::json!({ - "schema": INVITATION_REQUEST_SCHEMA, - "policyId": policy_id, - "recipient": request.recipient_email, - "resource": resource, - "credentialType": credential_type, - "returnLink": request.share_url, - "envelopeRef": request.share_cid, - "audience": delivery.credentials_origin, - "issuedAt": format_time(now), - "expiresAt": request.expires_at, - "nonce": request.jti, - }); - let mut admission = serde_json::json!({ - "schema": DELIVERY_ADMISSION_SCHEMA, - "policyId": policy_id, - "ownerDid": registration.owner_did, - "recipient": request.recipient_email, - "resource": resource, - "actions": ["tinycloud.kv/get"], - "credentialType": credential_type, - "returnLink": request.share_url, - "envelopeRef": request.share_cid, - "senderKeyDid": invocation.0 .0.invoker, - "audience": delivery.credentials_origin, - "issuedAt": format_time(now), - "expiresAt": request.expires_at, - "nonce": request.jti, + if share_expires_at != registration.expires_at { + return Err((Status::Forbidden, "delivery-authorization-invalid".into())); + } + let binding_json = serde_json::json!({ + "version": 3, + "policyCid": policy_cid, + "shareCid": request.share_cid, + "policyId": registration.policy_cid, + "requestBodyDigest": request.request_body_digest, + "senderKeyDid": sender_key_did, }); let _writer = match &runtime.sqlite_writer_lock { Some(lock) => Some(lock.lock().await), None => None, }; - share_invitation_authorization_jti::ActiveModel { + let replay = match replay { + Some(existing) => Some(existing), + None => share_invitation_authorization_jti::Entity::find_by_id(request.jti.clone()) + .one(&runtime.conn) + .await + .map_err(db_error)?, + }; + if let Some(existing) = replay { + if !delivery_replay_request_matches(&existing, &request, sender_key_did) + || existing.binding_json != binding_json + || existing.expires_at != request.expires_at + || existing.consumed_at.is_none() + || !parse_time(&existing.issued_at) + .is_ok_and(|issued_at| format_time(issued_at) == existing.issued_at) + { + return Err((Status::Conflict, "delivery-authorization-replayed".into())); + } + let receipt = build_delivery_authorization_receipt( + &request, + ®istration, + resource, + credential_type, + sender_key_did, + &delivery.invitation_origin, + &existing.issued_at, + runtime, + )?; + if receipt.authorization_digest != existing.authorization_digest { + return Err((Status::Conflict, "delivery-authorization-replayed".into())); + } + return Ok(Json(receipt.value)); + } + + let issued_at = format_time(now); + let receipt = build_delivery_authorization_receipt( + &request, + ®istration, + resource, + credential_type, + sender_key_did, + &delivery.invitation_origin, + &issued_at, + runtime, + )?; + let insert = share_invitation_authorization_jti::ActiveModel { jti: Set(request.jti.clone()), - authorization_digest: Set(encode_config( - Sha256::digest(canonical_json_value(&admission)), - URL_SAFE_NO_PAD, - )), - binding_json: Set(serde_json::json!({ - "version": 3, - "policyCid": policy_cid, - "shareCid": request.share_cid, - "policyId": policy_id, - })), - issued_at: Set(format_time(now)), + authorization_digest: Set(receipt.authorization_digest.clone()), + binding_json: Set(binding_json.clone()), + issued_at: Set(issued_at), expires_at: Set(request.expires_at.clone()), consumed_at: Set(Some(format_time(now))), } .insert(&runtime.conn) - .await - .map_err(|_| (Status::Conflict, "delivery-authorization-replayed".into()))?; - let mut signed = DELIVERY_ADMISSION_DOMAIN.to_vec(); - signed.extend_from_slice(&canonical_json_value(&admission)); - let signature = runtime - .signer - .node_keypair() - .sign(&Sha256::digest(signed)) - .map_err(|error| (Status::InternalServerError, error.to_string()))?; - admission["signature"] = serde_json::json!({ - "suite": "eddsa-ed25519-sha256-jcs-v1", - "signerDid": runtime.node_did, - "value": encode_config(signature, URL_SAFE_NO_PAD), - }); - Ok(Json(serde_json::json!({ - "request": invitation_request, - "admission": admission, - }))) + .await; + if insert.is_ok() { + return Ok(Json(receipt.value)); + } + + // A concurrent exact retry may have won the durable JTI insert. Recover + // only the same request binding; storage failures without a matching row + // remain fail closed. + let existing = share_invitation_authorization_jti::Entity::find_by_id(request.jti.clone()) + .one(&runtime.conn) + .await + .map_err(db_error)? + .ok_or((Status::InternalServerError, "delivery-unavailable".into()))?; + if !delivery_replay_request_matches(&existing, &request, sender_key_did) + || existing.binding_json != binding_json + || existing.expires_at != request.expires_at + || existing.consumed_at.is_none() + { + return Err((Status::Conflict, "delivery-authorization-replayed".into())); + } + let recovered = build_delivery_authorization_receipt( + &request, + ®istration, + resource, + credential_type, + sender_key_did, + &delivery.invitation_origin, + &existing.issued_at, + runtime, + )?; + if recovered.authorization_digest != existing.authorization_digest { + return Err((Status::Conflict, "delivery-authorization-replayed".into())); + } + Ok(Json(recovered.value)) } #[derive(Debug, Deserialize)] @@ -1464,14 +1657,14 @@ pub async fn register_policy( runtime, &policy_root_cid, "policy-authority", - &policy_root.0.delegation, + &policy_root.0, now, )?; let enforcement_status = initial_status_checkpoint( runtime, &enforcement_root_cid, "policy-enforcement", - &enforcement_root.0.delegation, + &enforcement_root.0, now, )?; @@ -2058,7 +2251,8 @@ pub async fn mint( validate_persisted_root(&root, root_cid, ®istration.policy_cid, &runtime.node_did) .await .map_err(|error| (Status::Forbidden, error.into()))?; - validate_stored_root_status(&root, root_cid, &runtime.node_did, now, true) + validate_root_liveness(&txn, &root, root_cid, &runtime.node_did, now, true) + .await .map_err(|error| (Status::Forbidden, error.into()))?; let current_event = decode_delegation( std::str::from_utf8(&root.authorization_bytes) @@ -2263,7 +2457,7 @@ impl PolicyV3Runtime { .map_err(|_| "root-unavailable")? .ok_or("root-missing")?; validate_persisted_root(&root, cid, ®istration.policy_cid, &self.node_did).await?; - validate_stored_root_status(&root, cid, &self.node_did, now, true)?; + validate_root_liveness(&self.conn, &root, cid, &self.node_did, now, true).await?; let encoded = std::str::from_utf8(&root.authorization_bytes) .map_err(|_| "policy-root-invalid")?; roots.push(decode_delegation(encoded).map_err(|_| "policy-root-invalid")?); @@ -2376,9 +2570,11 @@ fn validate_stored_root_status( .ok_or("root-status-invalid")?, ) .map_err(|_| "root-status-invalid")?; + let advertised_expiry = root_advertised_expiry(root)?; if checked > now - || (require_fresh && fresh <= now) - || fresh - checked > Duration::seconds(MAX_STATUS_AGE_SECONDS) + || checked >= advertised_expiry + || fresh != advertised_expiry + || (require_fresh && advertised_expiry <= now) { return Err("root-not-live"); } @@ -2434,6 +2630,51 @@ fn validate_stored_root_status( .map_err(|_| "root-status-signature-invalid") } +/// Policy status is an authenticated projection of this node's durable +/// authorization graph, not a lease that depends on a separate owner daemon. +/// The node therefore keeps an active root usable until the root's signed +/// expiry, while consulting the generic revocation graph on every use. This +/// makes an SDK `/revoke` immediately authoritative for Policy/v3 admission +/// and delivery as well as ordinary invocation authorization. +async fn validate_root_liveness( + db: &C, + root: &policy_v3_root::Model, + root_cid: &str, + node_did: &str, + now: OffsetDateTime, + require_fresh: bool, +) -> Result<(), &'static str> { + validate_stored_root_status(root, root_cid, node_did, now, require_fresh)?; + if is_root_generically_revoked(db, root_cid).await? { + return Err("root-revoked"); + } + Ok(()) +} + +async fn is_root_generically_revoked( + db: &C, + root_cid: &str, +) -> Result { + let cid = tinycloud_auth::ipld_core::cid::Cid::try_from(root_cid) + .map_err(|_| "policy-root-invalid")?; + revocation::Entity::find() + .filter(revocation::Column::Revoked.eq(tinycloud_core::hash::Hash::from(cid))) + .one(db) + .await + .map(|record| record.is_some()) + .map_err(|_| "root-revocation-unavailable") +} + +fn root_advertised_expiry(root: &policy_v3_root::Model) -> Result { + decode_delegation( + std::str::from_utf8(&root.authorization_bytes).map_err(|_| "policy-root-invalid")?, + ) + .map_err(|_| "policy-root-invalid")? + .0 + .expiry + .ok_or("policy-root-expiry-missing") +} + fn validate_stored_revocation( root: &policy_v3_root::Model, root_cid: &str, @@ -2492,16 +2733,22 @@ fn initial_status_checkpoint( runtime: &PolicyV3Runtime, root_cid: &str, role: &str, - root: &TinyCloudDelegation, + root: &DelegationInfo, now: OffsetDateTime, ) -> Result, (Status, String)> { let checked_at = format_time(now); - let fresh_until = format_time(now + Duration::seconds(MAX_STATUS_AGE_SECONDS)); + // A status checkpoint does not grant authority by itself. Its active + // lifetime is exactly the signed root lifetime; revocation is checked + // from the same durable graph on every admission, delivery, and invoke. + let fresh_until = format_time( + root.expiry + .ok_or((Status::Forbidden, "policy-root-expiry-missing".into()))?, + ); let mut unsigned = serde_json::json!({ "schema": ROOT_STATUS_V1_SCHEMA, "targetCid": root_cid, "targetRole": role, - "ownerDid": fact(root, "ownerDid").ok_or((Status::Forbidden, "root-owner-missing".into()))?, + "ownerDid": fact(&root.delegation, "ownerDid").ok_or((Status::Forbidden, "root-owner-missing".into()))?, "nodeAudience": runtime.node_did.clone(), "state": "active", "sequence": 1, @@ -2546,6 +2793,12 @@ async fn ingest_status_checkpoint_unmounted( if root.revoked_at.is_some() || root.revocation_bytes.is_some() { return Err((Status::Conflict, "status-rollback".into())); } + if is_root_generically_revoked(&runtime.conn, &request.root_cid) + .await + .map_err(|error| (Status::ServiceUnavailable, error.into()))? + { + return Err((Status::Conflict, "root-revoked".into())); + } validate_persisted_root( &root, &request.root_cid, @@ -2590,6 +2843,10 @@ async fn ingest_status_checkpoint_unmounted( std::str::from_utf8(&root.authorization_bytes) .map_err(|_| (Status::Forbidden, "root-invalid".into()))?, )?; + let root_expiry = root_delegation + .0 + .expiry + .ok_or((Status::Forbidden, "root-expiry-missing".into()))?; if object.get("schema").and_then(Value::as_str) != Some(ROOT_STATUS_V1_SCHEMA) || object.get("issuerDid").and_then(Value::as_str) != Some(runtime.node_did.as_str()) || object.get("targetCid").and_then(Value::as_str) != Some(request.root_cid.as_str()) @@ -2635,8 +2892,9 @@ async fn ingest_status_checkpoint_unmounted( let checked_at = parse_time(checked).map_err(bad)?; let fresh_until = parse_time(fresh).map_err(bad)?; if checked_at > now - || fresh_until <= now - || fresh_until - checked_at > Duration::seconds(MAX_STATUS_AGE_SECONDS) + || checked_at >= root_expiry + || root_expiry <= now + || fresh_until != root_expiry { return Err((Status::Forbidden, "status-stale".into())); } @@ -2872,6 +3130,12 @@ pub async fn status( if root.revoked_at.is_some() || root.revocation_bytes.is_some() { return Err((Status::Conflict, "root-revoked".into())); } + if is_root_generically_revoked(&runtime.conn, &request.root_cid) + .await + .map_err(|error| (Status::ServiceUnavailable, error.into()))? + { + return Err((Status::Conflict, "root-revoked".into())); + } validate_persisted_root( &root, &request.root_cid, @@ -2884,6 +3148,13 @@ pub async fn status( std::str::from_utf8(&root.authorization_bytes) .map_err(|_| (Status::Forbidden, "policy-root-invalid".into()))?, )?; + let root_expiry = root_event + .0 + .expiry + .ok_or((Status::Forbidden, "policy-root-expiry-missing".into()))?; + if root_expiry <= OffsetDateTime::now_utc() { + return Err((Status::Forbidden, "policy-root-expired".into())); + } let owner = fact(&root_event.0.delegation, "ownerDid") .ok_or((Status::Forbidden, "policy-root-owner-missing".into()))?; let sequence = root.status_sequence + 1; @@ -2909,6 +3180,7 @@ pub async fn status( owner, "active", sequence, + root_expiry, now, Some(previous.clone()), None, @@ -2933,7 +3205,7 @@ pub async fn status( ) .col_expr( policy_v3_root::Column::StatusFreshUntil, - Expr::value(format_time(now + Duration::seconds(MAX_STATUS_AGE_SECONDS))), + Expr::value(format_time(root_expiry)), ) .filter(policy_v3_root::Column::RootCid.eq(request.root_cid.clone())) .filter(policy_v3_root::Column::StatusSequence.eq(root.status_sequence)) @@ -2964,6 +3236,9 @@ pub async fn get_status( .await .map_err(db_error)? .ok_or((Status::NotFound, "policy-root-missing".into()))?; + let generic_revocation = is_root_generically_revoked(&runtime.conn, root_cid) + .await + .map_err(|error| (Status::ServiceUnavailable, error.into()))?; if root.revoked_at.is_none() { validate_stored_root_status( &root, @@ -2996,7 +3271,7 @@ pub async fn get_status( .map_err(|_| (Status::Forbidden, "root-revocation-invalid".into()))?; Ok(Json(StatusCheckpointResponse { root_cid: root_cid.to_owned(), - state: if root.revoked_at.is_some() { + state: if root.revoked_at.is_some() || generic_revocation { "revoked" } else { "active" @@ -3043,6 +3318,10 @@ pub async fn revoke_root( std::str::from_utf8(&root.authorization_bytes) .map_err(|_| (Status::Forbidden, "policy-root-invalid".into()))?, )?; + let root_expiry = root_event + .0 + .expiry + .ok_or((Status::Forbidden, "policy-root-expiry-missing".into()))?; let owner = fact(&root_event.0.delegation, "ownerDid") .ok_or((Status::Forbidden, "policy-root-owner-missing".into()))?; let (revocation_bytes, revocation_digest, revoked_at) = validate_root_revocation( @@ -3067,6 +3346,7 @@ pub async fn revoke_root( owner, "revoked", sequence, + root_expiry, OffsetDateTime::now_utc(), Some(previous.clone()), Some(format_time(revoked_at)), @@ -3091,9 +3371,7 @@ pub async fn revoke_root( ) .col_expr( policy_v3_root::Column::StatusFreshUntil, - Expr::value(format_time( - OffsetDateTime::now_utc() + Duration::seconds(MAX_STATUS_AGE_SECONDS), - )), + Expr::value(format_time(root_expiry)), ) .col_expr( policy_v3_root::Column::RevokedAt, @@ -3277,6 +3555,7 @@ fn signed_status_checkpoint( owner: &str, state: &str, sequence: i64, + root_expiry: OffsetDateTime, now: OffsetDateTime, previous: Option, revoked_at: Option, @@ -3291,7 +3570,7 @@ fn signed_status_checkpoint( "state": state, "sequence": sequence, "checkedAt": format_time(now), - "freshUntil": format_time(now + Duration::seconds(MAX_STATUS_AGE_SECONDS)), + "freshUntil": format_time(root_expiry), "issuerDid": runtime.node_did, }); if let Some(previous) = previous { @@ -5319,6 +5598,26 @@ struct VerifiedOpenCredential { credential_digest: String, } +fn pinned_profile_status_freshness_seconds( + projection: &serde_json::Map, + trusted_issuer: &IssuerKey, +) -> Option { + let profile = projection + .get("profile") + .and_then(Value::as_object) + .and_then(|profile| profile.get("id")) + .and_then(Value::as_str); + let credential_type = projection + .get("credentialType") + .and_then(Value::as_object) + .and_then(|credential_type| credential_type.get("id")) + .and_then(Value::as_str); + (profile == Some("tinycloud.email-proof/v1") + && credential_type == Some("opencredentials.email/v1") + && trusted_issuer.vct == "opencredentials.email/v1") + .then_some(EMAIL_PROOF_STATUS_FRESHNESS_SECONDS) +} + fn verify_opencredentials_credential( envelope: &Value, requirement: &Value, @@ -5519,7 +5818,13 @@ fn verify_opencredentials_credential( "credential-requirement-not-satisfied".into(), )); } - validate_credential_time(envelope, &disclosed, requirement, now)?; + validate_credential_time( + envelope, + &disclosed, + requirement, + pinned_profile_status_freshness_seconds(projection, trusted_issuer), + now, + )?; let credential_id = disclosed .get("jti") .and_then(Value::as_str) @@ -5618,6 +5923,7 @@ fn validate_credential_time( envelope: &serde_json::Map, disclosed: &serde_json::Map, requirement: &Value, + status_freshness_seconds: Option, now: OffsetDateTime, ) -> Result<(), (Status, String)> { let issued = parse_time( @@ -5641,7 +5947,8 @@ fn validate_credential_time( .ok_or((Status::Forbidden, "credential-time-invalid".into()))?, ) .map_err(|_| (Status::Forbidden, "credential-time-invalid".into()))?; - if not_before > now + if issued > now + || not_before > now || expires <= now || disclosed.get("iat").and_then(Value::as_i64) != Some(issued.unix_timestamp()) || disclosed.get("nbf").and_then(Value::as_i64) != Some(not_before.unix_timestamp()) @@ -5650,6 +5957,7 @@ fn validate_credential_time( .get("maxAgeSeconds") .and_then(Value::as_i64) .is_some_and(|max_age| now - issued > Duration::seconds(max_age)) + || status_freshness_seconds.is_some_and(|seconds| now - issued > Duration::seconds(seconds)) { return Err((Status::Forbidden, "credential-time-invalid".into())); } @@ -5819,7 +6127,7 @@ mod tests { use base64::encode_config; use serde_json::json; use tinycloud_auth::authorization::HeaderEncode; - use tinycloud_auth::ssi::{claims::jwt::NumericDate, dids::DIDURLBuf, ucan::Payload}; + use tinycloud_auth::ssi::{claims::jwt::NumericDate, ucan::Payload}; use tinycloud_core::migrations::Migrator; use tinycloud_core::sea_orm::{Database, EntityTrait, TransactionTrait}; use tinycloud_core::sea_orm_migration::MigratorTrait; @@ -5831,11 +6139,151 @@ mod tests { .unwrap() } + async fn active_root_for_clock( + runtime: &PolicyV3Runtime, + now: OffsetDateTime, + expiry: OffsetDateTime, + ) -> anyhow::Result { + let owner_jwk = JWK::generate_ed25519()?; + let owner_did = tinycloud_auth::resolver::DID_METHODS + .generate(&owner_jwk, "key")? + .to_string(); + let owner_vm = format!("{owner_did}#{}", owner_did.trim_start_matches("did:key:")); + let authorization = TinyCloudDelegation::Ucan(Box::new( + Payload { + issuer: owner_vm.parse()?, + audience: runtime.node_did.parse()?, + not_before: Some(NumericDate::try_from_seconds(now.unix_timestamp() as f64)?), + expiration: NumericDate::try_from_seconds(expiry.unix_timestamp() as f64)?, + nonce: Some("root-liveness-clock".into()), + facts: Some(vec![json!({"ownerDid": owner_did})]), + proof: vec![], + attenuation: tinycloud_auth::ucan_capabilities_object::Capabilities::new(), + } + .sign(Algorithm::EdDSA, &owner_jwk)?, + )) + .encode()?; + let event = + decode_delegation(&authorization).map_err(|(_, error)| anyhow::anyhow!(error))?; + let root_cid = event.content_hash().to_cid(0x55).to_string(); + let checkpoint = + initial_status_checkpoint(runtime, &root_cid, "policy-authority", &event.0, now) + .map_err(|(_, error)| anyhow::anyhow!(error))?; + let checkpoint_value: Value = serde_json::from_slice(&checkpoint)?; + tinycloud_core::models::actor::ActiveModel { + id: Set(owner_did.clone()), + } + .insert(&runtime.conn) + .await?; + tinycloud_core::models::actor::ActiveModel { + id: Set(runtime.node_did.clone()), + } + .insert(&runtime.conn) + .await?; + delegation_model::ActiveModel { + id: Set(tinycloud_core::hash::Hash::from( + root_cid.parse::()?, + )), + delegator: Set(owner_did), + delegatee: Set(runtime.node_did.clone()), + expiry: Set(Some(expiry)), + issued_at: Set(Some(now)), + not_before: Set(Some(now)), + facts: Set(None), + serialization: Set(authorization.as_bytes().to_vec()), + } + .insert(&runtime.conn) + .await?; + Ok(policy_v3_root::Model { + root_cid, + policy_cid: "policy-clock".into(), + role: "policy-authority".into(), + authorization_bytes: authorization.into_bytes(), + status_checkpoint_bytes: Some(checkpoint), + previous_checkpoint_digest_hex: None, + status_sequence: 1, + admission_epoch: 0, + status_checked_at: Some( + checkpoint_value["checkedAt"] + .as_str() + .expect("checkpoint checkedAt") + .into(), + ), + status_fresh_until: Some( + checkpoint_value["freshUntil"] + .as_str() + .expect("checkpoint freshUntil") + .into(), + ), + revoked_at: None, + revocation_bytes: None, + }) + } + #[tokio::test] - async fn embedded_delivery_runtime_uses_node_key_and_fails_closed_on_trust_mismatch() { - let db = Database::connect("sqlite::memory:").await.unwrap(); - let signer = StaticSecret::new(vec![53; 32]).unwrap(); - let node_did = signer.node_did(); + async fn root_liveness_uses_advertised_expiry_and_generic_revocation() -> anyhow::Result<()> { + let db = Database::connect("sqlite::memory:").await?; + Migrator::up(&db, None).await?; + let signer = StaticSecret::new(vec![91; 32]).expect("a 32-byte static secret"); + let runtime = PolicyV3Runtime::new(db.clone(), signer.node_did(), signer); + let checked_at = parse_time("2026-08-07T16:00:00Z")?; + let root = + active_root_for_clock(&runtime, checked_at, checked_at + Duration::minutes(30)).await?; + + // Injected time makes the former five-minute cliff explicit: no owner + // daemon renewed this checkpoint, but the signed root is still live. + let after_five_minutes = checked_at + Duration::seconds(301); + assert!(validate_root_liveness( + &db, + &root, + &root.root_cid, + &runtime.node_did, + after_five_minutes, + true, + ) + .await + .is_ok()); + + // A generic SDK `/revoke` is stored in this table. The Policy control + // plane must deny it even though the signed status checkpoint itself + // predates the revocation. + tinycloud_core::models::actor::ActiveModel { + id: Set("did:key:zGenericRevoker".into()), + } + .insert(&db) + .await?; + revocation::ActiveModel { + id: Set(hash(b"root-liveness-generic-revocation")), + revoker: Set("did:key:zGenericRevoker".into()), + revoked: Set(tinycloud_core::hash::Hash::from( + root.root_cid + .parse::()?, + )), + serialization: Set(b"root-liveness-generic-revocation".to_vec()), + revoked_at: Set(Some(after_five_minutes)), + } + .insert(&db) + .await?; + assert_eq!( + validate_root_liveness( + &db, + &root, + &root.root_cid, + &runtime.node_did, + after_five_minutes, + true, + ) + .await, + Err("root-revoked") + ); + Ok(()) + } + + #[tokio::test] + async fn embedded_delivery_runtime_uses_node_key_and_fails_closed_on_trust_mismatch() { + let db = Database::connect("sqlite::memory:").await.unwrap(); + let signer = StaticSecret::new(vec![53; 32]).unwrap(); + let node_did = signer.node_did(); let mut config = ShareEmailConfig { enabled: true, target_origin: "https://node.example".into(), @@ -5843,6 +6291,7 @@ mod tests { node_signing_kid: format!("{node_did}#delivery"), invitation_kid: format!("{node_did}#delivery"), credentials_origin: Some("https://witness.credentials.org".into()), + email_origin: Some("https://witness.credentials.org".into()), invitation_public_key: Some(encode_config( signer.share_invitation_public_key(), URL_SAFE_NO_PAD, @@ -6090,17 +6539,31 @@ mod tests { .as_object() .unwrap() .clone(); - assert!(validate_credential_time(&base, &disclosed, &requirement, now).is_ok()); + assert!(validate_credential_time(&base, &disclosed, &requirement, None, now).is_ok()); + + // The caller obtains descriptor freshness from authenticated profile + // material; envelope status is deliberately not an input here. + assert!(validate_credential_time(&base, &disclosed, &requirement, Some(30), now).is_ok()); + assert!(validate_credential_time( + &base, + &disclosed, + &requirement, + Some(30), + now + Duration::seconds(31), + ) + .is_err()); let mut not_yet_valid = base.clone(); not_yet_valid.insert("notBefore".into(), json!("2026-08-07T16:00:31Z")); - assert!(validate_credential_time(¬_yet_valid, &disclosed, &requirement, now).is_err()); + assert!( + validate_credential_time(¬_yet_valid, &disclosed, &requirement, None, now).is_err() + ); let mut expired = base.clone(); expired.insert("expiresAt".into(), json!("2026-08-07T16:00:30Z")); - assert!(validate_credential_time(&expired, &disclosed, &requirement, now).is_err()); + assert!(validate_credential_time(&expired, &disclosed, &requirement, None, now).is_err()); let mut malformed = base; malformed.insert("notBefore".into(), json!("not-a-time")); - assert!(validate_credential_time(&malformed, &disclosed, &requirement, now).is_err()); + assert!(validate_credential_time(&malformed, &disclosed, &requirement, None, now).is_err()); } fn credential_envelope_stub(projection: &Value, holder: &str) -> Value { @@ -6278,6 +6741,37 @@ mod tests { assert!(validate_policy_document(&altered, &altered_bytes, &cid).is_err()); } + fn seal_delivery_envelope( + envelope: &Value, + key: [u8; 32], + nonce: [u8; SEALED_ENVELOPE_NONCE_BYTES], + ) -> (String, String, String) { + let cipher = Aes256Gcm::new_from_slice(&key).unwrap(); + let ciphertext = cipher + .encrypt( + &Nonce::from(nonce), + AeadPayload { + msg: &canonical_json_value(envelope), + aad: SEALED_ENVELOPE_AAD, + }, + ) + .unwrap(); + let mut sealed = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); + sealed.push(SEALED_ENVELOPE_VERSION); + sealed.extend_from_slice(&nonce); + sealed.extend_from_slice(&ciphertext); + let share_cid = tinycloud_auth::ipld_core::cid::Cid::new_v1( + 0x55, + tinycloud_auth::multihash_codetable::Code::Sha2_256.digest(&sealed), + ) + .to_string(); + ( + encode_config(&sealed, URL_SAFE_NO_PAD), + encode_config(key, URL_SAFE_NO_PAD), + share_cid, + ) + } + fn delivery_fixture() -> ( tinycloud_core::libp2p::identity::ed25519::Keypair, policy_v3_registration::Model, @@ -6294,7 +6788,7 @@ mod tests { "contentSource": {"shareId":"share-v3","kvResource":"did:key:zOwner/kv/shares/share-v3/report.pdf"}, }); let policy_bytes = canonical_json_value(&policy); - let registration = policy_v3_registration::Model { + let mut registration = policy_v3_registration::Model { policy_cid: "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), policy_bytes, policy_digest_hex: "11".repeat(32), @@ -6310,9 +6804,8 @@ mod tests { }; let delivery = DeliveryRuntime { target_origin: "https://tee.node.tinycloud.xyz".into(), - enforcer_did: "did:key:zEnforcer".into(), return_origin: "https://share.tinycloud.xyz".into(), - credentials_origin: "https://witness.credentials.org".into(), + invitation_origin: "https://witness.credentials.org".into(), }; let content_source = policy["contentSource"].clone(); let mut envelope = json!({ @@ -6333,6 +6826,8 @@ mod tests { "expiry": registration.expires_at, "display": {"filename":"report.pdf"}, }); + registration.attested_enforcer_binding_bytes = + canonical_json_value(&envelope["attestedEnforcerBinding"]); let mut bytes = b"xyz.tinycloud.share/envelope/v3\0".to_vec(); bytes.extend_from_slice(&canonical_json_value(&envelope)); envelope["signature"] = json!({ @@ -6340,36 +6835,15 @@ mod tests { "signerDid":owner_did, "value":encode_config(owner.sign(&Sha256::digest(bytes)), URL_SAFE_NO_PAD), }); - let key = [5_u8; 32]; - let nonce = [6_u8; 12]; - let ciphertext = Aes256Gcm::new_from_slice(&key) - .unwrap() - .encrypt( - &Nonce::from(nonce), - aes_gcm::aead::Payload { - msg: &canonical_json_value(&envelope), - aad: b"tinycloud-share-envelope-v1", - }, - ) - .unwrap(); - let mut sealed = vec![1_u8]; - sealed.extend_from_slice(&nonce); - sealed.extend_from_slice(&ciphertext); - let share_cid = tinycloud_auth::ipld_core::cid::Cid::new_v1( - 0x55, - tinycloud_auth::multihash_codetable::Code::Sha2_256.digest(&sealed), - ) - .to_string(); + let (sealed_envelope, envelope_key, share_cid) = + seal_delivery_envelope(&envelope, [9; 32], [10; SEALED_ENVELOPE_NONCE_BYTES]); let request = DeliveryAuthorizationRequest { envelope, - sealed_envelope: encode_config(&sealed, URL_SAFE_NO_PAD), - envelope_key: encode_config(key, URL_SAFE_NO_PAD), + sealed_envelope, + envelope_key: envelope_key.clone(), share_cid: share_cid.clone(), recipient_email: "alice@example.com".into(), - share_url: format!( - "https://share.tinycloud.xyz/s/{share_cid}#k={}", - encode_config(key, URL_SAFE_NO_PAD) - ), + share_url: format!("https://share.tinycloud.xyz/s/{share_cid}#k={envelope_key}"), document_name: "report.pdf".into(), jti: encode_config([7_u8; 16], URL_SAFE_NO_PAD), expires_at: "2026-08-06T12:05:00Z".into(), @@ -6419,37 +6893,106 @@ mod tests { } #[test] - fn v3_delivery_rejects_cid_not_bound_to_sealed_envelope() { - let (_, _, _, mut request) = delivery_fixture(); - assert!(verify_v3_sealed_envelope(&request.envelope, &request).is_ok()); - - request.share_cid = "bafkreieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".into(); - assert!(verify_v3_sealed_envelope(&request.envelope, &request).is_err()); - } - - #[test] - fn v3_delivery_rejects_link_with_different_fragment_key() { + fn v3_delivery_binds_cid_to_the_sealed_envelope() { let (_, _, delivery, request) = delivery_fixture(); assert!(v3_delivery_url_matches( &request.share_url, &delivery.return_origin, &request.share_cid, + &request.sealed_envelope, &request.envelope_key, + &request.envelope, )); - let other_key = encode_config([8_u8; 32], URL_SAFE_NO_PAD); - let altered_url = format!( - "{}/s/{}#k={other_key}", - delivery.return_origin, request.share_cid - ); + let altered_cid = "bafkreieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; assert!(!v3_delivery_url_matches( - &altered_url, + &request.share_url, &delivery.return_origin, - &request.share_cid, + altered_cid, + &request.sealed_envelope, &request.envelope_key, + &request.envelope, )); } + #[test] + fn v3_delivery_replay_binding_rejects_recipient_and_resource_rebinding() { + let (_, registration, _, mut request) = delivery_fixture(); + let sender_key_did = "did:key:zSender"; + request.request_body_digest = delivery_request_digest(&request).unwrap(); + let existing = share_invitation_authorization_jti::Model { + jti: request.jti.clone(), + authorization_digest: "digest".into(), + binding_json: json!({ + "version": 3, + "policyCid": registration.policy_cid, + "shareCid": request.share_cid, + "policyId": registration.policy_cid, + "requestBodyDigest": request.request_body_digest, + "senderKeyDid": sender_key_did, + }), + issued_at: "2026-08-06T12:00:00Z".into(), + expires_at: request.expires_at.clone(), + consumed_at: Some("2026-08-06T12:00:00Z".into()), + }; + assert!(delivery_replay_request_matches( + &existing, + &request, + sender_key_did + )); + + let mut rebound_recipient = request.clone(); + rebound_recipient.recipient_email = "mallory@example.com".into(); + rebound_recipient.request_body_digest = + delivery_request_digest(&rebound_recipient).unwrap(); + assert!(!delivery_replay_request_matches( + &existing, + &rebound_recipient, + sender_key_did + )); + + let mut rebound_resource = request.clone(); + rebound_resource.envelope["contentSource"]["kvResource"] = + Value::String("did:key:zOwner/kv/shares/other/report.pdf".into()); + rebound_resource.request_body_digest = delivery_request_digest(&rebound_resource).unwrap(); + assert!(!delivery_replay_request_matches( + &existing, + &rebound_resource, + sender_key_did + )); + assert!(!delivery_replay_request_matches( + &existing, + &request, + "did:key:zDifferentSender" + )); + + let stored = existing.binding_json.to_string(); + assert!(!stored.contains("alice@example.com")); + assert!(!stored.contains(&request.envelope_key)); + } + + #[test] + fn v3_delivery_rejects_fragment_or_query_substitution() { + let (_, _, delivery, request) = delivery_fixture(); + for altered_url in [ + format!("{}#k=secret", request.share_url), + format!("{}&k=secret", request.share_url), + format!( + "https://share.tinycloud.xyz/viewer?tc2={}", + encode_config(canonical_json_value(&request.envelope), URL_SAFE_NO_PAD) + ), + ] { + assert!(!v3_delivery_url_matches( + &altered_url, + &delivery.return_origin, + &request.share_cid, + &request.sealed_envelope, + &request.envelope_key, + &request.envelope, + )); + } + } + #[test] fn v3_delivery_rejects_owner_signed_untrusted_enforcer() { let (owner, registration, delivery, mut request) = delivery_fixture(); @@ -6482,6 +7025,55 @@ mod tests { )); } + #[test] + fn v3_delivery_url_keeps_recipient_material_out_of_loggable_portion() { + let (_, registration, delivery, request) = delivery_fixture(); + let public_url = request.share_url.split('#').next().unwrap(); + assert_eq!( + public_url, + format!("https://share.tinycloud.xyz/s/{}", request.share_cid) + ); + assert!(!public_url.contains("?")); + assert!(!public_url.contains("alice@example.com")); + assert!(v3_delivery_url_matches( + &request.share_url, + &delivery.return_origin, + &request.share_cid, + &request.sealed_envelope, + &request.envelope_key, + &request.envelope, + )); + assert!(v3_envelope_delivery_projection( + &request.envelope, + ®istration, + &delivery, + &request, + ) + .is_ok()); + + let inline_payload = canonical_json_value(&json!({ + "v": 2, + "c": request.sealed_envelope, + "cid": request.share_cid, + "k": request.envelope_key, + })); + let inline_url = format!( + "https://share.tinycloud.xyz/s/inline#v=2&p={}", + encode_config(inline_payload, URL_SAFE_NO_PAD) + ); + let inline_public_url = inline_url.split('#').next().unwrap(); + assert_eq!(inline_public_url, "https://share.tinycloud.xyz/s/inline"); + assert!(!inline_public_url.contains("alice@example.com")); + assert!(v3_delivery_url_matches( + &inline_url, + &delivery.return_origin, + &request.share_cid, + &request.sealed_envelope, + &request.envelope_key, + &request.envelope, + )); + } + #[tokio::test] async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> { use k256::ecdsa::SigningKey; @@ -6554,7 +7146,12 @@ mod tests { })]; let encryption_resource = format!("urn:tinycloud:encryption:{owner_did}:mainnet"); let ceiling = vec![ - requested[0].clone(), + json!({ + "kind": "kv", + "resource": content_resource.to_string(), + "selector": "exact", + "actions": ["tinycloud.kv/get", "tinycloud.kv/metadata"] + }), json!({ "kind": "encryption", "resource": encryption_resource, @@ -6704,9 +7301,45 @@ mod tests { vector["policyProjection"]["issuerKid"].as_str().unwrap(), issuer_key.public().to_bytes(), ); + let projection_object = projection.as_object().unwrap(); + assert!(verify_opencredentials_credential( + &credential, + &requirement, + projection_object, + &issuer, + &holder_did, + issued + Duration::seconds(299), + ) + .is_ok()); + // The compact SD-JWT remains issuer-signed after this mutation. The + // unsigned transport envelope must not be able to extend its pinned + // descriptor freshness window. + let mut freshness_mutated = credential.clone(); + freshness_mutated["status"]["freshnessSeconds"] = json!(3600); + assert!(verify_opencredentials_credential( + &freshness_mutated, + &requirement, + projection_object, + &issuer, + &holder_did, + issued + Duration::seconds(301), + ) + .is_err()); let db = Database::connect("sqlite::memory:").await.unwrap(); - let runtime = - PolicyV3Runtime::new(db.clone(), node_did, node_secret).with_credential_issuer(issuer); + let delivery_config = ShareEmailConfig { + enabled: true, + target_origin: "https://node.example".into(), + return_origin: "https://share.tinycloud.xyz".into(), + email_origin: Some("https://witness.credentials.org".into()), + invitation_public_key: Some(encode_config( + node_secret.share_invitation_public_key(), + URL_SAFE_NO_PAD, + )), + ..ShareEmailConfig::default() + }; + let runtime = PolicyV3Runtime::new(db.clone(), node_did.clone(), node_secret) + .with_credential_issuer(issuer) + .with_delivery(&delivery_config)?; let admission = validate_credential_admission_v3( &requirement, &credential, @@ -6781,42 +7414,55 @@ mod tests { "nativeProjectionHashHex": projections.native_projection_hash_hex, "nodeAudience": runtime.node_did, }); - let root_payload = |audience: &str, role: &str, mode: &str, enforcer: Option<&str>| { - let mut facts = common_facts.as_object().unwrap().clone(); - facts.insert("role".into(), json!(role)); - facts.insert("mode".into(), json!(mode)); - if let Some(enforcer) = enforcer { - facts.insert("enforcerDid".into(), json!(enforcer)); - } - Payload { - issuer: owner_vm.parse::().unwrap(), - audience: audience.parse::().unwrap(), - not_before: Some( - NumericDate::try_from_seconds(issued.unix_timestamp() as f64).unwrap(), - ), - expiration: NumericDate::try_from_seconds(expires.unix_timestamp() as f64).unwrap(), - nonce: Some(format!("tc-470-{role}")), - facts: Some(vec![Value::Object(facts)]), - proof: vec![], - attenuation: serde_json::from_value(projections.attenuation.clone()).unwrap(), - } - .sign(Algorithm::EdDSA, &owner_jwk) - .unwrap() - }; - let policy_root_authorization = TinyCloudDelegation::Ucan(Box::new(root_payload( + let root_authorization = + |audience: &str, role: &str, mode: &str, enforcer: Option<&str>| { + let mut facts = common_facts.as_object().unwrap().clone(); + facts.insert("role".into(), json!(role)); + facts.insert("mode".into(), json!(mode)); + if let Some(enforcer) = enforcer { + facts.insert("enforcerDid".into(), json!(enforcer)); + } + let header = json!({ + "alg": "EdDSA", + "jwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "kty": "OKP", + "x": encode_config(owner_key.public().to_bytes(), URL_SAFE_NO_PAD), + }, + "typ": "JWT", + "ucv": "0.10.0", + }); + let payload = json!({ + "att": projections.attenuation.clone(), + "aud": audience, + "exp": expires.unix_timestamp(), + "fct": [Value::Object(facts)], + "iss": owner_vm.clone(), + "nbf": issued.unix_timestamp(), + "nnc": format!("tc-470-{role}"), + "prf": [], + }); + let protected = encode_config(canonical_json_value(&header), URL_SAFE_NO_PAD); + let payload = encode_config(canonical_json_value(&payload), URL_SAFE_NO_PAD); + let signing_input = format!("{protected}.{payload}"); + format!( + "{signing_input}.{}", + encode_config(owner_key.sign(signing_input.as_bytes()), URL_SAFE_NO_PAD) + ) + }; + let policy_root_authorization = root_authorization( &format!("did:tinycloud:policy:{policy_digest}"), "policy-authority", "policy-source", None, - ))) - .encode()?; - let enforcement_root_authorization = TinyCloudDelegation::Ucan(Box::new(root_payload( + ); + let enforcement_root_authorization = root_authorization( &enforcer_did, "policy-enforcement", "conditional-mint", Some(&enforcer_did), - ))) - .encode()?; + ); use rocket::{http::ContentType, local::asynchronous::Client}; use std::sync::Arc; @@ -6872,10 +7518,12 @@ mod tests { rocket::routes![ register_policy, issue_enforcer_binding, + authorize_delivery, challenge, mint, crate::routes::delegate, - crate::routes::invoke + crate::routes::invoke, + crate::routes::revoke ], ) .attach(crate::tracing::TracingFairing::new( @@ -6916,6 +7564,11 @@ mod tests { "tinycloud.kv/put".parse::()?, [std::collections::BTreeMap::::new()], ); + sender_capabilities.with_action( + content_resource.as_uri(), + "tinycloud.kv/get".parse::()?, + [std::collections::BTreeMap::::new()], + ); let sender_authorization = TinyCloudDelegation::Ucan(Box::new( Payload { issuer: owner_vm.parse()?, @@ -7073,7 +7726,6 @@ mod tests { .await; assert_eq!(binding_response.status(), Status::Ok); let live_binding: Value = binding_response.into_json().await.unwrap(); - let register_response = client .post("/policy/v3/policies") .header(ContentType::JSON) @@ -7094,6 +7746,205 @@ mod tests { let register_status = register_response.status(); let register_body = register_response.into_string().await.unwrap_or_default(); assert_eq!(register_status, Status::Ok, "register: {register_body}"); + let registered: Value = serde_json::from_str(®ister_body)?; + + // Exercise the exact SDK/Rust seam used before email delivery accepts + // an email: owner-signed envelope -> sealed `/s/#k=` link + // -> real ordinary invocation -> Node-signed delivery admission. + let mut delivery_envelope = json!({ + "version": 3, + "shareId": "share-tc-470", + "recipientMatcher": {"kind":"exactEmail","value":"alice@example.test"}, + "deliveryEmail": "alice@example.test", + "actions": ["read"], + "resource": {"kind":"exact","path":"shares/tc-470/document.txt"}, + "target": {"origin":"https://node.example","nodeAudience":node_did,"spaceId":content_space.to_string()}, + "policy": policy, + "policyCid": policy_cid, + "policyRoot": {"cid":registered["policyRootCid"],"authorization":policy_root_authorization,"role":"policy-authority"}, + "enforcementRoot": {"cid":registered["enforcementRootCid"],"authorization":enforcement_root_authorization,"role":"policy-enforcement"}, + "attestedEnforcerBinding": live_binding, + "contentSource": content_source, + "contentSourceDigestHex": projections.content_source_digest_hex, + "encryptionNetwork": encryption_resource, + "expiry": format_time(expires), + "display": {"filename":"document.txt"}, + "encrypted": true, + "metadata": {"filename":"document.txt","mediaType":"text/plain","byteLength":19} + }); + let mut envelope_preimage = b"xyz.tinycloud.share/envelope/v3\0".to_vec(); + envelope_preimage.extend_from_slice(&canonical_json_value(&delivery_envelope)); + delivery_envelope["signature"] = json!({ + "algorithm":"Ed25519", + "signerDid":owner_did, + "value":encode_config(owner_key.sign(&Sha256::digest(envelope_preimage)), URL_SAFE_NO_PAD), + }); + let (sealed_envelope, envelope_key, share_cid) = seal_delivery_envelope( + &delivery_envelope, + [11; 32], + [12; SEALED_ENVELOPE_NONCE_BYTES], + ); + let mut delivery_request = DeliveryAuthorizationRequest { + envelope: delivery_envelope, + sealed_envelope, + envelope_key: envelope_key.clone(), + share_cid: share_cid.clone(), + recipient_email: "alice@example.test".into(), + share_url: format!("https://share.tinycloud.xyz/s/{share_cid}#k={envelope_key}"), + document_name: "document.txt".into(), + jti: encode_config([8_u8; 16], URL_SAFE_NO_PAD), + expires_at: format_time(OffsetDateTime::now_utc() + Duration::minutes(4)), + request_body_digest: String::new(), + }; + delivery_request.request_body_digest = delivery_request_digest(&delivery_request) + .map_err(|_| anyhow::anyhow!("delivery request digest"))?; + let delivery_jwk = JWK::from(Params::OKP(OctetParams { + curve: "Ed25519".to_owned(), + public_key: Base64urlUInt(holder_key.public().to_bytes().to_vec()), + private_key: Some(Base64urlUInt(holder_key.secret().as_ref().to_vec())), + })); + let delivery_invocation = make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &sender_cid, + &delivery_jwk, + &format!("{holder_did}#{}", holder_did.trim_start_matches("did:key:")), + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some("tc498-delivery-intent".into()), + ..InvocationOptions::default() + }, + )?; + let delivery_authorization = delivery_invocation.encode()?; + let delivery_response = client + .post("/policy/v3/deliveries/authorize") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + delivery_authorization.clone(), + )) + .body(serde_json::to_string(&delivery_request)?) + .dispatch() + .await; + let delivery_status = delivery_response.status(); + let delivery_body = delivery_response.into_string().await.unwrap_or_default(); + assert_eq!( + delivery_status, + Status::Ok, + "delivery authorization: {delivery_body}" + ); + let delivery_receipt: Value = serde_json::from_str(&delivery_body)?; + assert_eq!( + delivery_receipt["admission"]["recipient"], + "alice@example.test" + ); + assert_eq!( + delivery_receipt["admission"]["returnLink"], + delivery_request.share_url + ); + assert_eq!( + delivery_receipt["admission"]["audience"], + "https://witness.credentials.org" + ); + + // Model the cross-service partial failure where Node authorized the + // delivery but the OpenCredentials response was lost. The SDK retries + // with the same idempotency-derived JTI and must recover the identical + // signed receipt so it can safely retry credential invitation creation. + let delivery_retry = client + .post("/policy/v3/deliveries/authorize") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + delivery_authorization.clone(), + )) + .body(serde_json::to_string(&delivery_request)?) + .dispatch() + .await; + let delivery_retry_status = delivery_retry.status(); + let delivery_retry_body = delivery_retry.into_string().await.unwrap_or_default(); + assert_eq!( + delivery_retry_status, + Status::Ok, + "delivery authorization retry: {delivery_retry_body}" + ); + assert_eq!(delivery_retry_body, delivery_body); + let delivery_retry_receipt: Value = serde_json::from_str(&delivery_retry_body)?; + assert_eq!(delivery_retry_receipt, delivery_receipt); + + let persisted_replay = + share_invitation_authorization_jti::Entity::find_by_id(delivery_request.jti.clone()) + .one(&client.rocket().state::().unwrap().conn) + .await? + .ok_or_else(|| anyhow::anyhow!("missing delivery replay record"))?; + assert_eq!( + persisted_replay.binding_json["requestBodyDigest"], + delivery_request.request_body_digest + ); + assert_eq!(persisted_replay.binding_json["senderKeyDid"], holder_did); + let persisted_binding = persisted_replay.binding_json.to_string(); + assert!(!persisted_binding.contains("alice@example.test")); + assert!(!persisted_binding.contains(&envelope_key)); + + // Reusing the same JTI for a different signed body is a conflict, not + // a second authorization. This is checked before envelope projection + // so recipient/resource/link rebinding cannot change the error class. + let mut rebound_request: DeliveryAuthorizationRequest = + serde_json::from_value(serde_json::to_value(&delivery_request)?)?; + rebound_request.expires_at = + format_time(parse_time(&delivery_request.expires_at)? - Duration::seconds(1)); + rebound_request.request_body_digest = delivery_request_digest(&rebound_request) + .map_err(|_| anyhow::anyhow!("rebound delivery request digest"))?; + let rebound_response = client + .post("/policy/v3/deliveries/authorize") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + delivery_authorization, + )) + .body(serde_json::to_string(&rebound_request)?) + .dispatch() + .await; + assert_eq!(rebound_response.status(), Status::Conflict); + + if std::env::var("TC498_EMIT_DELIVERY_RECEIPT").as_deref() == Ok("1") { + let request = delivery_receipt["request"].clone(); + let mut proof_preimage = INVITATION_REQUEST_SCHEMA.as_bytes().to_vec(); + proof_preimage.push(0); + proof_preimage.extend_from_slice(&canonical_json_value(&request)); + let proof = json!({ + "alg":"EdDSA", + "kid":holder_did, + "signature":encode_config(holder_key.sign(&Sha256::digest(proof_preimage)), URL_SAFE_NO_PAD), + }); + let location_payload = format!( + "{{\"version\":1,\"subject\":{},\"multiaddrs\":[\"/dns/node.example/tcp/443/tls/http\"],\"updated_at\":\"2026-08-22T00:00:00.000Z\",\"sequence\":1}}", + serde_json::to_string(&owner_did)? + ); + let location_record = json!({ + "version":1, + "subject":owner_did, + "multiaddrs":["/dns/node.example/tcp/443/tls/http"], + "updated_at":"2026-08-22T00:00:00.000Z", + "sequence":1, + "signature":encode_config(owner_key.sign(location_payload.as_bytes()), URL_SAFE_NO_PAD), + }); + println!( + "TC498_DELIVERY_RECEIPT={}", + json!({ + "receipt": { + "request": request, + "admission": delivery_receipt["admission"], + "proof": proof, + }, + "locationRecord": location_record, + "nodeOrigin": "https://node.example", + "nodeDid": node_did, + }) + ); + } let challenge_response = client .post("/policy/v3/challenges") @@ -7423,6 +8274,8 @@ mod tests { ); assert_eq!(v4_read_body, b"tc-470-real-content"); + // A duplicate presentation remains a replay even while the roots are + // otherwise live. Root revocation below is a separate denial reason. let replay = client .post("/policy/v3/delegations") .header(ContentType::JSON) @@ -7430,6 +8283,118 @@ mod tests { .dispatch() .await; assert_eq!(replay.status(), Status::Unauthorized); + + // SDK revocation writes the generic graph. Policy/v3 joins that + // graph with its signed root status, so this one root revocation must + // close the already-minted session plus new control-plane admission + // and delivery. It intentionally does not fabricate a root-status + // checkpoint. + let policy_root_cid = registered["policyRootCid"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing policy root cid"))? + .parse::()?; + let mut revoke_capabilities = Capabilities::::new(); + revoke_capabilities.with_action( + format!("urn:cid:{policy_root_cid}").parse()?, + "tinycloud.delegation/revoke".parse::()?, + [std::collections::BTreeMap::::new()], + ); + let generic_revoke = tinycloud_auth::authorization::TinyCloudRevocation::Ucan(Box::new( + Payload { + issuer: owner_vm.parse()?, + audience: owner_did.parse()?, + not_before: None, + expiration: NumericDate::try_from_seconds( + (OffsetDateTime::now_utc() + Duration::seconds(60)).unix_timestamp() as f64, + )?, + nonce: Some("tc500-policy-root-generic-revoke".into()), + facts: Some(Vec::new()), + proof: Vec::new(), + attenuation: revoke_capabilities, + } + .sign(Algorithm::EdDSA, &owner_jwk)?, + )); + let revoke_response = client + .post("/revoke") + .header(rocket::http::Header::new( + "Authorization", + generic_revoke.encode()?, + )) + .dispatch() + .await; + let revoke_status = revoke_response.status(); + let revoke_body = revoke_response.into_string().await.unwrap_or_default(); + assert_eq!(revoke_status, Status::Ok, "SDK root revoke: {revoke_body}"); + + let post_revoke_now = OffsetDateTime::now_utc(); + let post_revoke_read = Payload { + issuer: reader_vm.parse()?, + audience: reader_did.parse()?, + not_before: Some(NumericDate::try_from_seconds( + post_revoke_now.unix_timestamp() as f64, + )?), + expiration: NumericDate::try_from_seconds( + (post_revoke_now + Duration::seconds(30)).unix_timestamp() as f64, + )?, + nonce: Some("tc500-existing-session-after-root-revoke".into()), + facts: Some(Vec::::new()), + proof: vec![child_cid], + attenuation: serde_json::from_value::>( + attenuation_for_policy_capabilities(&requested) + .map_err(|(_, error)| anyhow::anyhow!(error))?, + )?, + } + .sign(Algorithm::EdDSA, &reader_jwk)?; + let post_revoke_invoke = client + .post("/invoke") + .header(rocket::http::Header::new( + "Authorization", + post_revoke_read.encode()?, + )) + .dispatch() + .await; + assert_eq!(post_revoke_invoke.status(), Status::Forbidden); + + let post_revoke_challenge = client + .post("/policy/v3/challenges") + .header(ContentType::JSON) + .body( + json!({ + "policyCid": policy_cid, + "recipientDid": holder_did, + "requestedCapabilities": requested, + }) + .to_string(), + ) + .dispatch() + .await; + assert_eq!(post_revoke_challenge.status(), Status::Forbidden); + + let post_revoke_delivery = make_invocation( + [( + content_resource.clone(), + ["tinycloud.kv/get".parse::()?], + )], + &sender_cid, + &delivery_jwk, + &format!("{holder_did}#{}", holder_did.trim_start_matches("did:key:")), + (OffsetDateTime::now_utc() + Duration::seconds(45)).unix_timestamp() as f64, + InvocationOptions { + nonce: Some("tc500-delivery-after-root-revoke".into()), + ..InvocationOptions::default() + }, + )?; + let post_revoke_delivery_response = client + .post("/policy/v3/deliveries/authorize") + .header(ContentType::JSON) + .header(rocket::http::Header::new( + "Authorization", + post_revoke_delivery.encode()?, + )) + .body(serde_json::to_string(&delivery_request)?) + .dispatch() + .await; + assert_eq!(post_revoke_delivery_response.status(), Status::Forbidden); Ok(()) } diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index b3a38b3a..29742783 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -518,9 +518,11 @@ pub async fn delegate( /// `did:key`-signed UCAN-format revocation by the Grant Issuer. That second /// signature suite is staged on top of the existing pipeline as a followup /// (it requires a new variant in `tinycloud-auth::TinyCloudRevocation`); the -/// Policy-v3 roots are control-plane artifacts and are revoked only through -/// their signed `/policy/v3/status` checkpoint; this route must never -/// turn an ordinary revocation into a root-status projection. +/// Policy-v3 preserves its signed root-status projection, while its runtime +/// also consults this durable generic graph. Thus an SDK revocation of a +/// registered root immediately denies Policy/v3 admission, delivery, and +/// ordinary descendant invocation without this route forging a status +/// checkpoint. #[post("/revoke")] pub async fn revoke( r: AuthHeaderGetter, From 7a58693f8bcd0d4e9d4df40dd464abd8c9c763ed Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Mon, 14 Sep 2026 22:43:18 -0400 Subject: [PATCH 5/5] chore(release): tinycloud-node 1.17.1 (TC-500) (#235) --- CHANGELOG.md | 4 ++++ Cargo.lock | 2 +- test/m1-realdata-e2e/Cargo.lock | 2 +- tinycloud-node-server/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fbd811..979b349f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.17.1] - 2026-09-15 + +- Integrate the reviewed native sharing correction into the TinyChat production lineage: exact-email delivery is authorized by embedded Policy v3, retries preserve strict request-body, JTI, and sender-DID replay binding, and recipient access remains scoped to the ordinary `/delegate` then `/invoke` storage-enforcer path. TinyChat meeting publication, legacy write guards, and digest-pinned deployment are unchanged (TC-500, #234). + ## [1.17.0] - 2026-09-15 - Box large unauthorized resource payloads for current Rust Clippy checks; the two Rust error constructors now take `Box`, with unchanged authorization decisions and error messages. diff --git a/Cargo.lock b/Cargo.lock index 7f8d1d07..6d725fa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9679,7 +9679,7 @@ dependencies = [ [[package]] name = "tinycloud-node" -version = "1.17.0" +version = "1.17.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index 96b752c0..5ea81c9f 100644 --- a/test/m1-realdata-e2e/Cargo.lock +++ b/test/m1-realdata-e2e/Cargo.lock @@ -7753,7 +7753,7 @@ dependencies = [ [[package]] name = "tinycloud-node" -version = "1.16.0" +version = "1.17.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/tinycloud-node-server/Cargo.toml b/tinycloud-node-server/Cargo.toml index 8344b7a7..5d0ed091 100644 --- a/tinycloud-node-server/Cargo.toml +++ b/tinycloud-node-server/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tinycloud-node" build = "build.rs" -version = "1.17.0" +version = "1.17.1" authors = ["TinyCloud Protocol"] edition = "2021" description = "TinyCloud Protocol Node"