diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f62eac69..885e4a49 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: @@ -408,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/CHANGELOG.md b/CHANGELOG.md index d19c1145..979b349f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 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. +- 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..6d725fa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9679,7 +9679,7 @@ dependencies = [ [[package]] name = "tinycloud-node" -version = "1.16.0" +version = "1.17.1" dependencies = [ "aes-gcm", "anyhow", 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/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 3f009558..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.16.0" +version = "1.17.1" authors = ["TinyCloud Protocol"] edition = "2021" description = "TinyCloud Protocol Node" 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,