diff --git a/.github/workflows/dstack-ingress-release.yml b/.github/workflows/dstack-ingress-release.yml index cedbe18..d4a2025 100644 --- a/.github/workflows/dstack-ingress-release.yml +++ b/.github/workflows/dstack-ingress-release.yml @@ -90,4 +90,4 @@ jobs: | Image | Digest | Verification | |---|---|---| - | `${IMAGE_REFERENCE}` | `${IMAGE_DIGEST}` | [Verify on Sigstore](https://search.sigstore.dev/?hash=${IMAGE_DIGEST}) | \ No newline at end of file + | ${{ env.IMAGE_REFERENCE }} | ${{ steps.capture-digest.outputs.digest }} | [Verify on Sigstore](https://search.sigstore.dev/?hash=${{ steps.capture-digest.outputs.digest }}) | \ No newline at end of file diff --git a/.github/workflows/reproducible-build.yml b/.github/workflows/reproducible-build.yml new file mode 100644 index 0000000..3b0218d --- /dev/null +++ b/.github/workflows/reproducible-build.yml @@ -0,0 +1,107 @@ +name: Reproducible Build + +on: + push: + paths: + - 'tutorial/01a-reproducible-builds/**' + pull_request: + paths: + - 'tutorial/01a-reproducible-builds/**' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}/tutorial-01a-oracle + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + defaults: + run: + working-directory: tutorial/01a-reproducible-builds + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install skopeo + run: sudo apt-get update && sudo apt-get install -y skopeo + + - name: Build reproducible image + run: | + docker buildx create --name repro-builder --driver docker-container || true + docker buildx build \ + --builder repro-builder \ + --build-arg SOURCE_DATE_EPOCH=0 \ + --no-cache \ + --output type=oci,dest=image.tar,rewrite-timestamp=true \ + . + + - name: Compute and display hash + id: hash + run: | + HASH=$(sha256sum image.tar | awk '{print $1}') + DIGEST=$(skopeo inspect oci-archive:image.tar | jq -r .Digest) + echo "image_hash=$HASH" >> $GITHUB_OUTPUT + echo "image_digest=$DIGEST" >> $GITHUB_OUTPUT + echo "## Reproducible Build Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| **Image Hash** | \`$HASH\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Image Digest** | \`$DIGEST\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Compare with your local build:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "cd tutorial/01a-reproducible-builds && ./build-reproducible.sh" >> $GITHUB_STEP_SUMMARY + echo "cat build-manifest.json" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + + - name: Verify against committed manifest + run: | + if [[ -f build-manifest.json ]]; then + EXPECTED=$(jq -r .image_hash build-manifest.json) + ACTUAL="${{ steps.hash.outputs.image_hash }}" + echo "Expected: $EXPECTED" + echo "Actual: $ACTUAL" + if [[ "$EXPECTED" == "$ACTUAL" ]]; then + echo "✓ Build matches committed manifest" + else + echo "✗ Build differs from committed manifest" + exit 1 + fi + else + echo "No build-manifest.json found - skipping verification" + fi + + - name: Upload OCI image + uses: actions/upload-artifact@v4 + with: + name: reproducible-image + path: tutorial/01a-reproducible-builds/image.tar + retention-days: 7 + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push to GHCR + if: github.event_name != 'pull_request' + run: | + IMAGE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" + skopeo copy oci-archive:image.tar docker://$IMAGE_TAG + echo "Pushed: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + LATEST_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" + skopeo copy oci-archive:image.tar docker://$LATEST_TAG + echo "Pushed: $LATEST_TAG" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 53d811c..b9f8b8b 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -63,22 +63,22 @@ jobs: fi - name: Upload Hadolint results - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 if: always() with: sarif_file: hadolint-results.sarif - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: python queries: security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:python" diff --git a/README.md b/README.md index a869c1b..2c27108 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ **Example applications for [dstack](https://github.com/Dstack-TEE/dstack) - Deploy containerized apps to TEEs with end-to-end security in minutes** -[Getting Started](#getting-started) • [Examples](#examples) • [Contributing](CONTRIBUTING.md) • [Documentation](#documentation) • [Community](#community) +[Getting Started](#getting-started) • [Use Cases](#use-cases) • [Core Patterns](#core-patterns) • [Dev Tools](#dev-scaffolding) • [Starter Packs](#starter-packs) • [Other Use Cases](#other-use-cases) @@ -19,113 +19,220 @@ This repository contains ready-to-deploy examples demonstrating how to build and run applications on [dstack](https://github.com/Dstack-TEE/dstack), the developer-friendly SDK for deploying containerized apps in Trusted Execution Environments (TEE). -### What You'll Find Here +## Getting Started -- **Security Features** - Remote attestation, verification, and privacy-preserving apps -- **Secret Management** - Secure handling of credentials and sensitive data in TEE environments -- **Networking Patterns** - HTTPS termination, custom domains, port forwarding in the cloud -- **Best Practices** - Production-ready implementations following TEE security principles +### Prerequisites -## Prerequisites +- Docker and Docker Compose +- Node.js (for Phala CLI) +- Git -Before you begin, ensure you have: +### Setup -- Access to a dstack environment -- Basic understanding of [TEE concepts](https://docs.phala.network/dstack) -- Basic familiarity with Docker Compose configuration files -- Git for cloning the repository +```bash +# Clone the repo +git clone https://github.com/Dstack-TEE/dstack-examples.git +cd dstack-examples -You can deploy dstack on your own server, or use [Phala Cloud](https://cloud.phala.network). +# Install Phala CLI +npm install -g phala -## Getting Started +# Start the local simulator (no TEE hardware needed) +phala simulator start +``` -### Quick Start +### Run an Example Locally ```bash -# Clone the repository -git clone https://github.com/Dstack-TEE/dstack-examples.git -cd dstack-examples +cd tutorial/01-attestation-oracle +docker compose run --rm \ + -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock \ + app +``` + +### Deploy to Phala Cloud + +```bash +phala auth login +phala deploy -n my-app -c docker-compose.yaml +``` + +See [Phala Cloud](https://cloud.phala.network) for production TEE deployment. + +--- + +## Tutorials + +Step-by-step guides covering core dstack concepts. + +| Tutorial | Description | +|----------|-------------| +| [01-attestation-oracle](./tutorial/01-attestation-oracle) | Use the guest SDK to work with attestations directly — build an oracle, bind data to TDX quotes via `report_data`, verify with local scripts | +| [02-persistence-and-kms](./tutorial/02-persistence-and-kms) | Use `getKey()` for deterministic key derivation from a KMS — persistent wallets, same key across restarts | +| [03-gateway-and-ingress](./tutorial/03-gateway-and-ingress) | Custom domains with automatic SSL, certificate evidence chain | +| [04-upgrades](./tutorial/04-upgrades) | Extend `AppAuth.sol` with custom authorization logic — NFT-gated clusters, on-chain governance | + +--- -# Choose an example -cd attestation/configid-based +## Use Cases -# Copy the docker-compose.yaml content to your dstack deployment -# Follow the example-specific README for deployment instructions +Real-world applications you can build with dstack. + +| Example | Description | Status | +|---------|-------------|--------| +| [8004-agent](./8004-agent) | Trustless AI agent with on-chain attestation and LLM access | Coming Soon | +| [oracle](./oracle) | TEE oracle returning JSON + signature + attestation bundle | Coming Soon | +| [mcp-server](./mcp-server) | Attested MCP tool server behind gateway | Coming Soon | +| [telegram-agent](./telegram-agent) | Telegram bot with TEE wallet and verified execution | Coming Soon | + +--- + +## Core Patterns + +Key building blocks for dstack applications. + +### Attestation + +Request TEE attestations via the SDK. Mount `/var/run/dstack.sock` in your compose file to access the TEE. + +```javascript +import { DstackClient } from '@phala/dstack-sdk' +const client = new DstackClient() +const info = await client.info() // app_id, instance_id, tcb_info +const quote = await client.getQuote(data) // TDX quote with custom report_data +const key = await client.getKey('/my/path') // deterministic key derivation ``` -## Examples +```yaml +volumes: + - /var/run/dstack.sock:/var/run/dstack.sock +``` + +| Example | Description | Status | +|---------|-------------|--------| +| [timelock-nts](./timelock-nts) | Raw socket usage (what the SDK wraps) | Available | +| [attestation/configid-based](./attestation/configid-based) | ConfigID-based verification | Available | + +### Gateway & Domains + +TLS termination, custom domains, external connectivity. -### Security & Attestation | Example | Description | |---------|-------------| -| [attestation/configid-based](./attestation/configid-based) | ConfigID-based remote attestation verification | -| [attestation/rtmr3-based](./attestation/rtmr3-based) | RTMR3-based attestation (legacy) | +| [dstack-ingress](./custom-domain/dstack-ingress) | **Complete ingress solution** — auto SSL via Let's Encrypt, multi-domain, DNS validation, evidence generation with TDX quote chain | +| [custom-domain](./custom-domain/custom-domain) | Simpler custom domain setup via zt-https | + +### Keys & Persistence + +Persistent keys across deployments via KMS. + +| Example | Description | Status | +|---------|-------------|--------| +| [get-key-basic](./get-key-basic) | `dstack.get_key()` — same key identity across machines | Coming Soon | + +### On-Chain Interaction + +Light client for reading chain state, anchoring outputs. -### Networking & Domains | Example | Description | |---------|-------------| -| [custom-domain](./custom-domain) | Set up custom domain with automatic TLS certificate management via zt-https | +| [lightclient](./lightclient) | Ethereum light client (Helios) running in enclave | + +--- + +## Dev Scaffolding + +Development and debugging tools. **Not for production.** + +| Example | Description | +|---------|-------------| +| [webshell](./webshell) | Web-based shell access for debugging | | [ssh-over-gateway](./ssh-over-gateway) | SSH tunneling through dstack gateway | | [tcp-port-forwarding](./tcp-port-forwarding) | Arbitrary TCP port forwarding | -| [tor-hidden-service](./tor-hidden-service) | Run Tor hidden services in TEEs | -### Development Tools +--- + +## Tech Demos + +Interesting demonstrations. + | Example | Description | |---------|-------------| -| [launcher](./launcher) | Generic launcher pattern for Docker Compose apps | -| [webshell](./webshell) | Web-based shell access for debugging | -| [prelaunch-script](./prelaunch-script) | Pre-launch script patterns used by Phala Cloud | +| [tor-hidden-service](./tor-hidden-service) | Run Tor hidden services in TEEs | + +--- + +## Starter Packs + +Full-stack templates with SDK integration. These demonstrate attestation, key derivation, and wallet generation. + +| Template | Stack | Link | +|----------|-------|------| +| **Next.js Starter** | Next.js + TypeScript | [phala-cloud-nextjs-starter](https://github.com/Phala-Network/phala-cloud-nextjs-starter) | +| **Python Starter** | FastAPI + Python | [phala-cloud-python-starter](https://github.com/Phala-Network/phala-cloud-python-starter) | +| **Bun Starter** | Bun + TypeScript | [phala-cloud-bun-starter](https://github.com/Phala-Network/phala-cloud-bun-starter) | +| **Node.js Starter** | Express + TypeScript | [phala-cloud-node-starter](https://github.com/Gldywn/phala-cloud-node-starter) | + +Features: `/api/tdx_quote` (attestation), `/api/eth_account` (derived wallet), `/api/info` (TCB info) + +--- + +## Other Use Cases + +External projects and templates worth exploring. These are maintained elsewhere but demonstrate interesting TEE patterns. + +| Project | Description | Link | +|---------|-------------|------| +| **Oracle Template** | Price aggregator with verifiable networking (hardened TLS) and multi-source validation | [Gldywn/phala-cloud-oracle-template](https://github.com/Gldywn/phala-cloud-oracle-template) | +| **VRF Template** | Verifiable Random Function — hardware-backed cryptographic randomness | [Phala-Network/phala-cloud-vrf-template](https://github.com/Phala-Network/phala-cloud-vrf-template) | +| **Open WebUI** | Self-hosted AI chat interface in TEE | [phala-cloud/templates/openwebui](https://github.com/Phala-Network/phala-cloud/tree/main/templates/prebuilt/openwebui) | +| **n8n Automation** | Workflow automation (400+ integrations) with OAuth in TEE | [Marvin-Cypher/phala-n8n-template](https://github.com/Marvin-Cypher/phala-n8n-template) | +| **Primus Attestor** | zkTLS node — TEE + zero-knowledge proofs | [primus-labs/primus-network-startup](https://github.com/primus-labs/primus-network-startup) | +| **NEAR Shade Agent** | Blockchain oracle/agent for NEAR with TEE attestation | [phala-cloud/templates/near-shade-agent](https://github.com/Phala-Network/phala-cloud/tree/main/templates/prebuilt/near-shade-agent) | +| **Presidio** | Microsoft's PII de-identification running in TEE | [HashWarlock/presidio](https://github.com/HashWarlock/presidio/tree/phala-cloud) | +| **ByteBot** | AI desktop agent — computer control in isolated TEE sandbox | [phala-cloud/templates/bytebot](https://github.com/Phala-Network/phala-cloud/tree/main/templates/prebuilt/bytebot) | + +> **Note**: These templates use pre-built Docker images. For full auditability, review their source repos before deployment. + +See the full [Phala Cloud templates](https://github.com/Phala-Network/phala-cloud#templates) for more options. + +--- + +## Details + +Implementation details and infrastructure patterns. -### Advanced Use Cases | Example | Description | |---------|-------------| -| [lightclient](./lightclient) | Blockchain light client integration | -| [timelock-nts](./timelock-nts) | Timelock decryption with NTS | +| [launcher](./launcher) | Generic launcher pattern for Docker Compose apps | +| [prelaunch-script](./prelaunch-script) | Pre-launch script patterns (Phala Cloud) | | [private-docker-image-deployment](./private-docker-image-deployment) | Using private Docker registries | +| [attestation/rtmr3-based](./attestation/rtmr3-based) | RTMR3-based attestation (legacy) | + +--- ## Documentation - **[dstack Documentation](https://docs.phala.network/dstack)** - Official platform documentation - **[Main Repository](https://github.com/Dstack-TEE/dstack)** - Core dstack framework -- **[Security Guide](SECURITY.md)** - Security best practices - **[Contributing Guide](CONTRIBUTING.md)** - How to contribute ## Development -Use the `dev.sh` script for validation and development tasks: - ```bash ./dev.sh help # Show available commands ./dev.sh validate # Validate a specific example ./dev.sh validate-all # Validate all examples -./dev.sh security # Run security checks -./dev.sh lint # Run linting checks -./dev.sh check-all # Run all checks ``` -## Contributing - -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. - ## Community -### Getting Help - - **Telegram**: [Join our community](https://t.me/+UO4bS4jflr45YmUx) - **Issues**: [GitHub Issues](https://github.com/Dstack-TEE/dstack-examples/issues) -### Reporting Issues - -When reporting issues, please include: - -1. Example name and version -2. Steps to reproduce -3. Expected vs actual behavior -4. Relevant logs and error messages - ## License -This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. +Apache 2.0 — see [LICENSE](LICENSE). --- diff --git a/custom-domain/dstack-ingress/CLOUDFORMATION_EXAMPLE.yaml b/custom-domain/dstack-ingress/CLOUDFORMATION_EXAMPLE.yaml new file mode 100644 index 0000000..7435da0 --- /dev/null +++ b/custom-domain/dstack-ingress/CLOUDFORMATION_EXAMPLE.yaml @@ -0,0 +1,86 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Parameters: + HostedZoneId: + Type: String + Default: + Description: Route53 Hosted Zone ID + UserName: + Type: String + Description: IAM user that can only assume the Route53 role + +Resources: + User: + Type: AWS::IAM::User + Properties: + UserName: !Ref UserName + + AccessKey: + Type: AWS::IAM::AccessKey + Properties: + UserName: !Ref User + Status: Active + + Route53Role: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub '${UserName}-route53-role' + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + # The *account root* as trusted principal. + # This avoids invalid-principal errors while remaining safe, + # because the USER policy enforces the actual restriction. + Principal: + AWS: !Sub arn:aws:iam::${AWS::AccountId}:root + Action: sts:AssumeRole + Policies: + - PolicyName: Route53DnsChallenges + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AllowDnsChallengeChanges + Effect: Allow + Action: + - route53:ChangeResourceRecordSets + Resource: !Sub arn:aws:route53:::hostedzone/${HostedZoneId} + - Sid: AllowListingForDnsChallenge + Effect: Allow + Action: + - route53:ListHostedZonesByName + - route53:ListHostedZones + - route53:GetChange + - route53:ListResourceRecordSets + Resource: "*" + + UserAssumeRolePolicy: + Type: AWS::IAM::Policy + Properties: + PolicyName: !Sub '${UserName}-assume-route53-role' + Users: + - !Ref User + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sts:AssumeRole + Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/${UserName}-route53-role + +Outputs: + AWSAccessKeyId: + Description: Access key ID for the IAM user + Value: !Ref AccessKey + + AWSSecretAccessKey: + Description: Secret access key for the IAM user + Value: !GetAtt AccessKey.SecretAccessKey + + AWSUserArn: + Description: IAM User ARN + Value: !Sub arn:aws:iam::${AWS::AccountId}:user/${UserName} + + Route53RoleArn: + Description: ARN of the Route53 role used by Certbot + Value: !Sub arn:aws:iam::${AWS::AccountId}:role/${UserName}-route53-role diff --git a/custom-domain/dstack-ingress/DNS_PROVIDERS.md b/custom-domain/dstack-ingress/DNS_PROVIDERS.md index ee70c7e..845d288 100644 --- a/custom-domain/dstack-ingress/DNS_PROVIDERS.md +++ b/custom-domain/dstack-ingress/DNS_PROVIDERS.md @@ -7,6 +7,7 @@ This guide explains how to configure dstack-ingress to work with different DNS p - **Cloudflare** - The original and default provider - **Linode DNS** - For Linode-hosted domains - **Namecheap** - For Namecheap-hosted domains +- **Route53** - For AWS hosted domains ## Environment Variables @@ -73,6 +74,40 @@ NAMECHEAP_CLIENT_IP=your-client-ip - Namecheap doesn't support CAA records through their API currently - The certbot plugin uses the format `certbot-dns-namecheap` package +### Route53 + +```bash +DNS_PROVIDER=route53 +AWS_ACCESS_KEY_ID=service-account-key-that-can-assume-role +AWS_SECRET_ACCESS_KEY=service-account-secret-that-can-assume-role +AWS_ROLE_ARN=role-that-can-mod-route53 +AWS_REGION=your-closest-region +``` + +**Required Permissions:** +```yaml +PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: AllowDnsChallengeChanges + Effect: Allow + Action: + - route53:ChangeResourceRecordSets + Resource: !Sub arn:aws:route53:::hostedzone/${HostedZoneId} + - Sid: AllowListingForDnsChallenge + Effect: Allow + Action: + - route53:ListHostedZonesByName + - route53:ListHostedZones + - route53:GetChange + - route53:ListResourceRecordSets +``` + +**Important Notes for Route53:** +- The certbot plugin uses the format `certbot-dns-route53` package +- CAA will merge AWS & Let's Encrypt CA domains to existing records if they exist +- It is essential that the AWS service account used can only assume the limited role. See cloudformation example. + ## Docker Compose Examples ### Linode Example @@ -127,6 +162,34 @@ services: - ./evidences:/evidences ``` +### Route53 Example + +```yaml +services: + dstack-ingress: + image: dstack-ingress:latest + restart: unless-stopped + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock + - cert-data:/etc/letsencrypt + ports: + - 443:443 + environment: + DNS_PROVIDER: route53 + DOMAIN: app.example.com + GATEWAY_DOMAIN: _.${DSTACK_GATEWAY_DOMAIN} + + AWS_REGION: ${AWS_REGION} + AWS_ROLE_ARN: ${AWS_ROLE_ARN} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + + CERTBOT_EMAIL: ${CERTBOT_EMAIL} + TARGET_ENDPOINT: http://backend:8080 + SET_CAA: 'true' + +``` + ## Migration from Cloudflare-only Setup If you're currently using the Cloudflare-only version: @@ -166,4 +229,4 @@ Ensure your API tokens/credentials have the necessary permissions listed above f 1. Go to https://ap.www.namecheap.com/settings/tools/api-access/ 2. Enable API access for your account 3. Note down your API key and username -4. Make sure your IP address is whitelisted in the API settings \ No newline at end of file +4. Make sure your IP address is whitelisted in the API settings diff --git a/custom-domain/dstack-ingress/Dockerfile b/custom-domain/dstack-ingress/Dockerfile index 9e11a34..95fe693 100644 --- a/custom-domain/dstack-ingress/Dockerfile +++ b/custom-domain/dstack-ingress/Dockerfile @@ -58,6 +58,7 @@ RUN --mount=type=bind,source=scripts,target=/tmp/scripts,ro \ ENV PATH="/scripts:$PATH" ENV PYTHONPATH="/scripts" +ENV PYTHONUNBUFFERED=1 COPY --chmod=666 .GIT_REV /etc/ ENTRYPOINT ["/scripts/entrypoint.sh"] diff --git a/custom-domain/dstack-ingress/README.md b/custom-domain/dstack-ingress/README.md index 3b2c478..e9eae37 100644 --- a/custom-domain/dstack-ingress/README.md +++ b/custom-domain/dstack-ingress/README.md @@ -180,6 +180,9 @@ configs: - `PROXY_READ_TIMEOUT`: Optional value for nginx `proxy_read_timeout` (numeric with optional `s|m|h` suffix, e.g. `30s`) in single-domain mode - `PROXY_SEND_TIMEOUT`: Optional value for nginx `proxy_send_timeout` (numeric with optional `s|m|h` suffix, e.g. `30s`) in single-domain mode - `PROXY_CONNECT_TIMEOUT`: Optional value for nginx `proxy_connect_timeout` (numeric with optional `s|m|h` suffix, e.g. `10s`) in single-domain mode +- `PROXY_BUFFER_SIZE`: Optional value for nginx `proxy_buffer_size` (numeric with optional `k|m` suffix, e.g. `128k`) in single-domain mode +- `PROXY_BUFFERS`: Optional value for nginx `proxy_buffers` (format: `number size`, e.g. `4 256k`) in single-domain mode +- `PROXY_BUSY_BUFFERS_SIZE`: Optional value for nginx `proxy_busy_buffers_size` (numeric with optional `k|m` suffix, e.g. `256k`) in single-domain mode - `CERTBOT_STAGING`: Optional; set this value to the string `true` to set the `--staging` server option on the [`certbot` cli](https://eff-certbot.readthedocs.io/en/stable/using.html#certbot-command-line-options) **Backward Compatibility:** diff --git a/custom-domain/dstack-ingress/scripts/certman.py b/custom-domain/dstack-ingress/scripts/certman.py index 7495b14..52126d2 100644 --- a/custom-domain/dstack-ingress/scripts/certman.py +++ b/custom-domain/dstack-ingress/scripts/certman.py @@ -291,7 +291,10 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s if os.environ.get("CERTBOT_STAGING", "false") == "true": base_cmd.extend(["--staging"]) - base_cmd.extend(["--dns-cloudflare-propagation-seconds=120"]) + if getattr(self.provider, 'CERTBOT_PROPAGATION_SECONDS'): + propagation_seconds = self.provider.CERTBOT_PROPAGATION_SECONDS + propagation_param = f"--dns-{self.provider_type}-propagation-seconds={propagation_seconds}" + base_cmd.extend([propagation_param]) # Log command with masked email for debugging masked_cmd = [arg if not (i > 0 and base_cmd[i-1] == "--email") else "" @@ -380,13 +383,17 @@ def renew_certificate(self, domain: str) -> Tuple[bool, bool]: result = subprocess.run( cmd, capture_output=True, text=True, timeout=300) + stdout_output = result.stdout.strip() if result.stdout else "" + error_output = result.stderr.strip() if result.stderr else "" + if result.returncode == 0: + # Check if certbot actually renewed anything + if "No renewals were attempted" in stdout_output: + print("No certificates need renewal") + return True, False print(f"✓ Certificate renewal completed") return True, True else: - error_output = result.stderr.strip() if result.stderr else "" - stdout_output = result.stdout.strip() if result.stdout else "" - print( f"✗ Certificate renewal failed (exit code: {result.returncode})") @@ -406,14 +413,6 @@ def renew_certificate(self, domain: str) -> Tuple[bool, bool]: return False, False - # Check if no renewals were needed - if "No renewals were attempted" in result.stdout: - print("No certificates need renewal") - return True, False - - print("Certificate renewed successfully") - return True, True - except Exception as e: print(f"Error running certbot: {e}", file=sys.stderr) return False, False @@ -423,6 +422,23 @@ def certificate_exists(self, domain: str) -> bool: cert_path = f"/etc/letsencrypt/live/{domain}/fullchain.pem" return os.path.isfile(cert_path) + def acme_account_exists(self) -> bool: + """Check if an ACME account exists for the current server (staging or production). + + The account directory differs between staging and production: + - production: /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/ + - staging: /etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/ + + When switching between staging and production, the cert file persists + on the volume but the account only exists for the previous server. + """ + import glob + api_endpoint = "acme-v02.api.letsencrypt.org" + if os.environ.get("CERTBOT_STAGING", "false") == "true": + api_endpoint = "acme-staging-v02.api.letsencrypt.org" + pattern = f"/etc/letsencrypt/accounts/{api_endpoint}/directory/*/regr.json" + return len(glob.glob(pattern)) > 0 + def run_action( self, domain: str, email: str, action: str = "auto" ) -> Tuple[bool, bool]: @@ -432,10 +448,13 @@ def run_action( (success, needs_evidence): success status and whether evidence should be generated """ if action == "auto": - if self.certificate_exists(domain): + if self.certificate_exists(domain) and self.acme_account_exists(): success, renewed = self.renew_certificate(domain) return success, renewed # Only generate evidence if actually renewed else: + if self.certificate_exists(domain) and not self.acme_account_exists(): + print(f"Certificate exists for {domain} but ACME account is missing " + f"(staging/production switch?), re-obtaining") success = self.obtain_certificate(domain, email) return success, success # Always generate evidence for new certificates elif action == "obtain": diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/factory.py b/custom-domain/dstack-ingress/scripts/dns_providers/factory.py index 85f7532..e9d7a22 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/factory.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/factory.py @@ -6,6 +6,7 @@ from .cloudflare import CloudflareDNSProvider from .linode import LinodeDNSProvider from .namecheap import NamecheapDNSProvider +from .route53 import Route53DNSProvider class DNSProviderFactory: @@ -15,6 +16,7 @@ class DNSProviderFactory: "cloudflare": CloudflareDNSProvider, "linode": LinodeDNSProvider, "namecheap": NamecheapDNSProvider, + "route53": Route53DNSProvider, } @classmethod @@ -67,4 +69,4 @@ def _detect_provider_type(cls) -> str: @classmethod def get_supported_providers(cls) -> list: """Get list of supported DNS providers.""" - return list(cls.PROVIDERS.keys()) \ No newline at end of file + return list(cls.PROVIDERS.keys()) diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/namecheap.py b/custom-domain/dstack-ingress/scripts/dns_providers/namecheap.py index cc5f216..4aca88c 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/namecheap.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/namecheap.py @@ -91,7 +91,7 @@ def _make_request(self, command: str, **params) -> Dict: root = ET.fromstring(response.content) # Check for API errors - errors = root.find('.//{https://api.namecheap.com/xml.response}Errors') + errors = root.find('.//{http://api.namecheap.com/xml.response}Errors') if errors is not None and len(errors) > 0: error_messages = [] for error in errors: @@ -154,7 +154,7 @@ def get_dns_records( # Parse the host records from XML response records = [] - host_elements = result["result"].findall('.//{https://api.namecheap.com/xml.response}host') + host_elements = result["result"].findall('.//{http://api.namecheap.com/xml.response}host') for host in host_elements: record_name = host.get("Name") @@ -215,43 +215,46 @@ def create_dns_record(self, record: DNSRecord) -> bool: else: hostname = record.name.replace("." + sld + "." + tld, "") - # Remove existing records of the same type and name - filtered_records = [ - r for r in existing_records - if not (r.name == record.name and r.type == record.type) - ] - + # Remove existing records of the same type and name, convert to dicts + all_records = [] + for r in existing_records: + if r.name == record.name and r.type == record.type: + continue + r_hostname = "@" if r.name == sld + "." + tld else r.name.replace("." + sld + "." + tld, "") + d = {"HostName": r_hostname, "RecordType": r.type.value, "Address": r.content, "TTL": str(r.ttl)} + if r.type == RecordType.MX and r.priority: + d["MXPref"] = str(r.priority) + all_records.append(d) + # Add new record - new_record = { - "HostName": hostname, - "RecordType": record.type.value, - "Address": record.content, - "TTL": str(record.ttl) - } - + new_record = {"HostName": hostname, "RecordType": record.type.value, "Address": record.content, "TTL": str(record.ttl)} if record.type == RecordType.MX and record.priority: new_record["MXPref"] = str(record.priority) - - filtered_records.append(new_record) - - # Set all records - return self._set_dns_records(sld, tld, filtered_records) + all_records.append(new_record) + + return self._set_dns_records(sld, tld, all_records) def delete_dns_record(self, record_id: str, domain: str) -> bool: """Delete a DNS record.""" - # Namecheap doesn't support individual record deletion - # We need to get all records, remove the one with the matching ID, and set them all domain_info = self._get_domain_info(domain) if not domain_info: return False - + sld, tld = domain_info existing_records = self.get_dns_records(domain) - - # Remove the record with the matching ID - filtered_records = [r for r in existing_records if r.id != record_id] - - return self._set_dns_records(sld, tld, filtered_records) + + # Remove record with matching ID, convert rest to dicts + all_records = [] + for r in existing_records: + if r.id == record_id: + continue + r_hostname = "@" if r.name == sld + "." + tld else r.name.replace("." + sld + "." + tld, "") + d = {"HostName": r_hostname, "RecordType": r.type.value, "Address": r.content, "TTL": str(r.ttl)} + if r.type == RecordType.MX and r.priority: + d["MXPref"] = str(r.priority) + all_records.append(d) + + return self._set_dns_records(sld, tld, all_records) def create_caa_record(self, caa_record: CAARecord) -> bool: """Create a CAA record.""" diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/route53.py b/custom-domain/dstack-ingress/scripts/dns_providers/route53.py new file mode 100644 index 0000000..1c22a0f --- /dev/null +++ b/custom-domain/dstack-ingress/scripts/dns_providers/route53.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 + +import os +import sys +from typing import List, Optional +from .base import DNSProvider, DNSRecord, CAARecord, RecordType + + + +class Route53DNSProvider(DNSProvider): + """DNS provider implementation for AWS Route53.""" + + DETECT_ENV = "AWS_ACCESS_KEY_ID" + + # Certbot configuration + CERTBOT_PLUGIN = "dns-route53" + CERTBOT_PLUGIN_MODULE = "certbot_dns_route53" + CERTBOT_PACKAGE = "certbot-dns-route53==5.1.0" + CERTBOT_PROPAGATION_SECONDS = None + + def __init__(self): + super().__init__() + + # Import boto3 here to avoid requiring it unless Route53 is used + try: + import boto3 + + self.boto3 = boto3 + except ImportError: + raise ImportError( + "boto3 is required for Route53 provider. " + "Install with: pip install boto3" + ) + + try: + self.client = self.boto3.client("route53") + except Exception as e: + raise ValueError(f"Failed to initialize Route53 client: {e}") + + self.hosted_zone_id: Optional[str] = None + self.hosted_zone_name: Optional[str] = None + + + def setup_certbot_credentials(self) -> bool: + """Setup AWS credentials file for certbot. + + This container will be provided with aws credentials purely for the purpose + of assuming a role. Doing so will enable the boto platform to provision + temporary access key and secret keys on demand! + + Using this strategy we can impose least permissive and fast expiring access + to our domain. + + """ + + try: + # Pre-fetch hosted zone ID if we have a domain + domain = os.getenv("DOMAIN") + if domain: + self._ensure_hosted_zone_id(domain) + + return True + + except Exception as e: + print(f"Error setting up AWS credentials: {e}", file=sys.stderr) + return False + + def validate_credentials(self) -> bool: + """Validate AWS credentials by testing Route53 access.""" + try: + # Test API access by listing hosted zones (limited response) + self.client.list_hosted_zones(MaxItems="1") + print("✓ AWS Route53 credentials are valid") + return True + except Exception as e: + print(f"✗ AWS Route53 credential validation failed: {e}", file=sys.stderr) + return False + + def _get_hosted_zone_info(self, domain: str) -> Optional[tuple[str, str]]: + """Get the hosted zone ID and name for a domain. + + Returns: + Tuple of (hosted_zone_id, hosted_zone_name) or None + """ + try: + # List all hosted zones + paginator = self.client.get_paginator("list_hosted_zones") + + best_match_id = None + best_match_name = None + best_match_length = 0 + + for page in paginator.paginate(): + for zone in page["HostedZones"]: + zone_name = zone["Name"].rstrip(".") # Remove trailing dot + zone_id = zone["Id"].split("/")[-1] # Extract ID from full path + + # Exact match + if domain == zone_name: + return (zone_id, zone_name) + + # Subdomain match - find the most specific (longest) zone + if ( + domain.endswith(f".{zone_name}") + and len(zone_name) > best_match_length + ): + best_match_length = len(zone_name) + best_match_id = zone_id + best_match_name = zone_name + + if best_match_id: + return (best_match_id, best_match_name) + else: + print(f"No hosted zone found for domain: {domain}", file=sys.stderr) + return None + + except Exception as e: + print(f"Error getting hosted zone: {e}", file=sys.stderr) + return None + + def _ensure_hosted_zone_id(self, domain: str) -> Optional[str]: + """Ensure we have a hosted zone ID for the domain, fetching if necessary.""" + # Check if we can reuse cached zone + if self.hosted_zone_id and self.hosted_zone_name: + if domain == self.hosted_zone_name or domain.endswith( + f".{self.hosted_zone_name}" + ): + return self.hosted_zone_id + + # Fetch zone info + zone_info = self._get_hosted_zone_info(domain) + if zone_info: + self.hosted_zone_id, self.hosted_zone_name = zone_info + return self.hosted_zone_id + + def _normalize_record_name(self, name: str) -> str: + """Normalize record name to FQDN with trailing dot (Route53 format).""" + if not name.endswith("."): + return f"{name}." + return name + + def get_dns_records( + self, name: str, record_type: Optional[RecordType] = None + ) -> List[DNSRecord]: + """Get DNS records for a domain.""" + hosted_zone_id = self._ensure_hosted_zone_id(name) + if not hosted_zone_id: + print( + f"Error: Could not find hosted zone for domain {name}", file=sys.stderr + ) + return [] + + normalized_name = self._normalize_record_name(name) + + print(f"Checking for existing DNS records for {name}") + + try: + # List resource record sets for the hosted zone + paginator = self.client.get_paginator("list_resource_record_sets") + records = [] + + for page in paginator.paginate(HostedZoneId=hosted_zone_id): + for record_set in page["ResourceRecordSets"]: + record_name = record_set["Name"] + record_type_str = record_set["Type"] + + # Filter by name + if record_name != normalized_name: + continue + + # Filter by type if specified + if record_type and record_type_str != record_type.value: + continue + + # Parse record content + content = "" + data = None + + if record_type_str == "CAA": + # CAA records have special format + if "ResourceRecords" in record_set: + caa_value = record_set["ResourceRecords"][0]["Value"] + # Format: "flags tag value" + parts = caa_value.split(" ", 2) + if len(parts) >= 3: + flags = int(parts[0]) + tag = parts[1] + value = parts[2].strip('"') + content = caa_value + data = {"flags": flags, "tag": tag, "value": value} + else: + # Standard records + if "ResourceRecords" in record_set: + # Get first record value (multiple values would need separate DNSRecord objects) + content = record_set["ResourceRecords"][0]["Value"] + # Remove quotes from TXT records + if record_type_str == "TXT": + content = content.strip('"') + elif "AliasTarget" in record_set: + # Alias record (Route53 specific) + content = record_set["AliasTarget"]["DNSName"].rstrip(".") + + # Route53 doesn't have persistent record IDs, use name+type as identifier + record_id = f"{record_name}:{record_type_str}" + + records.append( + DNSRecord( + id=record_id, + name=name, # Return original name without trailing dot + type=RecordType(record_type_str), + content=content, + ttl=record_set.get("TTL", 60), + proxied=False, # Route53 doesn't have proxy feature + priority=None, # Would be in record value for MX/SRV + data=data, + ) + ) + + return records + + except Exception as e: + print(f"Error getting DNS records: {e}", file=sys.stderr) + return [] + + def create_dns_record(self, record: DNSRecord) -> bool: + """Create a DNS record.""" + hosted_zone_id = self._ensure_hosted_zone_id(record.name) + if not hosted_zone_id: + print( + f"Error: Could not find hosted zone for domain {record.name}", + file=sys.stderr, + ) + return False + + normalized_name = self._normalize_record_name(record.name) + + # Prepare record value + if record.type == RecordType.TXT: + # TXT records need to be quoted + record_value = f'"{record.content}"' + else: + record_value = record.content + + # Prepare change batch + change_batch = { + "Changes": [ + { + "Action": "UPSERT", # UPSERT creates or updates + "ResourceRecordSet": { + "Name": normalized_name, + "Type": record.type.value, + "TTL": record.ttl, + "ResourceRecords": [{"Value": record_value}], + }, + } + ] + } + + try: + print(f"Adding {record.type.value} record for {record.name}") + response = self.client.change_resource_record_sets( + HostedZoneId=hosted_zone_id, ChangeBatch=change_batch + ) + + # Check if change was successful + change_info = response.get("ChangeInfo", {}) + if change_info.get("Status") in ["PENDING", "INSYNC"]: + return True + else: + print( + f"Unexpected change status: {change_info.get('Status')}", + file=sys.stderr, + ) + return False + + except Exception as e: + print(f"Error creating DNS record: {e}", file=sys.stderr) + return False + + def delete_dns_record(self, record_id: str, domain: str) -> bool: + """Delete a DNS record. + + Args: + record_id: Format is "name:type" since Route53 doesn't have persistent IDs + domain: The domain name (for zone lookup) + """ + hosted_zone_id = self._ensure_hosted_zone_id(domain) + if not hosted_zone_id: + print( + f"Error: Could not find hosted zone for domain {domain}", + file=sys.stderr, + ) + return False + + # Parse record_id to get name and type + try: + record_name, record_type = record_id.split(":", 1) + except ValueError: + print(f"Invalid record_id format: {record_id}", file=sys.stderr) + return False + + try: + # First, get the current record to know its full details + paginator = self.client.get_paginator("list_resource_record_sets") + record_set_to_delete = None + + for page in paginator.paginate(HostedZoneId=hosted_zone_id): + for record_set in page["ResourceRecordSets"]: + if ( + record_set["Name"] == record_name + and record_set["Type"] == record_type + ): + record_set_to_delete = record_set + break + if record_set_to_delete: + break + + if not record_set_to_delete: + print(f"Record not found: {record_id}", file=sys.stderr) + return False + + # Prepare DELETE change batch with exact record details + change_batch = { + "Changes": [ + {"Action": "DELETE", "ResourceRecordSet": record_set_to_delete} + ] + } + + print(f"Deleting record: {record_id}") + response = self.client.change_resource_record_sets( + HostedZoneId=hosted_zone_id, ChangeBatch=change_batch + ) + + change_info = response.get("ChangeInfo", {}) + if change_info.get("Status") in ["PENDING", "INSYNC"]: + return True + else: + print( + f"Unexpected change status: {change_info.get('Status')}", + file=sys.stderr, + ) + return False + + except Exception as e: + print(f"Error deleting DNS record: {e}", file=sys.stderr) + return False + + def create_caa_record(self, caa_record: CAARecord) -> bool: + """ + Create or merge a CAA record set on the apex of the Route53 hosted zone. + + - Ignores the specific subdomain in caa_record.name for placement + - Uses it only to locate the correct hosted zone + - Merges hard-coded issuers with any existing CAA values on the apex + """ + # Ensure we know which hosted zone this belongs to + hosted_zone_id = self._ensure_hosted_zone_id(caa_record.name) + if not hosted_zone_id: + print( + f"Error: Could not find hosted zone for domain {caa_record.name}", + file=sys.stderr, + ) + return False + + if not self.hosted_zone_name: + print("Error: Hosted zone name is not set", file=sys.stderr) + return False + + apex_name = self.hosted_zone_name # apex of the zone + normalized_name = self._normalize_record_name(apex_name) + + # Hard-coded issuers for this bridge (Let's Encrypt + AWS ACM) + required_issuers = [ + "letsencrypt.org", + "amazon.com", + "amazontrust.com", + "awstrust.com", + "amazonaws.com", + ] + + # Build the desired CAA "issue" values from the issuers + required_values = [ + f'{caa_record.flags} {caa_record.tag} "{issuer}"' + for issuer in required_issuers + ] + + # Look up any existing CAA RRSet on the apex + paginator = self.client.get_paginator("list_resource_record_sets") + existing_rrset = None + + try: + for page in paginator.paginate(HostedZoneId=hosted_zone_id): + for record_set in page["ResourceRecordSets"]: + if ( + record_set["Name"] == normalized_name + and record_set["Type"] == "CAA" + ): + existing_rrset = record_set + break + if existing_rrset: + break + except Exception as e: + print(f"Error listing existing CAA records: {e}", file=sys.stderr) + return False + + existing_values: List[str] = [] + ttl = caa_record.ttl + + if existing_rrset: + existing_values = [ + rr["Value"] for rr in existing_rrset.get("ResourceRecords", []) + ] + ttl = existing_rrset.get("TTL", ttl) + print( + f"Found existing CAA RRSet on apex {apex_name}, merging with " + f"required issuers" + ) + else: + print(f"No existing CAA RRSet on apex {apex_name}, creating new one") + + # Merge: keep all existing values, add any missing required issuer values + merged_values = list(existing_values) + for value in required_values: + if value not in merged_values: + merged_values.append(value) + + if not merged_values: + print("No CAA values to set on apex after merge; aborting", file=sys.stderr) + return False + + # Prepare change batch with the merged RRSet + change_batch = { + "Changes": [ + { + "Action": "UPSERT", + "ResourceRecordSet": { + "Name": normalized_name, + "Type": "CAA", + "TTL": ttl, + "ResourceRecords": [{"Value": v} for v in merged_values], + }, + } + ] + } + + try: + print( + f"Setting merged CAA record set for apex {apex_name}: " + f"{', '.join(merged_values)}" + ) + response = self.client.change_resource_record_sets( + HostedZoneId=hosted_zone_id, ChangeBatch=change_batch + ) + + change_info = response.get("ChangeInfo", {}) + if change_info.get("Status") in ["PENDING", "INSYNC"]: + return True + else: + print( + f"Unexpected change status for CAA apex update: " + f"{change_info.get('Status')}", + file=sys.stderr, + ) + return False + + except Exception as e: + print(f"Error creating/merging apex CAA record: {e}", file=sys.stderr) + return False diff --git a/custom-domain/dstack-ingress/scripts/entrypoint.sh b/custom-domain/dstack-ingress/scripts/entrypoint.sh index 7a4ea7f..25eb559 100644 --- a/custom-domain/dstack-ingress/scripts/entrypoint.sh +++ b/custom-domain/dstack-ingress/scripts/entrypoint.sh @@ -28,6 +28,15 @@ fi if ! PROXY_CONNECT_TIMEOUT=$(sanitize_proxy_timeout "$PROXY_CONNECT_TIMEOUT"); then exit 1 fi +if ! PROXY_BUFFER_SIZE=$(sanitize_proxy_buffer_size "$PROXY_BUFFER_SIZE"); then + exit 1 +fi +if ! PROXY_BUFFERS=$(sanitize_proxy_buffers "$PROXY_BUFFERS"); then + exit 1 +fi +if ! PROXY_BUSY_BUFFERS_SIZE=$(sanitize_proxy_buffer_size "$PROXY_BUSY_BUFFERS_SIZE"); then + exit 1 +fi if ! TXT_PREFIX=$(sanitize_dns_label "$TXT_PREFIX"); then exit 1 fi @@ -52,7 +61,7 @@ setup_py_env() { if [ ! -f /.venv_bootstrapped ]; then echo "Bootstrapping certbot dependencies" pip install --upgrade pip - pip install certbot requests + pip install certbot requests boto3 botocore touch /.venv_bootstrapped fi @@ -65,6 +74,26 @@ setup_certbot_env() { # shellcheck disable=SC1091 source /opt/app-venv/bin/activate + if [ "${DNS_PROVIDER}" = "route53" ]; then + mkdir -p /root/.aws + + cat </root/.aws/config +[profile certbot] +role_arn=${AWS_ROLE_ARN} +source_profile=certbot-source +region=${AWS_REGION:-us-east-1} +EOF + + cat </root/.aws/credentials +[certbot-source] +aws_access_key_id=${AWS_ACCESS_KEY_ID} +aws_secret_access_key=${AWS_SECRET_ACCESS_KEY} +EOF + + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN + export AWS_PROFILE=certbot + fi + # Use the unified certbot manager to install plugins and setup credentials echo "Installing DNS plugins and setting up credentials" certman.py setup @@ -97,6 +126,21 @@ setup_nginx_conf() { proxy_connect_timeout_conf=" ${PROXY_CMD}_connect_timeout ${PROXY_CONNECT_TIMEOUT};" fi + local proxy_buffer_size_conf="" + if [ -n "$PROXY_BUFFER_SIZE" ]; then + proxy_buffer_size_conf=" proxy_buffer_size ${PROXY_BUFFER_SIZE};" + fi + + local proxy_buffers_conf="" + if [ -n "$PROXY_BUFFERS" ]; then + proxy_buffers_conf=" proxy_buffers ${PROXY_BUFFERS};" + fi + + local proxy_busy_buffers_size_conf="" + if [ -n "$PROXY_BUSY_BUFFERS_SIZE" ]; then + proxy_busy_buffers_size_conf=" proxy_busy_buffers_size ${PROXY_BUSY_BUFFERS_SIZE};" + fi + cat </etc/nginx/conf.d/default.conf server { listen ${PORT} ssl; @@ -133,6 +177,9 @@ server { # SSL buffer size (optimized for TLS 1.3) ssl_buffer_size 4k; +${proxy_buffer_size_conf} +${proxy_buffers_conf} +${proxy_busy_buffers_size_conf} # Disable SSL renegotiation ssl_early_data off; @@ -200,13 +247,17 @@ set_caa_record() { echo "Skipping CAA record setup" return fi + local ACCOUNT_URI - find /etc/letsencrypt/accounts -name regr.json - path="/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/*/regr.json" - if [ "$CERTBOT_STAGING" == "true" ]; then - path="${path/acme-v02/acme-staging-v02}" + local account_file + + if ! account_file=$(get_letsencrypt_account_file); then + echo "Warning: Cannot set CAA record - account file not found" + echo "This is not critical - certificates can still be issued without CAA records" + return fi - ACCOUNT_URI=$(jq -j '.uri' $path) + + ACCOUNT_URI=$(jq -j '.uri' "$account_file") echo "Adding CAA record for $domain, accounturi=$ACCOUNT_URI" dnsman.py set_caa \ --domain "$domain" \ @@ -217,7 +268,6 @@ set_caa_record() { echo "Warning: Failed to set CAA record for $domain" echo "This is not critical - certificates can still be issued without CAA records" echo "Consider disabling CAA records by setting SET_CAA=false if this continues to fail" - # Don't exit - CAA records are optional for certificate generation fi } diff --git a/custom-domain/dstack-ingress/scripts/functions.sh b/custom-domain/dstack-ingress/scripts/functions.sh index 868555c..1a5a75c 100644 --- a/custom-domain/dstack-ingress/scripts/functions.sh +++ b/custom-domain/dstack-ingress/scripts/functions.sh @@ -82,3 +82,58 @@ sanitize_proxy_timeout() { echo "" fi } + +sanitize_proxy_buffer_size() { + local candidate="$1" + if [ -z "$candidate" ]; then + echo "" + return 0 + fi + if [[ "$candidate" =~ ^[0-9]+[kKmM]?$ ]]; then + echo "$candidate" + else + echo "Warning: Ignoring invalid proxy buffer size value: $candidate" >&2 + echo "" + fi +} + +sanitize_proxy_buffers() { + local candidate="$1" + if [ -z "$candidate" ]; then + echo "" + return 0 + fi + # Format: number size (e.g., "4 256k") + if [[ "$candidate" =~ ^[0-9]+[[:space:]]+[0-9]+[kKmM]?$ ]]; then + echo "$candidate" + else + echo "Warning: Ignoring invalid proxy buffers value: $candidate (expected format: 'number size', e.g., '4 256k')" >&2 + echo "" + fi +} + +get_letsencrypt_account_path() { + local base_path="/etc/letsencrypt/accounts" + local api_endpoint="acme-v02.api.letsencrypt.org" + + if [[ "$CERTBOT_STAGING" == "true" ]]; then + api_endpoint="acme-staging-v02.api.letsencrypt.org" + fi + + echo "${base_path}/${api_endpoint}/directory/*/regr.json" +} + +get_letsencrypt_account_file() { + local account_pattern + account_pattern=$(get_letsencrypt_account_path) + + local account_files + account_files=( $account_pattern ) + + if [[ ! -f "${account_files[0]}" ]]; then + echo "Error: Let's Encrypt account file not found at $account_pattern" >&2 + return 1 + fi + + echo "${account_files[0]}" +} diff --git a/custom-domain/dstack-ingress/scripts/generate-evidences.sh b/custom-domain/dstack-ingress/scripts/generate-evidences.sh index 401e15f..1c5b19b 100644 --- a/custom-domain/dstack-ingress/scripts/generate-evidences.sh +++ b/custom-domain/dstack-ingress/scripts/generate-evidences.sh @@ -2,15 +2,16 @@ set -e -path="/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/*/regr.json" -if [ "$CERTBOT_STAGING" == "true" ]; then - path="${path/acme-v02/acme-staging-v02}" +source "/scripts/functions.sh" + +if ! ACME_ACCOUNT_FILE=$(get_letsencrypt_account_file); then + echo "Error: Cannot generate evidences without Let's Encrypt account file" + exit 1 fi -ACME_ACCOUNT_FILE=$(ls $path) mkdir -p /evidences cd /evidences || exit -cp ${ACME_ACCOUNT_FILE} acme-account.json +cp "${ACME_ACCOUNT_FILE}" acme-account.json # Get all domains and copy their certificates all_domains=$(get-all-domains.sh) diff --git a/lightclient/docker-compose.yml b/lightclient/docker-compose.yml index 716edd0..78eab39 100644 --- a/lightclient/docker-compose.yml +++ b/lightclient/docker-compose.yml @@ -8,7 +8,7 @@ services: build: context: . dockerfile_inline: | - FROM ubuntu:22.04@sha256:01a3ee0b5e413cefaaffc6abe68c9c37879ae3cced56a8e088b1649e5b269eee + FROM ubuntu:24.04@sha256:b59d21599a2b151e23eea5f6602f4af4d7d31c4e236d22bf0b62b86d2e386b8f RUN apt-get update && apt install -y curl wget WORKDIR /root @@ -17,7 +17,7 @@ services: RUN tar -xzf ./foundry_nightly_linux_amd64.tar.gz -C /usr/local/bin # Helios - RUN curl -L 'https://github.com/a16z/helios/releases/download/0.7.0/helios_linux_amd64.tar.gz' | tar -xzC . + RUN curl -L 'https://github.com/a16z/helios/releases/download/0.8.8/helios_linux_amd64.tar.gz' | tar -xzC . CMD [ "bash", "/root/run.sh" ] platform: linux/amd64 @@ -27,7 +27,7 @@ configs: # First run Helios in the background # Provide a reasonable checkpoint. ( - /root/helios ethereum --network=holesky --checkpoint 0x9260657ed4167f2bbe57317978ff181b6b96c1065ecf9340bba05ba3578128fe \ + /root/helios ethereum --network=holesky --checkpoint 0x60409a013161b33c8c68c6183c7753e779ec6c24be2f3c50c6036c30e13b34a6 \ --consensus-rpc http://testing.holesky.beacon-api.nimbus.team --execution-rpc $${ETH_RPC_URL} ) & diff --git a/phala-cloud-prelaunch-script/prelaunch.sh b/phala-cloud-prelaunch-script/prelaunch.sh index a16a7ac..6feecac 100644 --- a/phala-cloud-prelaunch-script/prelaunch.sh +++ b/phala-cloud-prelaunch-script/prelaunch.sh @@ -1,6 +1,5 @@ -#!/bin/bash echo "----------------------------------------------" -echo "Running Phala Cloud Pre-Launch Script v0.0.8" +echo "Running Phala Cloud Pre-Launch Script v0.0.14" echo "----------------------------------------------" set -e @@ -33,7 +32,19 @@ perform_cleanup() { # Function: Check Docker login status without exposing credentials check_docker_login() { - # Try to verify login status without exposing credentials + local registry="$1" + + # When registry is specified, check auth entry for that registry in Docker config + if [[ -n "$registry" ]]; then + local docker_config_path="${DOCKER_CONFIG:-$HOME/.docker}/config.json" + if [[ -f "$docker_config_path" ]] && grep -q "$registry" "$docker_config_path"; then + return 0 + else + return 1 + fi + fi + + # Fallback check when no explicit registry is provided if docker info 2>/dev/null | grep -q "Username"; then return 0 else @@ -47,23 +58,25 @@ echo "Starting login process..." # Check if Docker credentials exist if [[ -n "$DSTACK_DOCKER_USERNAME" && -n "$DSTACK_DOCKER_PASSWORD" ]]; then echo "Docker credentials found" - + DOCKER_REGISTRY_TARGET="${DSTACK_DOCKER_REGISTRY:-docker.io}" + echo "Target Docker registry: $DOCKER_REGISTRY_TARGET" + # Check if already logged in - if check_docker_login; then - echo "Already logged in to Docker registry" + if check_docker_login "$DSTACK_DOCKER_REGISTRY"; then + echo "Already logged in to Docker registry: $DOCKER_REGISTRY_TARGET" else - echo "Logging in to Docker registry..." + echo "Logging in to Docker registry: $DOCKER_REGISTRY_TARGET" # Login without exposing password in process list if [[ -n "$DSTACK_DOCKER_REGISTRY" ]]; then echo "$DSTACK_DOCKER_PASSWORD" | docker login -u "$DSTACK_DOCKER_USERNAME" --password-stdin "$DSTACK_DOCKER_REGISTRY" else echo "$DSTACK_DOCKER_PASSWORD" | docker login -u "$DSTACK_DOCKER_USERNAME" --password-stdin fi - + if [ $? -eq 0 ]; then - echo "Docker login successful" + echo "Docker login successful: $DOCKER_REGISTRY_TARGET" else - echo "Docker login failed" + echo "Docker login failed: $DOCKER_REGISTRY_TARGET" notify_host_hoot_error "docker login failed" exit 1 fi @@ -71,7 +84,7 @@ if [[ -n "$DSTACK_DOCKER_USERNAME" && -n "$DSTACK_DOCKER_PASSWORD" ]]; then # Check if AWS ECR credentials exist elif [[ -n "$DSTACK_AWS_ACCESS_KEY_ID" && -n "$DSTACK_AWS_SECRET_ACCESS_KEY" && -n "$DSTACK_AWS_REGION" && -n "$DSTACK_AWS_ECR_REGISTRY" ]]; then echo "AWS ECR credentials found" - + # Check if AWS CLI is installed if [ ! -f "./aws/dist/aws" ]; then notify_host_hoot_info "awscli not installed, installing..." @@ -92,13 +105,13 @@ elif [[ -n "$DSTACK_AWS_ACCESS_KEY_ID" && -n "$DSTACK_AWS_SECRET_ACCESS_KEY" && export AWS_ACCESS_KEY_ID="$DSTACK_AWS_ACCESS_KEY_ID" export AWS_SECRET_ACCESS_KEY="$DSTACK_AWS_SECRET_ACCESS_KEY" export AWS_DEFAULT_REGION="$DSTACK_AWS_REGION" - + # Set session token if provided (for temporary credentials) if [[ -n "$DSTACK_AWS_SESSION_TOKEN" ]]; then echo "AWS session token found, using temporary credentials" export AWS_SESSION_TOKEN="$DSTACK_AWS_SESSION_TOKEN" fi - + # Test AWS credentials before attempting ECR login echo "Testing AWS credentials..." if ! ./aws/dist/aws sts get-caller-identity &> /dev/null; then @@ -136,36 +149,147 @@ fi perform_cleanup # -# Set root password if DSTACK_ROOT_PASSWORD is set. +# GHCR image pull access verification (pure HTTP, no docker daemon) # -if [[ -n "$DSTACK_ROOT_PASSWORD" ]]; then - echo "$DSTACK_ROOT_PASSWORD" | passwd --stdin root 2>/dev/null || echo -e "$DSTACK_ROOT_PASSWORD\n$DSTACK_ROOT_PASSWORD" | passwd root - unset $DSTACK_ROOT_PASSWORD - echo "Root password set" +if [[ "$DOCKER_REGISTRY_TARGET" == "ghcr.io" && -n "$DSTACK_DOCKER_USERNAME" && -n "$DSTACK_DOCKER_PASSWORD" ]]; then + COMPOSE_IMAGES=$(grep 'image:' /dstack/docker-compose.yaml 2>/dev/null | awk '{print $2}' | tr -d '"'"'" || true) + for img in $COMPOSE_IMAGES; do + [[ "$img" != ghcr.io/* ]] && continue + repo="${img#ghcr.io/}"; repo="${repo%%:*}" + tag="${img##*:}"; [[ "$tag" == "$img" || "$tag" == "$repo" ]] && tag="latest" + echo "Verifying GHCR pull access: $img" + token=$(curl -sf -u "$DSTACK_DOCKER_USERNAME:$DSTACK_DOCKER_PASSWORD" \ + "https://ghcr.io/token?service=ghcr.io&scope=repository:${repo}:pull" | jq -r '.token // empty' || true) + if [[ -z "$token" ]]; then + echo "ERROR: GHCR token exchange failed for $img" + notify_host_hoot_error "GHCR token exchange failed: $img" + exit 1 + fi + http_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $token" \ + -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json" \ + "https://ghcr.io/v2/${repo}/manifests/${tag}") + if [[ "$http_code" != "200" ]]; then + echo "ERROR: GHCR pull access denied for $img (HTTP $http_code)" + notify_host_hoot_error "GHCR pull access denied: $img (HTTP $http_code)" + exit 1 + fi + echo "GHCR pull access OK: $img" + done fi -if [[ -n "$DSTACK_ROOT_PUBLIC_KEY" ]]; then - mkdir -p /root/.ssh - echo "$DSTACK_ROOT_PUBLIC_KEY" > /root/.ssh/authorized_keys - unset $DSTACK_ROOT_PUBLIC_KEY - echo "Root public key set" + +# +# Set root password. +# +echo "Setting root password.." + +# Check if password files are writable +PASSWD_WRITABLE=true +if [ ! -w /etc/passwd ]; then + echo "Warning: /etc/passwd is read-only" + PASSWD_WRITABLE=false fi -if [[ -n "$DSTACK_AUTHORIZED_KEYS" ]]; then - mkdir -p /root/.ssh - echo "$DSTACK_AUTHORIZED_KEYS" > /root/.ssh/authorized_keys - unset $DSTACK_AUTHORIZED_KEYS - echo "Root authorized_keys set" +if [ ! -w /etc/shadow ]; then + echo "Warning: /etc/shadow is read-only" + PASSWD_WRITABLE=false fi +if [ "$PASSWD_WRITABLE" = "false" ]; then + echo "Skipping password setup due to read-only file system" +else + # Check if chpasswd is available + if command -v chpasswd >/dev/null 2>&1; then + echo "Using chpasswd method" + + if [ -n "$DSTACK_ROOT_PASSWORD" ]; then + echo "Setting root password from user.." + echo "root:$DSTACK_ROOT_PASSWORD" | chpasswd + unset DSTACK_ROOT_PASSWORD + echo "Root password set/updated from DSTACK_ROOT_PASSWORD" + elif [ -z "$(grep '^root:' /etc/shadow 2>/dev/null | cut -d: -f2)" ]; then + echo "Setting random root password.." + DSTACK_ROOT_PASSWORD=$( + LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | dd bs=1 count=32 2>/dev/null + ) + echo "root:$DSTACK_ROOT_PASSWORD" | chpasswd + unset DSTACK_ROOT_PASSWORD + echo "Root password set (random auto-init)" + else + echo "Root password already set; no changes." + fi + else + echo "Using passwd method" + + if [ -n "$DSTACK_ROOT_PASSWORD" ]; then + echo "Setting root password from user.." + echo "$DSTACK_ROOT_PASSWORD" | passwd --stdin root 2>/dev/null \ + || printf '%s\n%s\n' "$DSTACK_ROOT_PASSWORD" "$DSTACK_ROOT_PASSWORD" | passwd root + unset DSTACK_ROOT_PASSWORD + echo "Root password set/updated from DSTACK_ROOT_PASSWORD" + elif [ -z "$(grep '^root:' /etc/shadow 2>/dev/null | cut -d: -f2)" ]; then + echo "Setting random root password.." + DSTACK_ROOT_PASSWORD=$( + LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | dd bs=1 count=32 2>/dev/null + ) + echo "$DSTACK_ROOT_PASSWORD" | passwd --stdin root 2>/dev/null \ + || printf '%s\n%s\n' "$DSTACK_ROOT_PASSWORD" "$DSTACK_ROOT_PASSWORD" | passwd root + unset DSTACK_ROOT_PASSWORD + echo "Root password set (random auto-init)" + else + echo "Root password already set; no changes." + fi + fi +fi + +# +# Set SSH authorized keys +# +if mkdir -p /home/root/.ssh 2>/dev/null; then + if [[ -n "$DSTACK_ROOT_PUBLIC_KEY" ]]; then + echo "$DSTACK_ROOT_PUBLIC_KEY" > /home/root/.ssh/authorized_keys + unset $DSTACK_ROOT_PUBLIC_KEY + echo "Root public key set" + fi + if [[ -n "$DSTACK_AUTHORIZED_KEYS" ]]; then + echo "$DSTACK_AUTHORIZED_KEYS" > /home/root/.ssh/authorized_keys + unset $DSTACK_AUTHORIZED_KEYS + echo "Root authorized_keys set" + fi + + if [[ -f /dstack/user_config ]] && jq empty /dstack/user_config 2>/dev/null; then + if [[ $(jq 'has("ssh_authorized_keys")' /dstack/user_config 2>/dev/null) == "true" ]]; then + jq -j '.ssh_authorized_keys' /dstack/user_config >> /home/root/.ssh/authorized_keys + # Remove duplicates if there are multiple keys + if [[ $(cat /home/root/.ssh/authorized_keys | wc -l) -gt 1 ]]; then + sort -u /home/root/.ssh/authorized_keys > /home/root/.ssh/authorized_keys.tmp + mv /home/root/.ssh/authorized_keys.tmp /home/root/.ssh/authorized_keys + fi + echo "Set root authorized_keys from user preferences, total" $(cat /home/root/.ssh/authorized_keys | wc -l) "keys" + fi + fi +else + echo "Warning: Cannot create /home/root/.ssh directory (read-only file system?)" + echo "Skipping SSH key setup" +fi if [[ -S /var/run/dstack.sock ]]; then export DSTACK_APP_ID=$(curl -s --unix-socket /var/run/dstack.sock http://dstack/Info | jq -j .app_id) elif [[ -S /var/run/tappd.sock ]]; then export DSTACK_APP_ID=$(curl -s --unix-socket /var/run/tappd.sock http://dstack/prpc/Tappd.Info | jq -j .app_id) fi -# Check if app-compose.json has default_gateway_domain field and DSTACK_GATEWAY_DOMAIN is not set -# If true, set DSTACK_GATEWAY_DOMAIN from app-compose.json -if [[ $(jq 'has("default_gateway_domain")' app-compose.json) == "true" && -z "$DSTACK_GATEWAY_DOMAIN" ]]; then - export DSTACK_GATEWAY_DOMAIN=$(jq -j '.default_gateway_domain' app-compose.json) +# Check if DSTACK_GATEWAY_DOMAIN is not set, try to get it from user_config or app-compose.json +# Priority: user_config > app-compose.json +if [[ -z "$DSTACK_GATEWAY_DOMAIN" ]]; then + # First try to get from /dstack/user_config if it exists and is valid JSON + if [[ -f /dstack/user_config ]] && jq empty /dstack/user_config 2>/dev/null; then + if [[ $(jq 'has("default_gateway_domain")' /dstack/user_config 2>/dev/null) == "true" ]]; then + export DSTACK_GATEWAY_DOMAIN=$(jq -j '.default_gateway_domain' /dstack/user_config) + fi + fi + + # If still not set, try to get from app-compose.json + if [[ -z "$DSTACK_GATEWAY_DOMAIN" ]] && [[ $(jq 'has("default_gateway_domain")' app-compose.json) == "true" ]]; then + export DSTACK_GATEWAY_DOMAIN=$(jq -j '.default_gateway_domain' app-compose.json) + fi fi if [[ -n "$DSTACK_GATEWAY_DOMAIN" ]]; then export DSTACK_APP_DOMAIN=$DSTACK_APP_ID"."$DSTACK_GATEWAY_DOMAIN diff --git a/tutorial/01-attestation/README.md b/tutorial/01-attestation/README.md new file mode 100644 index 0000000..c3dbf20 --- /dev/null +++ b/tutorial/01-attestation/README.md @@ -0,0 +1,289 @@ +# Tutorial 01: Attestation + +Build a TEE oracle and verify its attestation end-to-end. + +This tutorial covers: +- Building an app that produces verifiable outputs +- Binding data to TDX quotes via `report_data` +- Multiple verification methods (hosted, scripts, programmatic) + +## What it does + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ TEE Oracle │ +│ │ +│ 1. Fetch price from api.coingecko.com │ +│ 2. Capture TLS certificate fingerprint │ +│ 3. Build statement: { price, tlsFingerprint, timestamp } │ +│ 4. Get TDX quote with sha256(statement) as report_data │ +│ 5. Return { statement, quote } │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +The TLS fingerprint proves which server the TEE connected to. The quote proves the statement came from this exact code running in a TEE. + +## Run Locally + +```bash +# Start the simulator +phala simulator start + +# Run the oracle +docker compose run --rm \ + -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock \ + app +``` + +## Endpoints + +**GET /** — App info and available endpoints + +**GET /price** — Attested BTC price +```json +{ + "statement": { + "source": "api.coingecko.com", + "price": 97234.00, + "tlsFingerprint": "5A:3B:...:F2", + "tlsIssuer": "Cloudflare, Inc.", + "tlsValidTo": "Dec 31 2025", + "timestamp": 1703347200000 + }, + "reportDataHash": "a1b2c3...", + "quote": "BAACAQI..." +} +``` + +## Deploy to Phala Cloud + +```bash +phala deploy -n tee-oracle -c docker-compose.yaml +``` + +--- + +## Verification + +The quote contains **measured values**. Verification means comparing them against **reference values** you trust. + +### Reference Values: Where They Come From + +| Measured Value | Reference Value | Who Provides It | +|----------------|-----------------|-----------------| +| Intel signature | Intel root CA | Intel (built into dcap-qvl) | +| MRTD, RTMR0-2 | Hash of OS image | [meta-dstack releases](https://github.com/Dstack-TEE/meta-dstack/releases) | +| compose-hash (RTMR3) | `sha256(app-compose.json)` | **The developer** | +| report_data | `sha256(statement)` | Computed from output | +| tlsFingerprint | Certificate fingerprint | Fetch from api.coingecko.com | + +This is the core insight: **attestation is only as trustworthy as your reference values.** + +- Intel provides reference values for hardware authenticity +- Dstack maintainers provide reference values for the OS layer +- **The app developer must provide reference values for the application layer** + +### Step 1: Validate Quote and Compare Measurements + +Use the included Python script: + +```bash +# Install verification tools +CFLAGS="-g0" cargo install dcap-qvl-cli +CGO_CFLAGS="-g0" go install github.com/kvinwang/dstack-mr@latest + +# Get attestation from your deployed app +phala cvms attestation tee-oracle --json > attestation.json + +# Download matching dstack OS image +curl -LO https://github.com/Dstack-TEE/meta-dstack/releases/download/v0.5.5/dstack-0.5.5.tar.gz +tar xzf dstack-0.5.5.tar.gz + +# Verify +python3 verify_full.py attestation.json --image-folder dstack-0.5.5/ +``` + +Output: +``` +=== Step 1: Hardware Verification (dcap-qvl) === + ✓ Hardware verification passed + +=== Step 2: Extract Measurements === + Compose hash (from quote): 392b8a1f... + +=== Step 3: OS Verification (dstack-mr) === + ✓ MRTD matches - kernel/initramfs verified + +=== Step 4: Compose Hash Verification === + ✓ MATCH - Compose hash verified! +``` + +--- + +> **About compose-hash** +> +> Your `docker-compose.yaml` gets wrapped into an `app-compose.json` manifest: +> ```json +> { +> "docker_compose_file": "", +> "pre_launch_script": "#!/bin/bash\n...", +> "kms_enabled": true, +> ... +> } +> ``` +> The SHA-256 of this manifest is the **compose-hash** in RTMR3. +> +> **Important:** The `pre_launch_script` is included in the hash. Phala Cloud injects its own prelaunch script. To audit, fetch the complete `app-compose.json` via `phala cvms attestation `. See [prelaunch-script](../../prelaunch-script) for the Phala Cloud script source. +> +> For standalone verification that builds app-compose.json locally: [attestation/configid-based](../../attestation/configid-based) + +### Step 2: Verify report_data Binding + +The quote's `report_data` field contains `sha256(statement)`. Verify it matches: + +```bash +# Extract statement from response and hash it +cat response.json | jq -r '.statement | @json' | shasum -a 256 + +# Compare with reportDataHash in response +cat response.json | jq -r '.reportDataHash' +``` + +If they match, the statement is exactly what the TEE produced. + +### Step 3: Verify TLS Fingerprint + +The `tlsFingerprint` in the statement is the SHA-256 fingerprint of the API server's certificate. You can verify it matches CoinGecko's real certificate: + +```bash +# Get CoinGecko's current certificate fingerprint +echo | openssl s_client -connect api.coingecko.com:443 2>/dev/null | \ + openssl x509 -fingerprint -sha256 -noout + +# Compare with statement.tlsFingerprint +cat response.json | jq -r '.statement.tlsFingerprint' +``` + +--- + +## Critical Thinking: The Auditor's Perspective + +> *This section appears throughout the tutorial. Each chapter examines a different trust assumption.* + +### The Fundamental Question + +As an auditor, your job is to answer: **"Does the deployed system behave according to the source code I reviewed?"** + +TEE attestation helps, but only partially. The quote proves *some code* is running in isolated hardware. It gives you hashes. But hashes of what? + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ The Reference Value Problem │ +│ │ +│ What you audit: Source code, Dockerfile, docker-compose │ +│ What quote gives: compose-hash = 0x392b8a1f... │ +│ │ +│ The gap: Can you compute 0x392b8a1f from what you audited? │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Trust Layers + +Every TEE app has a trust stack. At each layer, ask: *"Where does the reference value come from?"* + +| Layer | What You're Trusting | Reference Value Source | +|-------|---------------------|------------------------| +| Hardware | Intel TDX is secure | Intel's attestation infrastructure | +| Firmware | No backdoors in BIOS/firmware | Platform vendor | +| OS | Dstack boots what it claims | meta-dstack releases (open source, reproducible) | +| App | Code matches what was audited | **Developer-provided** | + +The bottom three layers have established reference value sources. The app layer is the developer's responsibility. + +### How Auditing Actually Works + +An auditor doesn't just read code—they run it. The workflow: + +1. **Read source** — Form a mental model of intended behavior +2. **Run locally** — Test that model: "does it actually do X? what happens if Y?" +3. **Conclude** — "This code behaves as I understand it. It's safe." + +The auditor's conclusion is based on **what they ran**, not what they read. Reading code is necessary but not sufficient—you have to see it execute. + +### The Behavioral Gap Threat + +Here's the problem: what the auditor ran locally might differ from what's deployed. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ The Behavioral Gap │ +│ │ +│ Auditor builds locally → observes behavior A → "safe" │ +│ Production build → has behavior B (subtly different) │ +│ Attestation proves → production runs *something* │ +│ │ +│ The auditor certified behavior A. │ +│ Production has behavior B. │ +│ The audit doesn't apply. │ +└─────────────────────────────────────────────────────────────────┘ +``` + +This gap can arise from: +- Different dependency versions resolved at build time +- Timestamps or randomness affecting behavior +- Build environment differences (compiler, OS, architecture) +- Intentional divergence (malicious or accidental) + +**The hash question isn't abstract.** When an auditor asks "does my local build produce the same hash as production?"—they're really asking: *"Is my local model of the system actually the production system, or just a similar-looking approximation?"* + +### What Reproducibility Actually Provides + +Reproducibility closes the behavioral gap. If: +- Auditor builds from source → gets hash X +- Production attestation shows → hash X + +Then the auditor's local testing environment **is** the production system. Their conclusions apply. The audit is meaningful. + +Without reproducibility, the auditor has two options: +1. **Trust the developer's build** — "I audited something similar, probably fine" +2. **Pull the production image and diff manually** — Tedious, error-prone, incomplete + +Neither is satisfactory. Reproducibility makes the audit rigorous. + +### The Smart Contract Analogy + +Smart contracts solved this problem: +- Source code on Etherscan +- Compiler version specified +- Anyone can recompile and verify the bytecode matches on-chain codehash +- DYOR is actually possible + +TEE apps need the same pattern. The attestation is like the on-chain codehash. But without reproducible builds, there's no way to connect it back to auditable source. + +--- + +**Next:** [01a-reproducible-builds](../01a-reproducible-builds) shows how developers can provide the evidence auditors need—and protect against bitrot that breaks verification over time. + +--- + +## Next Steps + +- [01a-reproducible-builds](../01a-reproducible-builds): Make builds verifiable for auditors +- [02-kms-and-signing](../02-kms-and-signing): Derive persistent keys and sign messages +- [03-gateway-and-tls](../03-gateway-and-tls): Custom domains and TLS + +## SDK Reference + +- **JS/TS**: `npm install @phala/dstack-sdk` — [docs](https://github.com/Dstack-TEE/dstack/tree/master/sdk/js) +- **Python**: `pip install dstack-sdk` — [docs](https://github.com/Dstack-TEE/dstack/tree/master/sdk/python) + +## Files + +``` +01-attestation/ +├── docker-compose.yaml # Oracle app (quick-start, non-reproducible) +├── verify_full.py # Attestation verification script +└── README.md +``` diff --git a/tutorial/01-attestation/docker-compose.yaml b/tutorial/01-attestation/docker-compose.yaml new file mode 100644 index 0000000..48f7a62 --- /dev/null +++ b/tutorial/01-attestation/docker-compose.yaml @@ -0,0 +1,70 @@ +services: + app: + build: + context: . + dockerfile_inline: | + FROM node:22-slim + WORKDIR /app + RUN npm init -y && npm install @phala/dstack-sdk + RUN cat > index.mjs <<'SCRIPT' + import { DstackClient } from "@phala/dstack-sdk" + import { createServer } from "http" + import https from "https" + import crypto from "crypto" + + const client = new DstackClient() + const API_URL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + + // Fetch URL and capture TLS certificate fingerprint + function fetchWithTls(url) { + return new Promise((resolve, reject) => { + https.get(url, res => { + const cert = res.socket.getPeerCertificate() + let body = "" + res.on("data", c => body += c) + res.on("end", () => resolve({ + data: JSON.parse(body), + tlsFingerprint: cert.fingerprint256, + tlsIssuer: cert.issuer?.O, + tlsValidTo: cert.valid_to + })) + }).on("error", reject) + }) + } + + async function getAttestedPrice() { + // 1. Fetch price with TLS certificate info + const { data, tlsFingerprint, tlsIssuer, tlsValidTo } = await fetchWithTls(API_URL) + + // 2. Build statement (what we're attesting to) + const statement = { + source: "api.coingecko.com", + price: data.bitcoin.usd, + tlsFingerprint, + tlsIssuer, + tlsValidTo, + timestamp: Date.now() + } + + // 3. Hash statement and embed in TDX quote + const hash = crypto.createHash("sha256").update(JSON.stringify(statement)).digest("hex") + const quote = await client.getQuote(hash) + + return { statement, reportDataHash: hash, quote: quote.quote } + } + + createServer(async (req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }) + if (req.url === "/price") { + res.end(JSON.stringify(await getAttestedPrice(), null, 2)) + } else { + const info = await client.info() + res.end(JSON.stringify({ endpoints: ["/", "/price"], appId: info.app_id }, null, 2)) + } + }).listen(8080, () => console.log("Oracle at http://localhost:8080")) + SCRIPT + CMD ["node", "index.mjs"] + ports: + - "8080:8080" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock diff --git a/tutorial/01-attestation/verify_full.py b/tutorial/01-attestation/verify_full.py new file mode 100644 index 0000000..dc635df --- /dev/null +++ b/tutorial/01-attestation/verify_full.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +Full attestation verification using dcap-qvl for hardware + compose hash. + +This demonstrates end-to-end verification: +1. Hardware: dcap-qvl verifies TDX quote is from genuine Intel hardware +2. OS: dstack-mr calculates expected measurements from OS image +3. Compose: Compare compose hash from quote against expected manifest + +Prerequisites: + CFLAGS="-g0" cargo install dcap-qvl-cli + CGO_CFLAGS="-g0" go install github.com/kvinwang/dstack-mr@latest + phala cvms attestation --json > attestation.json +""" +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile + +def verify_quote(quote_hex: str) -> dict: + """Verify TDX quote with dcap-qvl, return parsed result.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.hex', delete=False) as f: + f.write(quote_hex) + quote_path = f.name + + result = subprocess.run( + ['dcap-qvl', 'verify', '--hex', quote_path], + capture_output=True, text=True + ) + if result.returncode != 0: + raise ValueError(f"dcap-qvl failed: {result.stderr}") + + return json.loads(result.stdout) + +def find_dstack_mr(): + """Find dstack-mr binary - check PATH and ~/go/bin.""" + dstack_mr = shutil.which('dstack-mr') + if dstack_mr: + return dstack_mr + go_bin = os.path.expanduser('~/go/bin/dstack-mr') + if os.path.exists(go_bin): + return go_bin + return None + +def calculate_os_measurements(image_folder: str) -> dict: + """Calculate expected OS measurements using dstack-mr.""" + dstack_mr = find_dstack_mr() + if not dstack_mr: + raise FileNotFoundError("dstack-mr not found") + metadata_path = os.path.join(image_folder, 'metadata.json') + result = subprocess.run( + [dstack_mr, '-metadata', metadata_path, '-json'], + capture_output=True, text=True + ) + if result.returncode != 0: + raise ValueError(f"dstack-mr failed: {result.stderr}") + return json.loads(result.stdout) + +def main(): + if len(sys.argv) < 2: + print("Usage: python verify_full.py [--image-folder PATH] [expected-manifest.json]") + print("\nGet attestation.json with: phala cvms attestation --json > attestation.json") + print("Download dstack image: curl -L https://github.com/Dstack-TEE/meta-dstack/releases/download/v0.5.5/dstack-0.5.5.tar.gz | tar xz") + sys.exit(1) + + attestation_path = sys.argv[1] + image_folder = None + manifest_path = None + i = 2 + while i < len(sys.argv): + if sys.argv[i] == '--image-folder' and i + 1 < len(sys.argv): + image_folder = sys.argv[i + 1] + i += 2 + else: + manifest_path = sys.argv[i] + i += 1 + + with open(attestation_path) as f: + data = json.load(f) + + # Extract quote + quote = data['app_certificates'][0]['quote'] + + print("=== Step 1: Hardware Verification (dcap-qvl) ===") + result = verify_quote(quote) + + status = result['status'] + advisories = result.get('advisory_ids', []) + print(f" TCB Status: {status}") + if advisories: + print(f" Advisories: {advisories}") + + if status not in ['UpToDate', 'SWHardeningNeeded']: + print(f" ✗ FAIL: TCB status {status} is not acceptable") + sys.exit(1) + print(" ✓ Hardware verification passed") + + # Extract measurements + report = result['report']['TD10'] + print() + print("=== Step 2: Extract Measurements ===") + print(f" MRTD: {report['mr_td'][:32]}...") + print(f" RTMR0: {report['rt_mr0'][:32]}...") + print(f" RTMR3: {report['rt_mr3'][:32]}...") + + # Extract compose hash from mr_config_id + config_id = report['mr_config_id'] + if not config_id.startswith('01'): + print(f" ✗ Unknown config ID format: {config_id[:4]}...") + sys.exit(1) + + verified_hash = config_id[2:66] + print(f" Compose hash (from quote): {verified_hash}") + + # OS verification (optional - requires dstack-mr and image folder) + print() + print("=== Step 3: OS Verification (dstack-mr) ===") + if not image_folder: + print(" (skipped - no --image-folder provided)") + print(" To verify OS: download dstack image matching your app's version") + elif not find_dstack_mr(): + print(" (skipped - dstack-mr not installed)") + print(" Install: CGO_CFLAGS=\"-g0\" go install github.com/kvinwang/dstack-mr@latest") + else: + expected = calculate_os_measurements(image_folder) + print(f" Expected MRTD: {expected['mrtd'][:32]}...") + print(f" Actual MRTD: {report['mr_td'][:32]}...") + if expected['mrtd'] == report['mr_td']: + print(" ✓ MRTD matches - kernel/initramfs verified") + else: + print(" ✗ MRTD mismatch - OS image may be different version") + sys.exit(1) + # Note: RTMR0-2 require dstack-mr-cli (Rust) with QEMU for accurate comparison + print(" (RTMR0-2 verification requires dstack-mr-cli with QEMU)") + + # Compare with expected + print() + print("=== Step 4: Compose Hash Verification ===") + + if manifest_path: + with open(manifest_path, 'rb') as f: + expected_hash = hashlib.sha256(f.read()).hexdigest() + print(f" Expected (from file): {expected_hash}") + else: + # Use manifest from attestation API response + manifest = data['tcb_info']['app_compose'] + expected_hash = hashlib.sha256(manifest.encode()).hexdigest() + print(f" Expected (from API): {expected_hash}") + + if verified_hash == expected_hash: + print(" ✓ MATCH - Compose hash verified!") + else: + print(" ✗ MISMATCH") + print(f" Verified: {verified_hash}") + print(f" Expected: {expected_hash}") + sys.exit(1) + + print() + print("=== Verification Complete ===") + print(" ✓ Hardware: Genuine Intel TDX") + if image_folder and find_dstack_mr(): + print(" ✓ OS: MRTD matches expected (kernel/initramfs)") + else: + print(" - OS: (skipped)") + print(" ✓ Compose: Matches expected manifest") + print() + print(" Security claim: This TEE is running the expected code") + print(" on genuine Intel TDX hardware.") + +if __name__ == "__main__": + main() diff --git a/tutorial/01a-reproducible-builds/Dockerfile b/tutorial/01a-reproducible-builds/Dockerfile new file mode 100644 index 0000000..f9d9c94 --- /dev/null +++ b/tutorial/01a-reproducible-builds/Dockerfile @@ -0,0 +1,27 @@ +# Reproducible build for TEE Oracle +# See README.md "Critical Thinking About TEE Apps" for why this matters + +ARG SOURCE_DATE_EPOCH=0 + +# Pin base image by digest, not tag +FROM node:22-slim@sha256:773413f36941ce1e4baf74b4a6110c03dcc4f968daffc389d4caef3f01412d2a + +ARG SOURCE_DATE_EPOCH +ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} +ENV npm_config_cache=/tmp/npm-cache + +WORKDIR /app + +# Copy lockfile first for layer caching +COPY package.json package-lock.json ./ + +# Install with ci (uses lockfile exactly) and clean all caches +# NODE_COMPILE_CACHE creates non-deterministic bytecode - must be disabled or cleaned +RUN npm ci --omit=dev --ignore-scripts && \ + rm -rf /tmp/npm-cache /tmp/node-compile-cache && \ + find /app -exec touch -d "@${SOURCE_DATE_EPOCH}" {} + 2>/dev/null || true + +COPY app.mjs ./ +RUN touch -d "@${SOURCE_DATE_EPOCH}" /app/app.mjs + +CMD ["node", "app.mjs"] diff --git a/tutorial/01a-reproducible-builds/README.md b/tutorial/01a-reproducible-builds/README.md new file mode 100644 index 0000000..4dd1663 --- /dev/null +++ b/tutorial/01a-reproducible-builds/README.md @@ -0,0 +1,328 @@ +# Tutorial 01a: Reproducible Builds + +Make your TEE app verifiable by auditors—now and in the future. + +This tutorial covers: +- Why reproducibility matters for TEE auditability +- Mechanics of deterministic Docker builds +- Testing reproducibility locally and remotely +- Protecting against bitrot + +**Prerequisite:** Read [01-attestation](../01-attestation) first to understand the auditor's perspective. + +--- + +## The Developer's Challenge + +In [01-attestation](../01-attestation), we saw what auditors demand: + +1. Source code +2. Build instructions +3. Reference hash +4. **Proof that rebuilding produces the reference hash** + +Item #4 is your job as a developer. If an auditor can't rebuild your image and get the same hash, they can't verify your deployment. Your app fails the audit before they even read the code. + +But there's a second threat: **bitrot**. + +### The Bitrot Problem + +Even with perfect reproducibility today, builds can break over time: + +- **snapshot.debian.org** doesn't guarantee permanence +- **npm packages** get unpublished (left-pad incident) +- **Docker Hub** prunes old images +- **GitHub releases** can be deleted + +In 2 years, your reproducible build might fail because dependencies vanished. The attestation becomes unverifiable—an auditor can't rebuild to confirm the hash. + +**The consequence:** Your "auditable" app degrades back to "trust me." + +--- + +## Quick Start + +```bash +# Build and verify reproducibility +./build-reproducible.sh + +# Test on a remote machine +./verify-remote.sh user@other-host +``` + +If both succeed, you have a reproducible build. + +--- + +## What Makes Builds Non-Reproducible + +Docker builds are **not reproducible by default**: + +```bash +$ docker build -t test:v1 . +$ docker build -t test:v2 . +$ docker inspect test:v1 --format='{{.Id}}' +sha256:a1b2c3... +$ docker inspect test:v2 --format='{{.Id}}' +sha256:d4e5f6... # Different! +``` + +Sources of non-determinism: + +| Source | Why It Breaks Builds | +|--------|---------------------| +| Image tags | `node:22` points to different images over time | +| Package versions | `apt-get install curl` gets latest version | +| Timestamps | Files have build-time timestamps | +| Caches | npm/pip/apt leave timestamped cache files | +| Layer ordering | BuildKit may reorder layers | + +--- + +## Making Builds Reproducible + +### 1. Pin Base Images by Digest + +```dockerfile +# BAD: Tag can change +FROM node:22-slim + +# GOOD: Pinned to exact image +FROM node:22-slim@sha256:773413f36941ce1e4baf74b4a6110c03dcc4f968daffc389d4caef3f01412d2a +``` + +Get the digest: +```bash +docker pull node:22-slim +docker inspect node:22-slim --format='{{index .RepoDigests 0}}' +``` + +### 2. Pin Package Versions + +```dockerfile +# BAD: Version depends on build date +RUN apt-get update && apt-get install -y curl + +# GOOD: Use Debian snapshot for point-in-time reproducibility +RUN echo 'deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/20250101T000000Z bookworm main' \ + > /etc/apt/sources.list && \ + apt-get -o Acquire::Check-Valid-Until=false update && \ + apt-get install -y curl=7.88.1-10+deb12u8 +``` + +For npm, use `package-lock.json` with exact versions (no `^` or `~`). + +### 3. Normalize Timestamps + +```dockerfile +ARG SOURCE_DATE_EPOCH=0 +RUN find /app -exec touch -d "@${SOURCE_DATE_EPOCH}" {} + +``` + +### 4. Clean All Caches + +```dockerfile +RUN npm ci --omit=dev --ignore-scripts && \ + rm -rf /tmp/npm-cache /tmp/node-compile-cache && \ + rm -rf /var/lib/apt/lists/* /var/log/* /var/cache/ldconfig/aux-cache +``` + +### 5. Use BuildKit with rewrite-timestamp + +```bash +docker buildx build \ + --build-arg SOURCE_DATE_EPOCH=0 \ + --output type=oci,dest=./image.tar,rewrite-timestamp=true \ + . +``` + +--- + +## The Complete Example + +This directory contains a reproducible version of the oracle from 01-attestation. + +**Dockerfile:** +```dockerfile +ARG SOURCE_DATE_EPOCH=0 + +FROM node:22-slim@sha256:773413f36941ce1e4baf74b4a6110c03dcc4f968daffc389d4caef3f01412d2a + +ARG SOURCE_DATE_EPOCH +ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} +ENV npm_config_cache=/tmp/npm-cache + +WORKDIR /app + +COPY package.json package-lock.json ./ + +RUN npm ci --omit=dev --ignore-scripts && \ + rm -rf /tmp/npm-cache /tmp/node-compile-cache && \ + find /app -exec touch -d "@${SOURCE_DATE_EPOCH}" {} + 2>/dev/null || true + +COPY app.mjs ./ +RUN touch -d "@${SOURCE_DATE_EPOCH}" /app/app.mjs + +CMD ["node", "app.mjs"] +``` + +**Build and verify:** +```bash +./build-reproducible.sh +``` + +Output: +``` +=== Building TEE Oracle (reproducible) === + +Build 1... + Hash: 5864fb1fdf1ee22b... + +Build 2... + Hash: 5864fb1fdf1ee22b... + +=== Results === +REPRODUCIBLE - both builds identical + +Image digest: +sha256:afadb40549b91ca4f9031e8ecd79bd4095b68afadbf6578fbecc57d0f6dfeab2 + +Saved: build-manifest.json +``` + +--- + +## Testing Reproducibility + +### Method 1: Double-build Locally + +The build script does this automatically—builds twice with `--no-cache`, compares hashes. + +### Method 2: Remote Machine + +Same source, different environment: + +```bash +./verify-remote.sh user@other-machine +``` + +This catches: +- Architecture-specific issues +- Toolchain differences +- Environment variable leakage + +### Method 3: GitHub Actions + +The gold standard—CI builds and compares against committed hash: + +```yaml +# .github/workflows/verify-reproducible.yml +name: Verify Reproducible Build +on: [push, pull_request] +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build and verify + run: | + ./build-reproducible.sh + EXPECTED=$(jq -r .image_hash build-manifest.json.committed) + ACTUAL=$(jq -r .image_hash build-manifest.json) + [[ "$EXPECTED" == "$ACTUAL" ]] || exit 1 +``` + +--- + +## Known Issues + +Some packages break reproducibility: + +| Package | Issue | Workaround | +|---------|-------|------------| +| Node.js 22+ | Compile cache in `/tmp/node-compile-cache` | `rm -rf /tmp/node-compile-cache` | +| Python pip | Timestamps in `.pyc` files | `PYTHONDONTWRITEBYTECODE=1` | +| Go binaries | Embeds build paths | Use `-trimpath` flag | +| npm native deps | Compilation varies | Pin compiler or use prebuilt | + +--- + +## Protecting Against Bitrot + +For long-term auditability, vendor your dependencies: + +```bash +# Download APT packages +mkdir -p vendor/apt && cd vendor/apt +apt-get download curl=7.88.1-10+deb12u8 + +# Download npm packages +mkdir -p vendor/npm && cd vendor/npm +npm pack express@4.18.2 + +# Save base image +docker save node:22-slim@sha256:773413... > vendor/images/node-22-slim.tar +``` + +Then build offline: +```bash +OFFLINE=1 ./build-reproducible.sh +``` + +This is extra work. Use it for: +- Apps handling significant value +- Deployments that need auditability for years +- Regulated environments + +--- + +## Levels of Reproducibility + +| Level | Achieves | Effort | When to Use | +|-------|----------|--------|-------------| +| **None** | "Runs in TEE" | Minimal | Demos only | +| **Loose** | Same hash today | Moderate | Most production apps | +| **Strict** | Rebuildable in 2+ years | High | High-stakes apps | +| **Extreme** | Bit-for-bit on any machine | Very high | Critical infrastructure | + +Start with **Loose**. Move to **Strict** if auditability must survive time. + +--- + +## Critical Thinking: Developer Self-Assessment + +Before claiming your app is auditable: + +1. **Can someone else rebuild and get the same hash?** + - Run `./verify-remote.sh` on a different machine + +2. **Are all dependencies pinned?** + - Check for `^` or `~` in package.json + - Check for unpinned apt packages + +3. **Is your build documented and automated?** + - Can a new team member reproduce it? + +4. **Will it still work in 2 years?** + - Consider vendoring if yes matters + +--- + +## Next Steps + +- [02-kms-and-signing](../02-kms-and-signing): Derive persistent keys +- [03-gateway-and-tls](../03-gateway-and-tls): Custom domains and TLS + +## Files + +``` +01a-reproducible-builds/ +├── docker-compose.yaml # Reproducible compose +├── Dockerfile # Pinned base, lockfile, cache cleanup +├── package.json # Pinned @phala/dstack-sdk +├── package-lock.json # Exact dependency versions +├── app.mjs # Oracle application +├── build-reproducible.sh # Build + verify script +├── verify-remote.sh # Remote machine verification +└── README.md +``` diff --git a/tutorial/01a-reproducible-builds/app.mjs b/tutorial/01a-reproducible-builds/app.mjs new file mode 100644 index 0000000..34625ad --- /dev/null +++ b/tutorial/01a-reproducible-builds/app.mjs @@ -0,0 +1,48 @@ +import { DstackClient } from "@phala/dstack-sdk" +import { createServer } from "http" +import https from "https" +import crypto from "crypto" + +const client = new DstackClient() +const API_URL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + +function fetchWithTls(url) { + return new Promise((resolve, reject) => { + https.get(url, res => { + const cert = res.socket.getPeerCertificate() + let body = "" + res.on("data", c => body += c) + res.on("end", () => resolve({ + data: JSON.parse(body), + tlsFingerprint: cert.fingerprint256, + tlsIssuer: cert.issuer?.O, + tlsValidTo: cert.valid_to + })) + }).on("error", reject) + }) +} + +async function getAttestedPrice() { + const { data, tlsFingerprint, tlsIssuer, tlsValidTo } = await fetchWithTls(API_URL) + const statement = { + source: "api.coingecko.com", + price: data.bitcoin.usd, + tlsFingerprint, + tlsIssuer, + tlsValidTo, + timestamp: Date.now() + } + const hash = crypto.createHash("sha256").update(JSON.stringify(statement)).digest("hex") + const quote = await client.getQuote(hash) + return { statement, reportDataHash: hash, quote: quote.quote } +} + +createServer(async (req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }) + if (req.url === "/price") { + res.end(JSON.stringify(await getAttestedPrice(), null, 2)) + } else { + const info = await client.info() + res.end(JSON.stringify({ endpoints: ["/", "/price"], appId: info.app_id }, null, 2)) + } +}).listen(8080, () => console.log("Oracle at http://localhost:8080")) diff --git a/tutorial/01a-reproducible-builds/build-manifest.json b/tutorial/01a-reproducible-builds/build-manifest.json new file mode 100644 index 0000000..00f0cc7 --- /dev/null +++ b/tutorial/01a-reproducible-builds/build-manifest.json @@ -0,0 +1,6 @@ +{ + "image_hash": "5864fb1fdf1ee22be3318e049bcdb8c472a3c96d4854f3608f07afffa73abdb3", + "image_digest": "sha256:afadb40549b91ca4f9031e8ecd79bd4095b68afadbf6578fbecc57d0f6dfeab2", + "build_date": "2025-12-27T17:31:37Z", + "source_date_epoch": 0 +} diff --git a/tutorial/01a-reproducible-builds/build-reproducible.sh b/tutorial/01a-reproducible-builds/build-reproducible.sh new file mode 100755 index 0000000..55df3dd --- /dev/null +++ b/tutorial/01a-reproducible-builds/build-reproducible.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reproducible build script for TEE Oracle +# Tests that the build produces identical output across runs + +cd "$(dirname "$0")" + +for cmd in docker skopeo jq; do + command -v "$cmd" >/dev/null || { echo "Required: $cmd"; exit 1; } +done + +echo "=== Building TEE Oracle (reproducible) ===" + +# Ensure buildx builder exists +if ! docker buildx inspect repro-builder &>/dev/null; then + docker buildx create --name repro-builder --driver docker-container +fi + +build_image() { + local output_file="$1" + docker buildx build \ + --builder repro-builder \ + --build-arg SOURCE_DATE_EPOCH=0 \ + --no-cache \ + --output type=oci,dest="$output_file",rewrite-timestamp=true \ + . +} + +# Build 1 +echo "" +echo "Build 1..." +build_image build1.tar +HASH1=$(sha256sum build1.tar | awk '{print $1}') +echo " Hash: ${HASH1:0:16}..." + +# Build 2 +echo "" +echo "Build 2..." +build_image build2.tar +HASH2=$(sha256sum build2.tar | awk '{print $1}') +echo " Hash: ${HASH2:0:16}..." + +# Compare +echo "" +echo "=== Results ===" +if [[ "$HASH1" == "$HASH2" ]]; then + echo "REPRODUCIBLE - both builds identical" + echo "" + echo "Image digest:" + skopeo inspect oci-archive:build1.tar | jq -r .Digest + + # Save manifest for future verification + cat > build-manifest.json << EOF +{ + "image_hash": "$HASH1", + "image_digest": "$(skopeo inspect oci-archive:build1.tar | jq -r .Digest)", + "build_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "source_date_epoch": 0 +} +EOF + echo "" + echo "Saved: build-manifest.json" + + # Load into docker for local testing + docker load < build1.tar 2>/dev/null || true + + rm -f build1.tar build2.tar + exit 0 +else + echo "NOT REPRODUCIBLE - builds differ" + echo "" + echo "Build 1: $HASH1" + echo "Build 2: $HASH2" + echo "" + echo "Debug: keeping build1.tar and build2.tar for inspection" + echo "Compare with: diff <(tar -tvf build1.tar) <(tar -tvf build2.tar)" + exit 1 +fi diff --git a/tutorial/01a-reproducible-builds/docker-compose.yaml b/tutorial/01a-reproducible-builds/docker-compose.yaml new file mode 100644 index 0000000..33b66f6 --- /dev/null +++ b/tutorial/01a-reproducible-builds/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + SOURCE_DATE_EPOCH: "0" + ports: + - "8080:8080" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock diff --git a/tutorial/01a-reproducible-builds/package-lock.json b/tutorial/01a-reproducible-builds/package-lock.json new file mode 100644 index 0000000..9824539 --- /dev/null +++ b/tutorial/01a-reproducible-builds/package-lock.json @@ -0,0 +1,1812 @@ +{ + "name": "tee-oracle", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tee-oracle", + "version": "1.0.0", + "dependencies": { + "@phala/dstack-sdk": "0.5.7" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT", + "optional": true + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@phala/dstack-sdk": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@phala/dstack-sdk/-/dstack-sdk-0.5.7.tgz", + "integrity": "sha512-yhdH1dIYCeyn/3jp9tIT4aCfOaVtO1cwFcTHKjeLzKeL/XTVWzbyTX1SU6NCN7tKpHWJ9y6Vdht/vcffZYEZnw==", + "license": "Apache-2.0", + "dependencies": { + "crypto-browserify": "^3.12.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@noble/curves": "^1.8.1", + "@solana/web3.js": "^1.98.0", + "viem": "^2.21.0 <3.0.0" + }, + "peerDependencies": { + "@noble/curves": "^1.8.1", + "@noble/hashes": "^1.6.1", + "@solana/web3.js": "^1.98.0", + "viem": "^2.21.0 <3.0.0" + }, + "peerDependenciesMeta": { + "@noble/curves": { + "optional": true + }, + "@solana/web3.js": { + "optional": true + }, + "viem": { + "optional": true + } + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@solana/buffer-layout": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", + "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "~6.0.3" + }, + "engines": { + "node": ">=5.10" + } + }, + "node_modules/@solana/codecs-core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", + "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", + "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@solana/codecs-core": "2.3.0", + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/errors": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", + "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js": { + "version": "1.98.4", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", + "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.25.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@solana/buffer-layout": "^4.0.1", + "@solana/codecs-numbers": "^2.1.0", + "agentkeepalive": "^4.5.0", + "bn.js": "^5.2.1", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.3", + "fast-stable-stringify": "^1.0.0", + "jayson": "^4.1.1", + "node-fetch": "^2.7.0", + "rpc-websockets": "^9.0.2", + "superstruct": "^2.0.2" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bn.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", + "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "license": "MIT" + }, + "node_modules/borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "license": "MIT", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "license": "ISC", + "dependencies": { + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.6.1", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "license": "MIT" + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "license": "MIT", + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT", + "optional": true + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "es6-promise": "^4.0.3" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT", + "optional": true + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", + "optional": true, + "engines": { + "node": "> 0.1.90" + } + }, + "node_modules/fast-stable-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", + "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", + "license": "MIT", + "optional": true + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "optional": true, + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "optional": true, + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jayson": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", + "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/connect": "^3.4.33", + "@types/node": "^12.12.54", + "@types/ws": "^7.4.4", + "commander": "^2.20.3", + "delay": "^5.0.0", + "es6-promisify": "^5.0.0", + "eyes": "^0.1.8", + "isomorphic-ws": "^4.0.1", + "json-stringify-safe": "^5.0.1", + "stream-json": "^1.9.1", + "uuid": "^8.3.2", + "ws": "^7.5.10" + }, + "bin": { + "jayson": "bin/jayson.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jayson/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "optional": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/ox": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.11.1.tgz", + "integrity": "sha512-1l1gOLAqg0S0xiN1dH5nkPna8PucrZgrIJOfS49MLNiMevxu07Iz4ZjuJS9N+xifvT+PsZyIptS7WHM8nC+0+A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/parse-asn1": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", + "license": "ISC", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pbkdf2": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rpc-websockets": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.2.tgz", + "integrity": "sha512-VuW2xJDnl1k8n8kjbdRSWawPRkwaVqUQNjE1TdeTawf0y0abGhtVJFTXCLfgpgGDBkO/Fj6kny8Dc/nvOW78MA==", + "license": "LGPL-3.0-only", + "optional": true, + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/uuid": "^8.3.4", + "@types/ws": "^8.2.2", + "buffer": "^6.0.3", + "eventemitter3": "^5.0.1", + "uuid": "^8.3.2", + "ws": "^8.5.0" + }, + "funding": { + "type": "paypal", + "url": "https://paypal.me/kozjak" + }, + "optionalDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + } + }, + "node_modules/rpc-websockets/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/rpc-websockets/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-encoding-utf-8": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", + "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==", + "optional": true + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/viem": { + "version": "2.43.3", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.43.3.tgz", + "integrity": "sha512-zM251fspfSjENCtfmT7cauuD+AA/YAlkFU7cksdEQJxj7wDuO0XFRWRH+RMvfmTFza88B9kug5cKU+Wk2nAjJg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.11.1", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tutorial/01a-reproducible-builds/package.json b/tutorial/01a-reproducible-builds/package.json new file mode 100644 index 0000000..e9103c5 --- /dev/null +++ b/tutorial/01a-reproducible-builds/package.json @@ -0,0 +1,8 @@ +{ + "name": "tee-oracle", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@phala/dstack-sdk": "0.5.7" + } +} diff --git a/tutorial/01a-reproducible-builds/verify-remote.sh b/tutorial/01a-reproducible-builds/verify-remote.sh new file mode 100755 index 0000000..0196ac4 --- /dev/null +++ b/tutorial/01a-reproducible-builds/verify-remote.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify reproducibility on a remote machine +# Usage: ./verify-remote.sh user@host [expected-hash] + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 user@host [expected-hash]" + echo "" + echo "Tests if the build reproduces on a different machine." + echo "If expected-hash is provided, compares against it." + echo "Otherwise, uses hash from local build-manifest.json." + exit 1 +fi + +REMOTE="$1" +cd "$(dirname "$0")" + +if [[ $# -ge 2 ]]; then + EXPECTED="$2" +elif [[ -f build-manifest.json ]]; then + EXPECTED=$(jq -r .image_hash build-manifest.json) +else + echo "No expected hash provided and no build-manifest.json found." + echo "Run ./build-reproducible.sh first, or provide hash as argument." + exit 1 +fi + +echo "=== Remote Reproducibility Test ===" +echo "Remote: $REMOTE" +echo "Expected hash: ${EXPECTED:0:16}..." +echo "" + +# Create tarball of source files +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +tar -czf "$TMPDIR/src.tar.gz" \ + Dockerfile \ + package.json \ + package-lock.json \ + app.mjs \ + build-reproducible.sh + +echo "Copying source to remote..." +scp "$TMPDIR/src.tar.gz" "$REMOTE:/tmp/tee-oracle-verify.tar.gz" + +echo "Building on remote..." +REMOTE_HASH=$(ssh "$REMOTE" bash -s << 'ENDSSH' +set -e +cd /tmp +rm -rf tee-oracle-verify +mkdir tee-oracle-verify +cd tee-oracle-verify +tar -xzf ../tee-oracle-verify.tar.gz + +# Ensure buildx (suppress all output) +docker buildx create --name repro-builder --driver docker-container >/dev/null 2>&1 || true + +docker buildx build \ + --builder repro-builder \ + --build-arg SOURCE_DATE_EPOCH=0 \ + --no-cache \ + --output type=oci,dest=verify.tar,rewrite-timestamp=true \ + . >/dev/null 2>&1 + +sha256sum verify.tar | awk '{print $1}' +rm -rf /tmp/tee-oracle-verify* +ENDSSH +) + +echo "" +echo "=== Results ===" +echo "Expected: $EXPECTED" +echo "Remote: $REMOTE_HASH" + +if [[ "$EXPECTED" == "$REMOTE_HASH" ]]; then + echo "" + echo "VERIFIED - remote build matches local" + exit 0 +else + echo "" + echo "MISMATCH - remote build differs from local" + exit 1 +fi diff --git a/tutorial/02-kms-and-signing/README.md b/tutorial/02-kms-and-signing/README.md new file mode 100644 index 0000000..4ad2583 --- /dev/null +++ b/tutorial/02-kms-and-signing/README.md @@ -0,0 +1,225 @@ +# Tutorial 02: KMS and Signing + +Derive persistent keys that survive restarts and produce verifiable signatures. + +## The Problem + +TEE memory is wiped on restart. If your app generates a private key at startup, it gets a new key every time — breaking wallets, signatures, and any persistent identity. + +## The Solution: `getKey()` + +The dstack SDK's `getKey()` derives deterministic keys from KMS: + +```javascript +import { DstackClient } from '@phala/dstack-sdk' + +const client = new DstackClient() +const result = await client.getKey('/oracle', 'ethereum') + +const privateKey = '0x' + Buffer.from(result.key).toString('hex').slice(0, 64) +``` + +The derived key is: +- **Deterministic**: Same path → same key, every restart +- **Unique to your app**: Different apps (compose hashes) get different keys +- **Verifiable**: Signature chain proves the key came from KMS + +## Signature Chain + +The KMS returns a **signature chain** proving derivation: + +``` +KMS Root (known on-chain) + │ + │ signs: "dstack-kms-issued:" + appId + appPubkey + ▼ +App Key (recovered from kmsSignature) + │ + │ signs: "ethereum:" + derivedPubkeyHex + ▼ +Derived Key → signs your messages +``` + +```javascript +const result = await client.getKey('/oracle', 'ethereum') + +result.key // Your derived key bytes +result.signature_chain[0] // App signature: appKey signs derivedPubkey +result.signature_chain[1] // KMS signature: kmsRoot signs appPubkey +``` + +## Try It + +```bash +pip install -r requirements.txt +phala simulator start + +docker compose build +docker compose run --rm -p 8080:8080 \ + -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock \ + app +``` + +In another terminal: + +```bash +python3 test_local.py +``` + +Output: +``` +TEE Oracle Signature Chain Verification +============================================================ +Oracle URL: http://localhost:8080 +KMS Root: 0x8f2cF602C9695b23130367ed78d8F557554de7C5 + +Fetching from oracle... +Got price: $97234.0 +Source: api.coingecko.com + +Verifying Signature Chain +================================================== +App ID: 0x... +Derived Pubkey: 02a1b2c3d4e5f6... + +Step 1: App signature over derived key + App Address: 0x... + +Step 2: KMS signature over app key + Recovered KMS: 0x8f2cF602C9695b23130367ed78d8F557554de7C5 + Expected KMS: 0x8f2cF602C9695b23130367ed78d8F557554de7C5 + OK: KMS signature verified + +Step 3: Message signature + Recovered signer: 0x... + Expected signer: 0x... + OK: Message signature verified + +============================================================ +All verifications passed: + - KMS signed the app key + - App key signed the derived key + - Derived key signed the oracle message +``` + +## Verifying the Signature Chain + +The verification steps: + +1. **App signature** — Recover the app public key from `appSignature` over the message `"{purpose}:{derivedPubkeyHex}"` + +2. **KMS signature** — Recover the KMS signer from `kmsSignature` over the message `"dstack-kms-issued:" + appId + appPubkeyCompressed`. Compare against known KMS root. + +3. **Message signature** — Recover the signer from the message signature. Compare against address derived from `derivedPubkey`. + +If all three pass, the signature chain is valid: the message was signed by a key derived from KMS for this specific app. + +## On-Chain Verification + +The same verification can run in a smart contract. See [04-onchain-oracle](../04-onchain-oracle) for `TeeOracle.sol` which implements: + +```solidity +function verify( + bytes32 messageHash, + bytes calldata messageSignature, + bytes calldata appSignature, + bytes calldata kmsSignature, + bytes calldata derivedCompressedPubkey, + bytes calldata appCompressedPubkey, + string calldata purpose +) public view returns (bool isValid) +``` + +## Key Paths + +Use paths to organize multiple keys: + +```javascript +await client.getKey('/wallet/main') // Main wallet +await client.getKey('/wallet/fees') // Fee payer +await client.getKey('/signing/oracle') // Oracle signatures +``` + +## Multi-Node Deployment + +Multiple TEE nodes can derive the **same key** if they share the same `appId`. This enables redundancy and load balancing while maintaining a single signing identity. + +### Deploy with allowAnyDevice + +The simplest multi-node setup uses `allowAnyDevice=true`, which lets any TEE with the correct compose hash join: + +```bash +# Deploy first node (deploys AppAuth contract with allowAnyDevice=true) +export PRIVATE_KEY="0x..." +python3 deploy_with_contract.py +``` + +Output: +``` +SUCCESS! Save this for deploying replicas: + APP_ID=0xc96d55b03ede924c89154348be9dcffd52304af0 + COMPOSE_HASH=0x392b8a1f... +``` + +### Deploy Replicas + +Edit `deploy_replica.py` with the APP_ID from above, then: + +```bash +python3 deploy_replica.py +``` + +Both nodes now derive the same key: +``` +Node 1: Oracle signer: 0x7a3B... (same!) +Node 2: Oracle signer: 0x7a3B... (same!) +``` + +### How It Works + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AppAuth Contract (allowAnyDevice=true) │ +│ │ +│ allowedComposeHashes[0x392b...] = true │ +│ allowAnyDevice = true │ +│ │ +│ isAppAllowed(bootInfo): │ +│ if composeHash in allowedComposeHashes → ALLOW │ +│ (device ID doesn't matter) │ +└─────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ Node 1 │ │ Node 2 │ + │ prod5 │ │ prod9 │ + └──────────┘ └──────────┘ + │ │ + │ getKey("/oracle") │ getKey("/oracle") + ▼ ▼ + Same derived key Same derived key +``` + +For more controlled multi-node setups (owner-approved devices, custom AppAuth), see [04-onchain-oracle](../04-onchain-oracle). + +## Files + +``` +02-kms-and-signing/ +├── docker-compose.yaml # Oracle with signing +├── test_local.py # Signature chain verification +├── deploy_with_contract.py # Deploy with allowAnyDevice=true +├── deploy_replica.py # Deploy replica using existing appId +├── requirements.txt # Python dependencies +└── README.md +``` + +## Next Steps + +- [03-gateway-and-tls](../03-gateway-and-tls): Custom domains and TLS +- [04-onchain-oracle](../04-onchain-oracle): On-chain verification contract, AppAuth deployment + +## References + +- [Key Management Protocol](https://docs.phala.network/dstack/design-documents/key-management-protocol) +- [DstackKms contract](https://github.com/dstack-tee/dstack/blob/main/kms/auth-eth/contracts/DstackKms.sol) diff --git a/tutorial/02-kms-and-signing/deploy_replica.py b/tutorial/02-kms-and-signing/deploy_replica.py new file mode 100644 index 0000000..10bfb40 --- /dev/null +++ b/tutorial/02-kms-and-signing/deploy_replica.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Deploy a CVM replica using existing appId via direct API calls. +This bypasses the CLI's limitation of always deploying a new AppAuth contract. +""" + +import os +import json +import platform +import hashlib +import requests +from pathlib import Path +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +CLOUD_API = "https://cloud-api.phala.network/api/v1" + +def get_machine_key(): + """Generate machine-specific key like the CLI does""" + import subprocess + hostname = platform.node() + plat = platform.system().lower() + # Node.js os.arch() returns 'x64' not 'x86_64' + arch = platform.machine() + if arch == "x86_64": + arch = "x64" + try: + cpu_model = subprocess.check_output("cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d: -f2", shell=True).decode().strip() + except: + cpu_model = "" + username = os.environ.get("USER", "") + + parts = f"{hostname}|{plat}|{arch}|{cpu_model}|{username}" + return hashlib.sha256(parts.encode()).digest() + +def decrypt_api_key(): + """Decrypt the stored API key""" + key_file = Path.home() / ".phala-cloud" / "api-key" + if not key_file.exists(): + return None + + encrypted = key_file.read_text().strip() + parts = encrypted.split(":") + if len(parts) != 2: + return None + + iv = bytes.fromhex(parts[0]) + ciphertext = bytes.fromhex(parts[1]) + key = get_machine_key()[:32] + + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + padded = decryptor.update(ciphertext) + decryptor.finalize() + + # Remove PKCS7 padding + pad_len = padded[-1] + return padded[:-pad_len].decode() + +API_KEY = os.environ.get("PHALA_CLOUD_API_KEY") or decrypt_api_key() + +# First CVM's info - UPDATE THIS with your appId from the first deployment +EXISTING_APP_ID = "c96d55b03ede924c89154348be9dcffd52304af0" +EXISTING_APP_AUTH_ADDRESS = "0x" + EXISTING_APP_ID # For on-chain KMS, appId IS the contract address + +# Target node for replica +TARGET_NODE_ID = 18 # prod9 + +# Replica name - change this for each replica +REPLICA_NAME = "tee-oracle-option2-replica" + +def get_headers(): + return { + "X-API-Key": API_KEY, + "Content-Type": "application/json" + } + +def read_compose_file(): + with open("docker-compose.yaml", "r") as f: + return f.read() + +def provision_cvm(name: str, compose_content: str, node_id: int, kms_id: str): + """Step 1: Provision CVM resources""" + payload = { + "name": name, + "image": "dstack-0.5.4", + "vcpu": 1, + "memory": 2048, + "disk_size": 20, + "teepod_id": node_id, + "kms_id": kms_id, + "compose_file": { + "docker_compose_file": compose_content, + "allowed_envs": [], + "features": ["kms"], + "kms_enabled": True, + "manifest_version": 2, + "name": name, + "public_logs": True, + "public_sysinfo": True, + "tproxy_enabled": False + }, + "env_keys": [], + "listed": True, + "instance_type": "tdx.small" + } + + resp = requests.post(f"{CLOUD_API}/cvms/provision", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def create_cvm_with_existing_app(app_id: str, compose_hash: str, app_auth_address: str, deployer_address: str): + """Step 2: Create CVM using existing appId (skip contract deployment)""" + payload = { + "app_id": app_id, + "compose_hash": compose_hash, + "encrypted_env": "", + "app_auth_contract_address": app_auth_address, + "deployer_address": deployer_address + } + + resp = requests.post(f"{CLOUD_API}/cvms", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def main(): + if not API_KEY: + print("Set PHALA_CLOUD_API_KEY environment variable") + return + + print("=" * 60) + print("Deploying CVM Replica with Existing App ID") + print("=" * 60) + print(f"Existing App ID: {EXISTING_APP_ID}") + print(f"Target Node: prod9 (id={TARGET_NODE_ID})") + print() + + # Step 1: Provision + print("Step 1: Provisioning CVM resources...") + compose_content = read_compose_file() + provision_result = provision_cvm( + name=REPLICA_NAME, + compose_content=compose_content, + node_id=TARGET_NODE_ID, + kms_id="kms-base-prod9" + ) + + print(f" Compose Hash: {provision_result.get('compose_hash', 'N/A')}") + print(f" Device ID: {provision_result.get('device_id', 'N/A')}") + + # Step 2: Create CVM with existing app_id + print("\nStep 2: Creating CVM with existing App ID...") + print(" (Skipping contract deployment - using existing AppAuth)") + + # Get deployer address from first CVM or use a known one + # For allowAnyDevice=true, the deployer doesn't matter for auth + deployer = "0x0000000000000000000000000000000000000000" # placeholder + + try: + create_result = create_cvm_with_existing_app( + app_id=EXISTING_APP_ID, + compose_hash=provision_result["compose_hash"], + app_auth_address=EXISTING_APP_AUTH_ADDRESS, + deployer_address=deployer + ) + + print("\nCVM Created!") + print(json.dumps(create_result, indent=2)) + + except requests.exceptions.HTTPError as e: + print(f"\nError: {e}") + print(f"Response: {e.response.text}") + print("\nThis might fail if:") + print(" 1. The AppAuth contract wasn't deployed with allowAnyDevice=true") + print(" 2. The compose_hash isn't registered in the contract") + print(" 3. The device_id for prod9 isn't whitelisted") + +if __name__ == "__main__": + main() diff --git a/tutorial/02-kms-and-signing/deploy_with_contract.py b/tutorial/02-kms-and-signing/deploy_with_contract.py new file mode 100644 index 0000000..79f6c5a --- /dev/null +++ b/tutorial/02-kms-and-signing/deploy_with_contract.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Deploy CVM with allowAnyDevice=true for self-join support. + +This script: +1. Provisions a CVM +2. Deploys AppAuth contract with allowAnyDevice=true +3. Creates the CVM + +For replicas, use deploy_replica.py with the appId from this deployment. +""" + +import os +import json +import platform +import hashlib +import requests +from pathlib import Path +from web3 import Web3 +from eth_account import Account + +# Decrypt API key (same as deploy_replica.py) +def get_machine_key(): + import subprocess + hostname = platform.node() + plat = platform.system().lower() + arch = platform.machine() + if arch == "x86_64": + arch = "x64" + try: + cpu_model = subprocess.check_output("cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d: -f2", shell=True).decode().strip() + except: + cpu_model = "" + username = os.environ.get("USER", "") + parts = f"{hostname}|{plat}|{arch}|{cpu_model}|{username}" + return hashlib.sha256(parts.encode()).digest() + +def decrypt_api_key(): + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + key_file = Path.home() / ".phala-cloud" / "api-key" + if not key_file.exists(): + return None + encrypted = key_file.read_text().strip() + parts = encrypted.split(":") + if len(parts) != 2: + return None + iv = bytes.fromhex(parts[0]) + ciphertext = bytes.fromhex(parts[1]) + key = get_machine_key()[:32] + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + padded = decryptor.update(ciphertext) + decryptor.finalize() + pad_len = padded[-1] + return padded[:-pad_len].decode() + +CLOUD_API = "https://cloud-api.phala.network/api/v1" +API_KEY = os.environ.get("PHALA_CLOUD_API_KEY") or decrypt_api_key() +PRIVATE_KEY = os.environ.get("PRIVATE_KEY") + +# Base mainnet +BASE_RPC = "https://mainnet.base.org" +KMS_CONTRACT = "0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C" + +# KMS Factory ABI for deploying AppAuth with allowAnyDevice +KMS_FACTORY_ABI = [{ + "inputs": [ + {"name": "deployer", "type": "address"}, + {"name": "disableUpgrades", "type": "bool"}, + {"name": "allowAnyDevice", "type": "bool"}, + {"name": "deviceId", "type": "bytes32"}, + {"name": "composeHash", "type": "bytes32"} + ], + "name": "deployAndRegisterApp", + "outputs": [{"name": "", "type": "address"}], + "stateMutability": "nonpayable", + "type": "function" +}, { + "inputs": [ + {"name": "appId", "type": "address", "indexed": True}, + {"name": "deployer", "type": "address", "indexed": True} + ], + "name": "AppDeployedViaFactory", + "type": "event" +}] + +def get_headers(): + return {"X-API-Key": API_KEY, "Content-Type": "application/json"} + +def read_compose_file(): + with open("docker-compose.yaml", "r") as f: + return f.read() + +def provision_cvm(name: str, compose_content: str, node_id: int, kms_id: str): + payload = { + "name": name, + "image": "dstack-0.5.4", + "vcpu": 1, + "memory": 2048, + "disk_size": 20, + "teepod_id": node_id, + "kms_id": kms_id, + "compose_file": { + "docker_compose_file": compose_content, + "allowed_envs": [], + "features": ["kms"], + "kms_enabled": True, + "manifest_version": 2, + "name": name, + "public_logs": True, + "public_sysinfo": True, + "tproxy_enabled": False + }, + "env_keys": [], + "listed": True, + "instance_type": "tdx.small" + } + resp = requests.post(f"{CLOUD_API}/cvms/provision", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def deploy_app_auth_any_device(compose_hash: str): + """Deploy AppAuth contract with allowAnyDevice=true""" + w3 = Web3(Web3.HTTPProvider(BASE_RPC)) + account = Account.from_key(PRIVATE_KEY) + + contract = w3.eth.contract(address=KMS_CONTRACT, abi=KMS_FACTORY_ABI) + + # Zero device ID + allowAnyDevice=true + device_id = bytes(32) + compose_hash_bytes = bytes.fromhex(compose_hash.replace("0x", "")) + + tx = contract.functions.deployAndRegisterApp( + account.address, # deployer + False, # disableUpgrades + True, # allowAnyDevice = TRUE! + device_id, # zero device ID + compose_hash_bytes # compose hash + ).build_transaction({ + 'from': account.address, + 'nonce': w3.eth.get_transaction_count(account.address), + 'gas': 500000, + 'gasPrice': w3.eth.gas_price + }) + + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + print(f" Transaction: {tx_hash.hex()}") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + # Parse AppDeployedViaFactory event to get appId + logs = contract.events.AppDeployedViaFactory().process_receipt(receipt) + if not logs: + raise Exception("No AppDeployedViaFactory event found") + + app_id = logs[0]['args']['appId'] + return app_id, account.address + +def create_cvm(app_id: str, compose_hash: str, app_auth_address: str, deployer: str): + payload = { + "app_id": app_id.lower().replace("0x", ""), + "compose_hash": compose_hash, + "encrypted_env": "", + "app_auth_contract_address": app_auth_address, + "deployer_address": deployer + } + resp = requests.post(f"{CLOUD_API}/cvms", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def main(): + if not API_KEY: + print("Set PHALA_CLOUD_API_KEY or have ~/.phala-cloud/api-key") + return + if not PRIVATE_KEY: + print("Set PRIVATE_KEY environment variable (for Base contract deployment)") + return + + print("=" * 60) + print("Deploying CVM with allowAnyDevice=true") + print("=" * 60) + + # Step 1: Provision + print("\nStep 1: Provisioning CVM resources on prod5...") + compose_content = read_compose_file() + provision = provision_cvm("tee-oracle-any", compose_content, 26, "kms-base-prod5") + compose_hash = provision["compose_hash"] + print(f" Compose Hash: {compose_hash}") + + # Step 2: Deploy AppAuth with allowAnyDevice=true + print("\nStep 2: Deploying AppAuth contract with allowAnyDevice=true...") + app_id, deployer = deploy_app_auth_any_device(compose_hash) + print(f" App ID: {app_id}") + print(f" Deployer: {deployer}") + + # Step 3: Create CVM + print("\nStep 3: Creating CVM...") + result = create_cvm(app_id, compose_hash, app_id, deployer) + print(f" CVM ID: {result.get('id')}") + print(f" Status: {result.get('status')}") + + print("\n" + "=" * 60) + print("SUCCESS! Save this for deploying replicas:") + print(f" APP_ID={app_id}") + print(f" COMPOSE_HASH={compose_hash}") + print("=" * 60) + +if __name__ == "__main__": + main() diff --git a/tutorial/02-kms-and-signing/docker-compose.yaml b/tutorial/02-kms-and-signing/docker-compose.yaml new file mode 100644 index 0000000..e5c5b65 --- /dev/null +++ b/tutorial/02-kms-and-signing/docker-compose.yaml @@ -0,0 +1,107 @@ +services: + app: + build: + context: . + dockerfile_inline: | + FROM node:22-slim + WORKDIR /app + RUN npm init -y && npm install @phala/dstack-sdk viem + RUN cat > index.mjs <<'SCRIPT' + import { DstackClient } from "@phala/dstack-sdk" + import { createServer } from "http" + import https from "https" + import { privateKeyToAccount } from "viem/accounts" + import { keccak256, encodePacked, toHex, hexToBytes } from "viem" + import { secp256k1 } from "@noble/curves/secp256k1" + + const client = new DstackClient() + const API_URL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + + async function getOracleKey() { + const result = await client.getKey("/oracle", "ethereum") + const privateKey = "0x" + Buffer.from(result.key).toString("hex").slice(0, 64) + const account = privateKeyToAccount(privateKey) + + // Extract compressed public key for signature chain verification + const derivedPrivBytes = hexToBytes(privateKey) + const derivedPubkey = secp256k1.getPublicKey(derivedPrivBytes.slice(0, 32), true) + + const toHexStr = (x) => typeof x === 'string' ? x : '0x' + Buffer.from(x).toString('hex') + return { + account, + derivedPubkey: toHex(derivedPubkey), + appSignature: toHexStr(result.signature_chain[0]), + kmsSignature: toHexStr(result.signature_chain[1]) + } + } + + function fetchWithTls(url) { + return new Promise((resolve, reject) => { + https.get(url, res => { + const cert = res.socket.getPeerCertificate() + let body = "" + res.on("data", c => body += c) + res.on("end", () => resolve({ + data: JSON.parse(body), + tlsFingerprint: cert.fingerprint256 + })) + }).on("error", reject) + }) + } + + async function getSignedPrice(oracle) { + const { data, tlsFingerprint } = await fetchWithTls(API_URL) + + const statement = { + source: "api.coingecko.com", + price: data.bitcoin.usd, + tlsFingerprint, + timestamp: Date.now() + } + + const messageHash = keccak256( + encodePacked( + ["string", "uint256", "uint256", "string"], + [statement.source, BigInt(Math.floor(statement.price * 100)), BigInt(statement.timestamp), statement.tlsFingerprint] + ) + ) + + const signature = await oracle.account.signMessage({ message: { raw: messageHash } }) + + return { + statement, + messageHash, + signature, + signatureChain: { + derivedPubkey: oracle.derivedPubkey, + appSignature: oracle.appSignature, + kmsSignature: oracle.kmsSignature + }, + signerAddress: oracle.account.address + } + } + + const oracle = await getOracleKey() + const info = await client.info() + console.log("Oracle signer:", oracle.account.address) + console.log("App ID:", info.app_id) + + createServer(async (req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }) + if (req.url === "/price") { + res.end(JSON.stringify(await getSignedPrice(oracle), null, 2)) + } else { + res.end(JSON.stringify({ + endpoints: ["/", "/price"], + appId: info.app_id, + signerAddress: oracle.account.address, + derivedPubkey: oracle.derivedPubkey + }, null, 2)) + } + }).listen(8080, () => console.log("Oracle at http://localhost:8080")) + SCRIPT + CMD ["node", "index.mjs"] + ports: + - "8080:8080" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock diff --git a/tutorial/02-kms-and-signing/requirements.txt b/tutorial/02-kms-and-signing/requirements.txt new file mode 100644 index 0000000..cdf3305 --- /dev/null +++ b/tutorial/02-kms-and-signing/requirements.txt @@ -0,0 +1,5 @@ +eth-account +eth-keys +requests +web3 +cryptography diff --git a/tutorial/02-kms-and-signing/test_local.py b/tutorial/02-kms-and-signing/test_local.py new file mode 100644 index 0000000..91a9e18 --- /dev/null +++ b/tutorial/02-kms-and-signing/test_local.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +Verify TEE oracle signature chain locally. + +Prerequisites: + pip install eth-account eth-keys requests + phala simulator start + docker compose up (oracle running on localhost:8080) +""" + +import requests +from eth_account import Account +from eth_utils import keccak +from eth_keys import keys + +# Simulator KMS root address +KMS_ROOT_ADDRESS = "0x8f2cF602C9695b23130367ed78d8F557554de7C5" + +ORACLE_URL = "http://localhost:8080" + +def fetch_oracle_price(): + print("Fetching from oracle...") + resp = requests.get(f"{ORACLE_URL}/price", timeout=10) + resp.raise_for_status() + return resp.json() + +def verify_signature_chain(data, expected_kms_root): + """ + Verify the complete signature chain: + 1. KMS root signed app key + 2. App key signed derived key + 3. Derived key signed the message + """ + print("\nVerifying Signature Chain") + print("=" * 50) + + chain = data["signatureChain"] + derived_pubkey = bytes.fromhex(chain["derivedPubkey"].replace("0x", "")) + app_signature = bytes.fromhex(chain["appSignature"].replace("0x", "")) + kms_signature = bytes.fromhex(chain["kmsSignature"].replace("0x", "")) + message_hash = bytes.fromhex(data["messageHash"].replace("0x", "")) + message_signature = bytes.fromhex(data["signature"].replace("0x", "")) + + info_resp = requests.get(f"{ORACLE_URL}/", timeout=10) + app_id = info_resp.json()["appId"] + app_id_bytes = bytes.fromhex(app_id.replace("0x", "")) + + print(f"App ID: {app_id}") + print(f"Derived Pubkey: {derived_pubkey.hex()[:20]}...") + print(f"Expected KMS Root: {expected_kms_root}") + + # Step 1: Verify app signature over derived key + purpose = "ethereum" + app_message = f"{purpose}:{derived_pubkey.hex()}" + app_message_hash = keccak(text=app_message) + + app_sig_obj = keys.Signature(app_signature) + app_pubkey = app_sig_obj.recover_public_key_from_msg_hash(app_message_hash) + app_pubkey_compressed = app_pubkey.to_compressed_bytes() + app_address = app_pubkey.to_checksum_address() + + print(f"\nStep 1: App signature over derived key") + print(f" App Address: {app_address}") + + # Step 2: Verify KMS signature over app key + kms_message = b"dstack-kms-issued:" + app_id_bytes + app_pubkey_compressed + kms_message_hash = keccak(kms_message) + + kms_signer = Account._recover_hash(kms_message_hash, signature=kms_signature) + + print(f"\nStep 2: KMS signature over app key") + print(f" Recovered KMS: {kms_signer}") + print(f" Expected KMS: {expected_kms_root}") + + if kms_signer.lower() != expected_kms_root.lower(): + print(" FAILED: KMS signature mismatch") + return False + + print(" OK: KMS signature verified") + + # Step 3: Verify message signature + eth_message = b"\x19Ethereum Signed Message:\n32" + message_hash + eth_hash = keccak(eth_message) + + message_signer = Account._recover_hash(eth_hash, signature=message_signature) + + derived_key_obj = keys.PublicKey.from_compressed_bytes(derived_pubkey) + expected_signer = derived_key_obj.to_checksum_address() + + print(f"\nStep 3: Message signature") + print(f" Recovered signer: {message_signer}") + print(f" Expected signer: {expected_signer}") + + if message_signer.lower() != expected_signer.lower(): + print(" FAILED: Message signature mismatch") + return False + + print(" OK: Message signature verified") + return True + +def main(): + print("TEE Oracle Signature Chain Verification") + print("=" * 60) + print(f"Oracle URL: {ORACLE_URL}") + print(f"KMS Root: {KMS_ROOT_ADDRESS}") + print() + + try: + data = fetch_oracle_price() + print(f"Got price: ${data['statement']['price']}") + print(f"Source: {data['statement']['source']}") + except Exception as e: + print(f"Failed to fetch oracle: {e}") + print("\nMake sure the oracle is running:") + print(" docker compose run --rm -p 8080:8080 \\") + print(" -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock app") + return False + + if verify_signature_chain(data, KMS_ROOT_ADDRESS): + print("\n" + "=" * 60) + print("All verifications passed:") + print(" - KMS signed the app key") + print(" - App key signed the derived key") + print(" - Derived key signed the oracle message") + print("\nThis signature chain can be verified on-chain.") + return True + else: + print("\nVerification failed!") + return False + +if __name__ == "__main__": + main() diff --git a/tutorial/03-gateway-and-tls/README.md b/tutorial/03-gateway-and-tls/README.md new file mode 100644 index 0000000..def4adf --- /dev/null +++ b/tutorial/03-gateway-and-tls/README.md @@ -0,0 +1,174 @@ +# Tutorial 03: TLS and Connectivity + +Self-signed TLS with attestation-bound certificates. + +## Prerequisites + +Complete [01-attestation](../01-attestation) first. This tutorial builds on attestation verification. + +## The Problem + +TEE apps need TLS, but: +- The dstack gateway terminates TLS → gateway sees plaintext +- Let's Encrypt requires DNS control → adds complexity +- Trusting a relay service (ngrok) to terminate TLS → breaks TEE integrity + +## The Solution: Attestation-Bound Certificates + +The TEE generates a self-signed certificate. The certificate fingerprint is included in the attestation. Clients verify: + +``` +1. Connect to TEE (ignore cert validation initially) +2. Fetch attestation from /attestation endpoint +3. Validate attestation (quote, measurements, etc.) +4. Extract cert fingerprint from attestation +5. Verify the TLS cert matches the attested fingerprint +6. Now the connection is trusted end-to-end +``` + +This works regardless of how you reach the TEE - gateway, ngrok, direct IP, etc. + +## Architecture + +``` +┌────────┐ ┌─────────┐ ┌─────────────────────────┐ +│ Client │ ─ TLS ─ │ Relay │ ─ TLS ─ │ TEE │ +│ │ │ (ngrok) │ │ ┌─────────────────────┐ │ +│ │ │ │ │ │ Self-signed cert │ │ +│ │ │ │ │ │ Attestation includes│ │ +│ │ │ │ │ │ cert fingerprint │ │ +│ │ │ │ │ └─────────────────────┘ │ +└────────┘ └─────────┘ └─────────────────────────┘ + relay only sees + encrypted TLS traffic +``` + +## Oracle with Self-Signed TLS + +Building on the oracle from [02-kms-and-signing](../02-kms-and-signing), we add: +- Self-signed TLS certificate generated at startup +- Certificate fingerprint included in `/attestation` response + +```yaml +services: + app: + build: + context: . + dockerfile_inline: | + FROM node:22-slim + RUN apt-get update && apt-get install -y openssl + WORKDIR /app + RUN npm init -y && npm install @phala/dstack-sdk viem + COPY index.mjs . + CMD ["node", "index.mjs"] + ports: + - "8443:8443" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock +``` + +## index.mjs + +See [index.mjs](index.mjs) for the full implementation. Key points: + +```javascript +// Generate self-signed cert at startup +execSync(`openssl req -x509 -newkey rsa:2048 ... -subj "/CN=tee-oracle"`) + +// Hash the DER-encoded certificate (matches what TLS clients see) +const certDer = Buffer.from(pemToDer(certPem), 'base64') +const certFingerprint = createHash("sha256").update(certDer).digest("hex") + +// Include fingerprint in attestation +app.get("/attestation", async (req, res) => { + const reportData = Buffer.from(certFingerprint, "hex") + const quote = await client.getQuote(reportData) + res.json({ certFingerprint, quote: quote.quote.toString("hex"), ... }) +}) +``` + +## Verification Script + +See [verify_tls.py](verify_tls.py) for the full implementation. The script: + +1. Connects to the endpoint and extracts the TLS certificate fingerprint +2. Fetches `/attestation` (ignoring cert validation initially) +3. Verifies the certificate fingerprint matches what's in the attestation +4. Verifies the attestation quote itself + +``` +$ python3 verify_tls.py https://localhost:8443 + +Verifying: https://localhost:8443 + +1. Connecting and getting certificate... + Certificate fingerprint: 789b0a77f2ad4b17... +2. Fetching attestation... + Attested fingerprint: 789b0a77f2ad4b17... +3. Verifying certificate matches attestation... + Certificate fingerprint matches attestation +4. Verifying attestation... + Quote present (full verification requires trust-center) + +============================================================ +SUCCESS: TLS certificate is bound to TEE attestation +The connection is end-to-end secure regardless of relay. +``` + +## Testing Locally + +```bash +# Terminal 1: Start simulator +phala simulator start + +# Terminal 2: Run oracle with TLS +docker compose build +docker compose run --rm -p 8443:8443 \ + -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock app + +# Terminal 3: Verify +python3 verify_tls.py https://localhost:8443 +``` + +## Connectivity Options + +The verification works regardless of how you reach the TEE - the attestation binds the certificate. + +| Method | TLS Termination | Trust Path | +|--------|-----------------|------------| +| Direct (localhost) | Your App TEE | Single TEE | +| Self-signed + attestation | Your App TEE | Single TEE | +| dstack gateway | Gateway TEE | Gateway TEE → Your App TEE | +| dstack-ingress | Your App TEE | Single TEE (with Let's Encrypt) | +| ngrok TCP tunnel | Your App TEE | Single TEE (ngrok is transport only) | + +### For Production + +**dstack-ingress** handles Let's Encrypt inside the TEE using DNS-01 challenges (no inbound port 80 needed). See [dstack-ingress](../../custom-domain/dstack-ingress) for setup. + +### About the dstack Gateway + +The dstack gateway is itself a TEE application - it's a dstack docker compose running in its own enclave. TLS termination happens inside the gateway's TEE, not in untrusted infrastructure. + +**Audit surface:** When using the gateway, verifying the gateway's attestation becomes part of your trust assumptions. The gateway code is open source and scheduled for security audit. + +For maximum isolation (single TEE in the trust path), use the self-signed + attestation approach from this tutorial or dstack-ingress. + +## Files + +``` +03-gateway-and-tls/ +├── docker-compose.yaml # Oracle with self-signed TLS +├── index.mjs # HTTPS server with attestation +├── verify_tls.py # Verification script +└── README.md +``` + +## Next Steps + +- [04-onchain-oracle](../04-onchain-oracle): AppAuth contracts and on-chain verification +- [05-hardening-https](../05-hardening-https): Let's Encrypt with dstack-ingress + +## Key Insight + +**The certificate doesn't need to be signed by a CA.** The attestation IS the trust anchor. By binding the certificate fingerprint to a valid TEE attestation, we prove the certificate was generated inside the TEE. This pattern works for any self-signed credential. diff --git a/tutorial/03-gateway-and-tls/docker-compose.yaml b/tutorial/03-gateway-and-tls/docker-compose.yaml new file mode 100644 index 0000000..e3a660a --- /dev/null +++ b/tutorial/03-gateway-and-tls/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + app: + build: + context: . + dockerfile_inline: | + FROM node:22-slim + RUN apt-get update && apt-get install -y openssl + WORKDIR /app + RUN npm init -y && npm install @phala/dstack-sdk viem + COPY index.mjs . + CMD ["node", "index.mjs"] + ports: + - "8443:8443" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock diff --git a/tutorial/03-gateway-and-tls/index.mjs b/tutorial/03-gateway-and-tls/index.mjs new file mode 100644 index 0000000..94ca065 --- /dev/null +++ b/tutorial/03-gateway-and-tls/index.mjs @@ -0,0 +1,57 @@ +import { DstackClient } from "@phala/dstack-sdk" +import { createServer } from "https" +import { execSync } from "child_process" +import { readFileSync, existsSync } from "fs" +import { createHash } from "crypto" + +const client = new DstackClient() + +// Generate self-signed cert if not exists +if (!existsSync("/tmp/cert.pem")) { + execSync(`openssl req -x509 -newkey rsa:2048 -keyout /tmp/key.pem -out /tmp/cert.pem -days 365 -nodes -subj "/CN=tee-oracle"`) +} + +const certPem = readFileSync("/tmp/cert.pem") +const key = readFileSync("/tmp/key.pem") + +// Extract DER from PEM and hash it (matches what TLS clients see) +const certDer = Buffer.from( + certPem.toString().replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\n/g, ''), + 'base64' +) +const certFingerprint = createHash("sha256").update(certDer).digest("hex") + +console.log("Cert fingerprint:", certFingerprint) + +async function handleRequest(req, res) { + res.setHeader("Content-Type", "application/json") + + if (req.url === "/attestation") { + // Pass cert fingerprint as report_data (hex string -> buffer -> first 64 bytes) + const reportData = Buffer.from(certFingerprint, "hex") + const quote = await client.getQuote(reportData) + res.end(JSON.stringify({ + certFingerprint, + quote: Buffer.from(quote.quote).toString("hex"), + eventLog: quote.event_log + })) + return + } + + if (req.url === "/") { + res.end(JSON.stringify({ + status: "ok", + certFingerprint, + message: "Fetch /attestation to verify this certificate" + })) + return + } + + res.statusCode = 404 + res.end(JSON.stringify({ error: "not found" })) +} + +const server = createServer({ cert: certPem, key }, handleRequest) +server.listen(8443, () => console.log("HTTPS server on :8443")) diff --git a/tutorial/03-gateway-and-tls/verify_tls.py b/tutorial/03-gateway-and-tls/verify_tls.py new file mode 100644 index 0000000..c3143e6 --- /dev/null +++ b/tutorial/03-gateway-and-tls/verify_tls.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Verify TEE oracle via attestation-bound certificate. + +Usage: + python3 verify_tls.py + +Examples: + python3 verify_tls.py https://localhost:8443 + python3 verify_tls.py https://0.tcp.ngrok.io:12345 +""" + +import sys +import ssl +import socket +import hashlib +import urllib3 +import requests +from urllib.parse import urlparse + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def get_cert_fingerprint(host, port): + """Get SHA256 fingerprint of server's TLS certificate""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + with ctx.wrap_socket(socket.socket(), server_hostname=host) as s: + s.connect((host, port)) + cert_der = s.getpeercert(binary_form=True) + return hashlib.sha256(cert_der).hexdigest() + +def verify_attestation(attestation): + """Verify the TDX quote - see 01-attestation for full verification""" + if not attestation.get("quote"): + raise Exception("No quote in attestation") + print(" Quote present (full verification requires trust-center)") + return True + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + endpoint = sys.argv[1] + parsed = urlparse(endpoint) + host = parsed.hostname + port = parsed.port or 443 + + print(f"Verifying: {endpoint}") + print() + + # Step 1: Get certificate fingerprint from TLS connection + print("1. Connecting and getting certificate...") + cert_fp = get_cert_fingerprint(host, port) + print(f" Certificate fingerprint: {cert_fp[:16]}...") + + # Step 2: Fetch attestation (ignoring cert validation) + print("2. Fetching attestation...") + resp = requests.get(f"{endpoint}/attestation", verify=False, timeout=10) + attestation = resp.json() + attested_fp = attestation["certFingerprint"] + print(f" Attested fingerprint: {attested_fp[:16]}...") + + # Step 3: Verify fingerprints match + print("3. Verifying certificate matches attestation...") + if cert_fp != attested_fp: + print(" FAILED: Certificate fingerprint mismatch!") + print(" This could indicate a MITM attack.") + sys.exit(1) + print(" Certificate fingerprint matches attestation") + + # Step 4: Verify the attestation itself + print("4. Verifying attestation...") + verify_attestation(attestation) + + print() + print("=" * 60) + print("SUCCESS: TLS certificate is bound to TEE attestation") + print("The connection is end-to-end secure regardless of relay.") + +if __name__ == "__main__": + main() diff --git a/tutorial/04-onchain-oracle/NOTES.md b/tutorial/04-onchain-oracle/NOTES.md new file mode 100644 index 0000000..9224614 --- /dev/null +++ b/tutorial/04-onchain-oracle/NOTES.md @@ -0,0 +1,63 @@ +# Implementation Notes: Self-Join Oracle + +Notes from implementing multi-node oracle deployment with shared signing keys. + +## Challenges Encountered + +### 1. CLI `--custom-app-id` Flag is Disabled + +The `--custom-app-id` flag exists in the CLI's help text but **the implementation is commented out** in the phala-cloud-cli source (`src/commands/deploy/index.ts` lines 126-162): + +```typescript +// TODO: remove customAppId for now +// if (customAppId) { ... } // ALL COMMENTED OUT +``` + +Every CLI deployment creates a new AppAuth contract, making self-join impossible via CLI alone. This is why we use direct API calls in `deploy_with_contract.py` and `deploy_replica.py`. + +### 2. API Key Decryption + +The CLI encrypts stored API keys (`~/.phala-cloud/api-key`) with AES-256-CBC using a machine-specific key: + +```python +parts = f"{hostname}|{platform}|{arch}|{cpu_model}|{username}" +key = hashlib.sha256(parts.encode()).digest() +``` + +**Gotcha**: Python `platform.machine()` returns `x86_64`, but Node.js `os.arch()` returns `x64`. The scripts include this conversion. + +### 3. allowAnyDevice Defaults to False + +First replica failed at "requesting app keys" because the AppAuth contract only allowed the original device (prod5). Required redeploying with `allowAnyDevice=true` via direct contract call. + +### 4. compose_hash Includes CVM Name + +Each CVM name produces a different compose_hash. Replicas fail with "Compose hash not allowed" until you call `addComposeHash()` on the AppAuth contract for each replica's hash. + +**Workaround**: After deploying a replica, get its compose_hash and register it: + +```python +from web3 import Web3 +w3 = Web3(Web3.HTTPProvider("https://mainnet.base.org")) +app_auth = w3.eth.contract(address=APP_AUTH_ADDRESS, abi=APP_AUTH_ABI) +tx = app_auth.functions.addComposeHash(compose_hash_bytes).build_transaction(...) +``` + +### 5. Image Version Matters + +Base KMS clusters showed "No available resources" with `v0.5.4-dev`. Using `dstack-0.5.4` (non-dev image) worked. + +## Future CLI Improvements Needed + +1. Re-enable `--custom-app-id` flag +2. Add `--allow-any-device` flag for AppAuth deployment +3. Add command to register additional compose hashes (`phala app add-compose-hash`) + +## Successful Deployment + +After resolving these issues, both oracles (prod5 and prod9) share identical: +- appId: `5a367973f645a11328d5b80fc226e3cb7436f78e` +- signerAddress: `0x7B83657880051cD6782E1D7fFFf3e6bd54f06853` +- derivedPubkey: `0x0323c9da9e831c9a677a92597a95c40bd7e7fe723d215763ef70874ae5dd660404` + +Both produce identical signatures for the same input, enabling oracle redundancy. diff --git a/tutorial/04-onchain-oracle/README.md b/tutorial/04-onchain-oracle/README.md new file mode 100644 index 0000000..c0611d1 --- /dev/null +++ b/tutorial/04-onchain-oracle/README.md @@ -0,0 +1,219 @@ +# Tutorial 04: On-Chain Oracle with AppAuth + +Controlled multi-node deployment and custom authorization contracts. + +## Prerequisites + +Complete [02-kms-and-signing](../02-kms-and-signing) first. That tutorial covers: +- Signature chain verification +- Basic multi-node with `allowAnyDevice=true` + +This tutorial covers **controlled** multi-node setups where the owner explicitly approves devices. + +## Understanding AppAuth + +Every dstack app has an **AppAuth contract** on Base. When a TEE requests keys, KMS calls your AppAuth's `isAppAllowed()` to decide. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DstackKms Contract (Base) │ +│ │ +│ registerApp(address) ← registers your AppAuth │ +│ isAppAllowed(bootInfo) → delegates to your contract │ +└─────────────────────────────────────────────────────────────┘ + │ + │ calls IAppAuth(appId).isAppAllowed(bootInfo) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Your AppAuth Contract (DstackApp) │ +│ │ +│ owner: 0x... ← can add devices/hashes │ +│ allowedDeviceIds[...] │ +│ allowedComposeHashes[...] │ +│ allowAnyDevice: true/false │ +│ │ +│ isAppAllowed(bootInfo) → checks whitelist │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key insight:** The private key you deploy with becomes the **owner** of the AppAuth contract. The owner controls which devices and compose hashes are allowed. + +## Deployment Options + +### Option 1: Single Device (CLI Default) + +```bash +phala deploy -n my-oracle -c docker-compose.yaml \ + --kms-id kms-base-prod5 \ + --private-key "$PRIVATE_KEY" +``` + +Creates AppAuth with: +- `owner` = address derived from PRIVATE_KEY +- `allowAnyDevice = false` +- `allowedDeviceIds[thisDevice] = true` +- `allowedComposeHashes[thisHash] = true` + +### Option 2: Owner Adds Devices (Controlled Multi-Node) + +Deploy first node, then the owner whitelists additional devices: + +```bash +# Step 1: Deploy first node +phala deploy -n my-oracle -c docker-compose.yaml \ + --kms-id kms-base-prod5 \ + --private-key "$PRIVATE_KEY" + +# Step 2: Add second device +cast send $APP_AUTH_ADDRESS "addDevice(bytes32)" $DEVICE_ID_2 \ + --private-key "$PRIVATE_KEY" --rpc-url https://mainnet.base.org + +# Step 3: Deploy second node +python3 deploy_replica.py +``` + +### Option 3: allowAnyDevice + +See [02-kms-and-signing](../02-kms-and-signing#multi-node-deployment) for the simpler `allowAnyDevice=true` approach. + +### Option 4: Custom AppAuth Contract + +Deploy your own contract implementing `IAppAuth`: + +```solidity +interface IAppAuth { + function isAppAllowed(AppBootInfo calldata bootInfo) + external view returns (bool isAllowed, string memory reason); +} +``` + +Then register it: +```solidity +DstackKms(KMS_ADDRESS).registerApp(yourContract); +``` + +**Examples:** NFT-gated, DAO-controlled, time-locked, multi-sig. See [08-extending-appauth](../08-extending-appauth). + +## DstackApp Owner Functions + +```solidity +function addDevice(bytes32 deviceId) external onlyOwner; +function removeDevice(bytes32 deviceId) external onlyOwner; +function addComposeHash(bytes32 composeHash) external onlyOwner; +function removeComposeHash(bytes32 composeHash) external onlyOwner; +function setAllowAnyDevice(bool allow) external onlyOwner; +function disableUpgrades() external onlyOwner; // Permanent! +``` + +## On-Chain Verification Contract + +`TeeOracle.sol` verifies the signature chain from [02-kms-and-signing](../02-kms-and-signing) on-chain: + +```solidity +function verify( + bytes32 messageHash, + bytes calldata messageSignature, + bytes calldata appSignature, + bytes calldata kmsSignature, + bytes calldata derivedCompressedPubkey, + bytes calldata appCompressedPubkey, + string calldata purpose +) public view returns (bool isValid) +``` + +## Test with Anvil + +Test the full on-chain verification locally: + +```bash +# Terminal 1: Start anvil +anvil & + +# Terminal 2: Start oracle (with simulator) +phala simulator start +docker compose run --rm -p 8080:8080 \ + -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock app + +# Terminal 3: Run anvil test +pip install -r requirements.txt +python3 test_anvil.py +``` + +Output: +``` +TeeOracle Anvil Test +============================================================ +Oracle: http://localhost:8080 +Anvil: http://localhost:8545 +KMS Root: 0x8f2cF602C9695b23130367ed78d8F557554de7C5 + +Anvil connected, block: 0 +Fetching from oracle... + Price: $87436 + App ID: ea549f02e1a25fabd1cb788380e033ec5461b2ff + App Pubkey: 02b85cceca0c02d878f0... +Deploying TeeOracle.sol... + Contract: 0x5FbDB2315678afecb367f032d93F642f64180aa3 +Calling verify() on-chain... + +============================================================ +SUCCESS: On-chain verification passed + - KMS signature verified + - App signature verified + - Message signature verified +``` + +## Files + +``` +04-onchain-oracle/ +├── TeeOracle.sol # On-chain signature verification +├── foundry.toml # Foundry config (via-ir for stack depth) +├── test_anvil.py # Test with local anvil +├── test_phalacloud.py # Test on Phala Cloud +├── deploy_replica.py # Deploy replica using existing appId +├── add_device.py # Add device to whitelist (Option 2) +├── add_compose_hash.py # Add compose hash to whitelist +├── docker-compose.yaml # Oracle app (same as 02) +├── requirements.txt +└── README.md +``` + +## Contract Addresses + +| Contract | Address | +|----------|---------| +| DstackKms (Base) | `0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C` | +| KMS Root (Simulator) | `0x8f2cF602C9695b23130367ed78d8F557554de7C5` | + +## IAppAuth Interface + +From [dstack/kms/auth-eth/contracts/IAppAuth.sol](https://github.com/dstack-tee/dstack/blob/main/kms/auth-eth/contracts/IAppAuth.sol): + +```solidity +struct AppBootInfo { + address appId; + bytes32 composeHash; + address instanceId; + bytes32 deviceId; + bytes32 mrAggregated; + bytes32 mrSystem; + bytes32 osImageHash; + string tcbStatus; + string[] advisoryIds; +} + +function isAppAllowed(AppBootInfo calldata bootInfo) + external view returns (bool isAllowed, string memory reason); +``` + +## Next Steps + +- [05-hardening-https](../05-hardening-https): Strengthen TLS verification +- [08-extending-appauth](../08-extending-appauth): Custom authorization contracts + +## References + +- [DstackKms.sol](https://github.com/dstack-tee/dstack/blob/main/kms/auth-eth/contracts/DstackKms.sol) +- [DstackApp.sol](https://github.com/dstack-tee/dstack/blob/main/kms/auth-eth/contracts/DstackApp.sol) +- [NOTES.md](NOTES.md) diff --git a/tutorial/04-onchain-oracle/TeeOracle.sol b/tutorial/04-onchain-oracle/TeeOracle.sol new file mode 100644 index 0000000..ac56ffe --- /dev/null +++ b/tutorial/04-onchain-oracle/TeeOracle.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/** + * @title TeeOracle + * @notice Verifies TEE oracle signatures via DStack signature chain + * @dev Based on SimpleDstackVerifier pattern from dstack-kms-simulator + */ +contract TeeOracle { + address public immutable kmsRoot; + bytes32 public immutable appId; + + event OracleMessageVerified(bytes32 indexed messageHash, address signer); + + constructor(address _kmsRoot, bytes32 _appId) { + kmsRoot = _kmsRoot; + appId = _appId; + } + + /** + * @notice Verify complete DStack signature chain + oracle message + * @param messageHash Hash of oracle data (e.g., price statement) + * @param messageSignature Oracle's signature over messageHash + * @param appSignature App key's signature over derived key + * @param kmsSignature KMS root's signature over app key + * @param derivedCompressedPubkey Derived key's compressed SEC1 pubkey (33 bytes) + * @param appCompressedPubkey App key's compressed SEC1 pubkey (33 bytes) + * @param purpose Key derivation purpose (e.g., "ethereum") + * @return isValid True if all signatures verify + */ + function verify( + bytes32 messageHash, + bytes calldata messageSignature, + bytes calldata appSignature, + bytes calldata kmsSignature, + bytes calldata derivedCompressedPubkey, + bytes calldata appCompressedPubkey, + string calldata purpose + ) public view returns (bool isValid) { + // Step 1: Verify app signature over derived key + string memory derivedHex = _bytesToHex(derivedCompressedPubkey); + string memory appMessage = string(abi.encodePacked(purpose, ":", derivedHex)); + bytes32 appMessageHash = keccak256(bytes(appMessage)); + address recoveredApp = _recoverSigner(appMessageHash, appSignature); + + // Step 2: Verify KMS signature over app key + bytes memory kmsMessage = abi.encodePacked( + "dstack-kms-issued:", + bytes20(appId), + appCompressedPubkey + ); + bytes32 kmsMessageHash = keccak256(kmsMessage); + address recoveredKms = _recoverSigner(kmsMessageHash, kmsSignature); + + if (recoveredKms != kmsRoot) return false; + + // Step 3: Verify oracle message signature + // Uses EIP-191 personal sign format + bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)); + address messageSigner = _recoverSigner(ethHash, messageSignature); + + // Verify message signer matches derived key + address derivedAddress = _compressedPubkeyToAddress(derivedCompressedPubkey); + if (messageSigner != derivedAddress) return false; + + // Verify app key matches recovered app signer + address appAddress = _compressedPubkeyToAddress(appCompressedPubkey); + if (recoveredApp != appAddress) return false; + + return true; + } + + /** + * @notice Verify and emit event (for on-chain record) + */ + function verifyAndLog( + bytes32 messageHash, + bytes calldata messageSignature, + bytes calldata appSignature, + bytes calldata kmsSignature, + bytes calldata derivedCompressedPubkey, + bytes calldata appCompressedPubkey, + string calldata purpose + ) external returns (bool isValid) { + isValid = verify(messageHash, messageSignature, appSignature, kmsSignature, + derivedCompressedPubkey, appCompressedPubkey, purpose); + if (isValid) { + address signer = _compressedPubkeyToAddress(derivedCompressedPubkey); + emit OracleMessageVerified(messageHash, signer); + } + } + + function _recoverSigner(bytes32 hash, bytes calldata sig) internal pure returns (address) { + require(sig.length == 65, "bad sig len"); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := calldataload(sig.offset) + s := calldataload(add(sig.offset, 32)) + v := byte(0, calldataload(add(sig.offset, 64))) + } + if (v < 27) v += 27; + return ecrecover(hash, v, r, s); + } + + function _compressedPubkeyToAddress(bytes calldata pubkey) internal view returns (address) { + require(pubkey.length == 33, "need compressed pubkey"); + // Decompress SEC1 compressed public key + uint8 prefix = uint8(pubkey[0]); + require(prefix == 0x02 || prefix == 0x03, "invalid prefix"); + + uint256 x; + assembly { + x := calldataload(add(pubkey.offset, 1)) + } + + // secp256k1 curve: y² = x³ + 7 + uint256 p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F; + uint256 y2 = addmod(mulmod(mulmod(x, x, p), x, p), 7, p); + uint256 y = _modExp(y2, (p + 1) / 4, p); + + // Check parity + if ((prefix == 0x02 && y % 2 != 0) || (prefix == 0x03 && y % 2 == 0)) { + y = p - y; + } + + // Hash uncompressed pubkey (without 0x04 prefix) + bytes32 hash = keccak256(abi.encodePacked(x, y)); + return address(uint160(uint256(hash))); + } + + function _modExp(uint256 base, uint256 exp, uint256 mod) internal view returns (uint256) { + // Use precompile at 0x05 for modular exponentiation + bytes memory input = abi.encodePacked( + uint256(32), uint256(32), uint256(32), + base, exp, mod + ); + bytes memory output = new bytes(32); + assembly { + if iszero(staticcall(gas(), 0x05, add(input, 32), 192, add(output, 32), 32)) { + revert(0, 0) + } + } + return abi.decode(output, (uint256)); + } + + function _bytesToHex(bytes calldata data) internal pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory str = new bytes(data.length * 2); + for (uint i = 0; i < data.length; i++) { + str[i*2] = alphabet[uint8(data[i] >> 4)]; + str[i*2+1] = alphabet[uint8(data[i] & 0x0f)]; + } + return string(str); + } +} diff --git a/tutorial/04-onchain-oracle/add_compose_hash.py b/tutorial/04-onchain-oracle/add_compose_hash.py new file mode 100644 index 0000000..8627b59 --- /dev/null +++ b/tutorial/04-onchain-oracle/add_compose_hash.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Add a compose hash to an AppAuth contract's whitelist. + +Usage: + python3 add_compose_hash.py + +The PRIVATE_KEY env var must be the owner of the AppAuth contract. +""" + +import os +import sys +from web3 import Web3 + +BASE_RPC = "https://mainnet.base.org" + +APP_AUTH_ABI = [{ + "inputs": [{"name": "composeHash", "type": "bytes32"}], + "name": "addComposeHash", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" +}, { + "inputs": [{"name": "composeHash", "type": "bytes32", "indexed": False}], + "name": "ComposeHashAdded", + "type": "event" +}, { + "inputs": [{"name": "", "type": "bytes32"}], + "name": "allowedComposeHashes", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function" +}] + +def main(): + if len(sys.argv) < 3: + print("Usage: python3 add_compose_hash.py ") + print("Example: python3 add_compose_hash.py 0x5a367973... 0xabcd1234...") + sys.exit(1) + + app_auth_address = sys.argv[1] + compose_hash = sys.argv[2] + + private_key = os.environ.get("PRIVATE_KEY") + if not private_key: + print("Set PRIVATE_KEY environment variable (must be owner of AppAuth)") + sys.exit(1) + + w3 = Web3(Web3.HTTPProvider(BASE_RPC)) + account = w3.eth.account.from_key(private_key) + + # Normalize and checksum address + if not app_auth_address.startswith("0x"): + app_auth_address = "0x" + app_auth_address + app_auth_address = w3.to_checksum_address(app_auth_address) + if not compose_hash.startswith("0x"): + compose_hash = "0x" + compose_hash + + # Ensure compose_hash is bytes32 (64 hex chars after 0x) + compose_hash_bytes = bytes.fromhex(compose_hash.replace("0x", "").zfill(64)) + + contract = w3.eth.contract(address=app_auth_address, abi=APP_AUTH_ABI) + + # Check if already allowed + is_allowed = contract.functions.allowedComposeHashes(compose_hash_bytes).call() + if is_allowed: + print(f"Compose hash {compose_hash} is already allowed") + sys.exit(0) + + print(f"Adding compose hash to AppAuth contract...") + print(f" AppAuth: {app_auth_address}") + print(f" ComposeHash: {compose_hash}") + print(f" Owner: {account.address}") + + tx = contract.functions.addComposeHash(compose_hash_bytes).build_transaction({ + 'from': account.address, + 'nonce': w3.eth.get_transaction_count(account.address), + 'gas': 100000, + 'gasPrice': w3.eth.gas_price + }) + + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + print(f" Transaction: {tx_hash.hex()}") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + if receipt.status == 1: + print(f" ✅ Compose hash added successfully") + else: + print(f" ❌ Transaction failed") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/tutorial/04-onchain-oracle/add_device.py b/tutorial/04-onchain-oracle/add_device.py new file mode 100644 index 0000000..a4693d8 --- /dev/null +++ b/tutorial/04-onchain-oracle/add_device.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Add a device to an AppAuth contract's whitelist. + +Usage: + python3 add_device.py + +The PRIVATE_KEY env var must be the owner of the AppAuth contract. +""" + +import os +import sys +from web3 import Web3 + +BASE_RPC = "https://mainnet.base.org" + +APP_AUTH_ABI = [{ + "inputs": [{"name": "deviceId", "type": "bytes32"}], + "name": "addDevice", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" +}, { + "inputs": [{"name": "deviceId", "type": "bytes32", "indexed": False}], + "name": "DeviceAdded", + "type": "event" +}, { + "inputs": [{"name": "", "type": "bytes32"}], + "name": "allowedDeviceIds", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function" +}] + +def main(): + if len(sys.argv) < 3: + print("Usage: python3 add_device.py ") + print("Example: python3 add_device.py 0x5a367973... 0xabcd1234...") + sys.exit(1) + + app_auth_address = sys.argv[1] + device_id = sys.argv[2] + + private_key = os.environ.get("PRIVATE_KEY") + if not private_key: + print("Set PRIVATE_KEY environment variable (must be owner of AppAuth)") + sys.exit(1) + + w3 = Web3(Web3.HTTPProvider(BASE_RPC)) + account = w3.eth.account.from_key(private_key) + + # Normalize and checksum address + if not app_auth_address.startswith("0x"): + app_auth_address = "0x" + app_auth_address + app_auth_address = w3.to_checksum_address(app_auth_address) + if not device_id.startswith("0x"): + device_id = "0x" + device_id + + # Ensure device_id is bytes32 (64 hex chars after 0x) + device_id_bytes = bytes.fromhex(device_id.replace("0x", "").zfill(64)) + + contract = w3.eth.contract(address=app_auth_address, abi=APP_AUTH_ABI) + + # Check if already allowed + is_allowed = contract.functions.allowedDeviceIds(device_id_bytes).call() + if is_allowed: + print(f"Device {device_id} is already allowed") + sys.exit(0) + + print(f"Adding device to AppAuth contract...") + print(f" AppAuth: {app_auth_address}") + print(f" Device: {device_id}") + print(f" Owner: {account.address}") + + tx = contract.functions.addDevice(device_id_bytes).build_transaction({ + 'from': account.address, + 'nonce': w3.eth.get_transaction_count(account.address), + 'gas': 100000, + 'gasPrice': w3.eth.gas_price + }) + + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + print(f" Transaction: {tx_hash.hex()}") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + if receipt.status == 1: + print(f" ✅ Device added successfully") + else: + print(f" ❌ Transaction failed") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/tutorial/04-onchain-oracle/deploy_replica.py b/tutorial/04-onchain-oracle/deploy_replica.py new file mode 100644 index 0000000..10bfb40 --- /dev/null +++ b/tutorial/04-onchain-oracle/deploy_replica.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Deploy a CVM replica using existing appId via direct API calls. +This bypasses the CLI's limitation of always deploying a new AppAuth contract. +""" + +import os +import json +import platform +import hashlib +import requests +from pathlib import Path +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +CLOUD_API = "https://cloud-api.phala.network/api/v1" + +def get_machine_key(): + """Generate machine-specific key like the CLI does""" + import subprocess + hostname = platform.node() + plat = platform.system().lower() + # Node.js os.arch() returns 'x64' not 'x86_64' + arch = platform.machine() + if arch == "x86_64": + arch = "x64" + try: + cpu_model = subprocess.check_output("cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d: -f2", shell=True).decode().strip() + except: + cpu_model = "" + username = os.environ.get("USER", "") + + parts = f"{hostname}|{plat}|{arch}|{cpu_model}|{username}" + return hashlib.sha256(parts.encode()).digest() + +def decrypt_api_key(): + """Decrypt the stored API key""" + key_file = Path.home() / ".phala-cloud" / "api-key" + if not key_file.exists(): + return None + + encrypted = key_file.read_text().strip() + parts = encrypted.split(":") + if len(parts) != 2: + return None + + iv = bytes.fromhex(parts[0]) + ciphertext = bytes.fromhex(parts[1]) + key = get_machine_key()[:32] + + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + padded = decryptor.update(ciphertext) + decryptor.finalize() + + # Remove PKCS7 padding + pad_len = padded[-1] + return padded[:-pad_len].decode() + +API_KEY = os.environ.get("PHALA_CLOUD_API_KEY") or decrypt_api_key() + +# First CVM's info - UPDATE THIS with your appId from the first deployment +EXISTING_APP_ID = "c96d55b03ede924c89154348be9dcffd52304af0" +EXISTING_APP_AUTH_ADDRESS = "0x" + EXISTING_APP_ID # For on-chain KMS, appId IS the contract address + +# Target node for replica +TARGET_NODE_ID = 18 # prod9 + +# Replica name - change this for each replica +REPLICA_NAME = "tee-oracle-option2-replica" + +def get_headers(): + return { + "X-API-Key": API_KEY, + "Content-Type": "application/json" + } + +def read_compose_file(): + with open("docker-compose.yaml", "r") as f: + return f.read() + +def provision_cvm(name: str, compose_content: str, node_id: int, kms_id: str): + """Step 1: Provision CVM resources""" + payload = { + "name": name, + "image": "dstack-0.5.4", + "vcpu": 1, + "memory": 2048, + "disk_size": 20, + "teepod_id": node_id, + "kms_id": kms_id, + "compose_file": { + "docker_compose_file": compose_content, + "allowed_envs": [], + "features": ["kms"], + "kms_enabled": True, + "manifest_version": 2, + "name": name, + "public_logs": True, + "public_sysinfo": True, + "tproxy_enabled": False + }, + "env_keys": [], + "listed": True, + "instance_type": "tdx.small" + } + + resp = requests.post(f"{CLOUD_API}/cvms/provision", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def create_cvm_with_existing_app(app_id: str, compose_hash: str, app_auth_address: str, deployer_address: str): + """Step 2: Create CVM using existing appId (skip contract deployment)""" + payload = { + "app_id": app_id, + "compose_hash": compose_hash, + "encrypted_env": "", + "app_auth_contract_address": app_auth_address, + "deployer_address": deployer_address + } + + resp = requests.post(f"{CLOUD_API}/cvms", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def main(): + if not API_KEY: + print("Set PHALA_CLOUD_API_KEY environment variable") + return + + print("=" * 60) + print("Deploying CVM Replica with Existing App ID") + print("=" * 60) + print(f"Existing App ID: {EXISTING_APP_ID}") + print(f"Target Node: prod9 (id={TARGET_NODE_ID})") + print() + + # Step 1: Provision + print("Step 1: Provisioning CVM resources...") + compose_content = read_compose_file() + provision_result = provision_cvm( + name=REPLICA_NAME, + compose_content=compose_content, + node_id=TARGET_NODE_ID, + kms_id="kms-base-prod9" + ) + + print(f" Compose Hash: {provision_result.get('compose_hash', 'N/A')}") + print(f" Device ID: {provision_result.get('device_id', 'N/A')}") + + # Step 2: Create CVM with existing app_id + print("\nStep 2: Creating CVM with existing App ID...") + print(" (Skipping contract deployment - using existing AppAuth)") + + # Get deployer address from first CVM or use a known one + # For allowAnyDevice=true, the deployer doesn't matter for auth + deployer = "0x0000000000000000000000000000000000000000" # placeholder + + try: + create_result = create_cvm_with_existing_app( + app_id=EXISTING_APP_ID, + compose_hash=provision_result["compose_hash"], + app_auth_address=EXISTING_APP_AUTH_ADDRESS, + deployer_address=deployer + ) + + print("\nCVM Created!") + print(json.dumps(create_result, indent=2)) + + except requests.exceptions.HTTPError as e: + print(f"\nError: {e}") + print(f"Response: {e.response.text}") + print("\nThis might fail if:") + print(" 1. The AppAuth contract wasn't deployed with allowAnyDevice=true") + print(" 2. The compose_hash isn't registered in the contract") + print(" 3. The device_id for prod9 isn't whitelisted") + +if __name__ == "__main__": + main() diff --git a/tutorial/04-onchain-oracle/deploy_with_contract.py b/tutorial/04-onchain-oracle/deploy_with_contract.py new file mode 100644 index 0000000..79f6c5a --- /dev/null +++ b/tutorial/04-onchain-oracle/deploy_with_contract.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Deploy CVM with allowAnyDevice=true for self-join support. + +This script: +1. Provisions a CVM +2. Deploys AppAuth contract with allowAnyDevice=true +3. Creates the CVM + +For replicas, use deploy_replica.py with the appId from this deployment. +""" + +import os +import json +import platform +import hashlib +import requests +from pathlib import Path +from web3 import Web3 +from eth_account import Account + +# Decrypt API key (same as deploy_replica.py) +def get_machine_key(): + import subprocess + hostname = platform.node() + plat = platform.system().lower() + arch = platform.machine() + if arch == "x86_64": + arch = "x64" + try: + cpu_model = subprocess.check_output("cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d: -f2", shell=True).decode().strip() + except: + cpu_model = "" + username = os.environ.get("USER", "") + parts = f"{hostname}|{plat}|{arch}|{cpu_model}|{username}" + return hashlib.sha256(parts.encode()).digest() + +def decrypt_api_key(): + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + key_file = Path.home() / ".phala-cloud" / "api-key" + if not key_file.exists(): + return None + encrypted = key_file.read_text().strip() + parts = encrypted.split(":") + if len(parts) != 2: + return None + iv = bytes.fromhex(parts[0]) + ciphertext = bytes.fromhex(parts[1]) + key = get_machine_key()[:32] + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + padded = decryptor.update(ciphertext) + decryptor.finalize() + pad_len = padded[-1] + return padded[:-pad_len].decode() + +CLOUD_API = "https://cloud-api.phala.network/api/v1" +API_KEY = os.environ.get("PHALA_CLOUD_API_KEY") or decrypt_api_key() +PRIVATE_KEY = os.environ.get("PRIVATE_KEY") + +# Base mainnet +BASE_RPC = "https://mainnet.base.org" +KMS_CONTRACT = "0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C" + +# KMS Factory ABI for deploying AppAuth with allowAnyDevice +KMS_FACTORY_ABI = [{ + "inputs": [ + {"name": "deployer", "type": "address"}, + {"name": "disableUpgrades", "type": "bool"}, + {"name": "allowAnyDevice", "type": "bool"}, + {"name": "deviceId", "type": "bytes32"}, + {"name": "composeHash", "type": "bytes32"} + ], + "name": "deployAndRegisterApp", + "outputs": [{"name": "", "type": "address"}], + "stateMutability": "nonpayable", + "type": "function" +}, { + "inputs": [ + {"name": "appId", "type": "address", "indexed": True}, + {"name": "deployer", "type": "address", "indexed": True} + ], + "name": "AppDeployedViaFactory", + "type": "event" +}] + +def get_headers(): + return {"X-API-Key": API_KEY, "Content-Type": "application/json"} + +def read_compose_file(): + with open("docker-compose.yaml", "r") as f: + return f.read() + +def provision_cvm(name: str, compose_content: str, node_id: int, kms_id: str): + payload = { + "name": name, + "image": "dstack-0.5.4", + "vcpu": 1, + "memory": 2048, + "disk_size": 20, + "teepod_id": node_id, + "kms_id": kms_id, + "compose_file": { + "docker_compose_file": compose_content, + "allowed_envs": [], + "features": ["kms"], + "kms_enabled": True, + "manifest_version": 2, + "name": name, + "public_logs": True, + "public_sysinfo": True, + "tproxy_enabled": False + }, + "env_keys": [], + "listed": True, + "instance_type": "tdx.small" + } + resp = requests.post(f"{CLOUD_API}/cvms/provision", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def deploy_app_auth_any_device(compose_hash: str): + """Deploy AppAuth contract with allowAnyDevice=true""" + w3 = Web3(Web3.HTTPProvider(BASE_RPC)) + account = Account.from_key(PRIVATE_KEY) + + contract = w3.eth.contract(address=KMS_CONTRACT, abi=KMS_FACTORY_ABI) + + # Zero device ID + allowAnyDevice=true + device_id = bytes(32) + compose_hash_bytes = bytes.fromhex(compose_hash.replace("0x", "")) + + tx = contract.functions.deployAndRegisterApp( + account.address, # deployer + False, # disableUpgrades + True, # allowAnyDevice = TRUE! + device_id, # zero device ID + compose_hash_bytes # compose hash + ).build_transaction({ + 'from': account.address, + 'nonce': w3.eth.get_transaction_count(account.address), + 'gas': 500000, + 'gasPrice': w3.eth.gas_price + }) + + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + print(f" Transaction: {tx_hash.hex()}") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + # Parse AppDeployedViaFactory event to get appId + logs = contract.events.AppDeployedViaFactory().process_receipt(receipt) + if not logs: + raise Exception("No AppDeployedViaFactory event found") + + app_id = logs[0]['args']['appId'] + return app_id, account.address + +def create_cvm(app_id: str, compose_hash: str, app_auth_address: str, deployer: str): + payload = { + "app_id": app_id.lower().replace("0x", ""), + "compose_hash": compose_hash, + "encrypted_env": "", + "app_auth_contract_address": app_auth_address, + "deployer_address": deployer + } + resp = requests.post(f"{CLOUD_API}/cvms", headers=get_headers(), json=payload) + resp.raise_for_status() + return resp.json() + +def main(): + if not API_KEY: + print("Set PHALA_CLOUD_API_KEY or have ~/.phala-cloud/api-key") + return + if not PRIVATE_KEY: + print("Set PRIVATE_KEY environment variable (for Base contract deployment)") + return + + print("=" * 60) + print("Deploying CVM with allowAnyDevice=true") + print("=" * 60) + + # Step 1: Provision + print("\nStep 1: Provisioning CVM resources on prod5...") + compose_content = read_compose_file() + provision = provision_cvm("tee-oracle-any", compose_content, 26, "kms-base-prod5") + compose_hash = provision["compose_hash"] + print(f" Compose Hash: {compose_hash}") + + # Step 2: Deploy AppAuth with allowAnyDevice=true + print("\nStep 2: Deploying AppAuth contract with allowAnyDevice=true...") + app_id, deployer = deploy_app_auth_any_device(compose_hash) + print(f" App ID: {app_id}") + print(f" Deployer: {deployer}") + + # Step 3: Create CVM + print("\nStep 3: Creating CVM...") + result = create_cvm(app_id, compose_hash, app_id, deployer) + print(f" CVM ID: {result.get('id')}") + print(f" Status: {result.get('status')}") + + print("\n" + "=" * 60) + print("SUCCESS! Save this for deploying replicas:") + print(f" APP_ID={app_id}") + print(f" COMPOSE_HASH={compose_hash}") + print("=" * 60) + +if __name__ == "__main__": + main() diff --git a/tutorial/04-onchain-oracle/docker-compose.yaml b/tutorial/04-onchain-oracle/docker-compose.yaml new file mode 100644 index 0000000..d8b8912 --- /dev/null +++ b/tutorial/04-onchain-oracle/docker-compose.yaml @@ -0,0 +1,107 @@ +services: + app: + build: + context: . + dockerfile_inline: | + FROM node:18-slim + WORKDIR /app + RUN npm init -y && npm install @phala/dstack-sdk viem + RUN cat > index.mjs <<'SCRIPT' + import { DstackClient } from "@phala/dstack-sdk" + import { createServer } from "http" + import https from "https" + import { privateKeyToAccount } from "viem/accounts" + import { keccak256, encodePacked, toHex, hexToBytes } from "viem" + import { secp256k1 } from "@noble/curves/secp256k1" + + const client = new DstackClient() + const API_URL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" + + async function getOracleKey() { + const result = await client.getKey("/oracle", "ethereum") + const privateKey = "0x" + Buffer.from(result.key).toString("hex").slice(0, 64) + const account = privateKeyToAccount(privateKey) + + // Get public keys for signature chain verification + const derivedPrivBytes = hexToBytes(privateKey) + const derivedPubkey = secp256k1.getPublicKey(derivedPrivBytes.slice(0, 32), true) + + const toHexStr = (x) => typeof x === 'string' ? x : '0x' + Buffer.from(x).toString('hex') + return { + account, + derivedPubkey: toHex(derivedPubkey), + appSignature: toHexStr(result.signature_chain[0]), + kmsSignature: toHexStr(result.signature_chain[1]) + } + } + + function fetchWithTls(url) { + return new Promise((resolve, reject) => { + https.get(url, res => { + const cert = res.socket.getPeerCertificate() + let body = "" + res.on("data", c => body += c) + res.on("end", () => resolve({ + data: JSON.parse(body), + tlsFingerprint: cert.fingerprint256 + })) + }).on("error", reject) + }) + } + + async function getSignedPrice(oracle) { + const { data, tlsFingerprint } = await fetchWithTls(API_URL) + + const statement = { + source: "api.coingecko.com", + price: data.bitcoin.usd, + tlsFingerprint, + timestamp: Date.now() + } + + const messageHash = keccak256( + encodePacked( + ["string", "uint256", "uint256", "string"], + [statement.source, BigInt(Math.floor(statement.price * 100)), BigInt(statement.timestamp), statement.tlsFingerprint] + ) + ) + + const signature = await oracle.account.signMessage({ message: { raw: messageHash } }) + + return { + statement, + messageHash, + signature, + signatureChain: { + derivedPubkey: oracle.derivedPubkey, + appSignature: oracle.appSignature, + kmsSignature: oracle.kmsSignature + }, + signerAddress: oracle.account.address + } + } + + const oracle = await getOracleKey() + const info = await client.info() + console.log("Oracle signer:", oracle.account.address) + console.log("App ID:", info.app_id) + + createServer(async (req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }) + if (req.url === "/price") { + res.end(JSON.stringify(await getSignedPrice(oracle), null, 2)) + } else { + res.end(JSON.stringify({ + endpoints: ["/", "/price"], + appId: info.app_id, + signerAddress: oracle.account.address, + derivedPubkey: oracle.derivedPubkey + }, null, 2)) + } + }).listen(8080, () => console.log("Oracle at http://localhost:8080")) + SCRIPT + CMD ["node", "index.mjs"] + ports: + - "8080:8080" + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock diff --git a/tutorial/04-onchain-oracle/foundry.toml b/tutorial/04-onchain-oracle/foundry.toml new file mode 100644 index 0000000..300dab4 --- /dev/null +++ b/tutorial/04-onchain-oracle/foundry.toml @@ -0,0 +1,11 @@ +[profile.default] +src = "." +out = "out" +libs = [] +via_ir = true +optimizer = true +optimizer_runs = 200 + +# Explicitly disable dry run +[rpc_endpoints] +anvil = "http://localhost:8545" diff --git a/tutorial/04-onchain-oracle/requirements.txt b/tutorial/04-onchain-oracle/requirements.txt new file mode 100644 index 0000000..1f5243f --- /dev/null +++ b/tutorial/04-onchain-oracle/requirements.txt @@ -0,0 +1,5 @@ +eth-account>=0.10.0 +eth-keys>=0.4.0 +eth-utils>=2.0.0 +requests>=2.28.0 +web3>=6.0.0 diff --git a/tutorial/04-onchain-oracle/test_anvil.py b/tutorial/04-onchain-oracle/test_anvil.py new file mode 100644 index 0000000..acabeda --- /dev/null +++ b/tutorial/04-onchain-oracle/test_anvil.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Test TeeOracle.sol signature verification on local anvil. + +Prerequisites: + pip install -r requirements.txt + phala simulator start + docker compose up (oracle on localhost:8080) + anvil & (local ethereum node on localhost:8545) + +Usage: + python3 test_anvil.py +""" + +import subprocess +import requests +from eth_account import Account +from eth_utils import keccak +from eth_keys import keys +from web3 import Web3 + +ORACLE_URL = "http://localhost:8080" +ANVIL_RPC = "http://localhost:8545" +ANVIL_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # anvil default + +# Simulator KMS root +KMS_ROOT = "0x8f2cF602C9695b23130367ed78d8F557554de7C5" + +def deploy_contract(app_id: str) -> str: + """Deploy TeeOracle.sol using forge""" + print("Deploying TeeOracle.sol...") + + # Convert app_id to bytes32 format (pad on right, not left - contract uses bytes20(appId)) + app_id_bytes32 = "0x" + app_id.replace("0x", "").ljust(64, '0') + + result = subprocess.run([ + "forge", "create", "TeeOracle.sol:TeeOracle", + "--broadcast", + "--rpc-url", ANVIL_RPC, + "--private-key", ANVIL_PRIVATE_KEY, + "--constructor-args", KMS_ROOT, app_id_bytes32 + ], capture_output=True, text=True, cwd="/home/amiller/projects/dstack/dstack-examples/tutorial/04-onchain-oracle") + + if result.returncode != 0: + print(f"Deploy failed: {result.stderr}") + raise Exception("Contract deployment failed") + + # Parse deployed address from output + for line in result.stdout.split("\n"): + if "Deployed to:" in line: + addr = line.split("Deployed to:")[1].strip() + print(f" Contract: {addr}") + return addr + + raise Exception("Could not parse deployed address") + +def fetch_oracle(): + """Fetch signed price from oracle""" + print("Fetching from oracle...") + resp = requests.get(f"{ORACLE_URL}/price", timeout=10) + resp.raise_for_status() + + info = requests.get(f"{ORACLE_URL}/", timeout=10).json() + data = resp.json() + data["appId"] = info["appId"] + return data + +def recover_app_pubkey(data) -> bytes: + """Recover compressed app pubkey from app signature""" + chain = data["signatureChain"] + derived_pubkey = bytes.fromhex(chain["derivedPubkey"].replace("0x", "")) + app_signature = bytes.fromhex(chain["appSignature"].replace("0x", "")) + + purpose = "ethereum" + app_message = f"{purpose}:{derived_pubkey.hex()}" + app_message_hash = keccak(text=app_message) + + app_sig_obj = keys.Signature(app_signature) + app_pubkey = app_sig_obj.recover_public_key_from_msg_hash(app_message_hash) + return app_pubkey.to_compressed_bytes() + +def test_verify(contract_addr: str, data: dict, app_pubkey: bytes): + """Call verify() on the deployed contract""" + print("Calling verify() on-chain...") + + w3 = Web3(Web3.HTTPProvider(ANVIL_RPC)) + + # TeeOracle ABI (just verify function) + abi = [{ + "inputs": [ + {"name": "messageHash", "type": "bytes32"}, + {"name": "messageSignature", "type": "bytes"}, + {"name": "appSignature", "type": "bytes"}, + {"name": "kmsSignature", "type": "bytes"}, + {"name": "derivedCompressedPubkey", "type": "bytes"}, + {"name": "appCompressedPubkey", "type": "bytes"}, + {"name": "purpose", "type": "string"} + ], + "name": "verify", + "outputs": [{"name": "isValid", "type": "bool"}], + "stateMutability": "view", + "type": "function" + }] + + contract = w3.eth.contract(address=contract_addr, abi=abi) + + chain = data["signatureChain"] + + result = contract.functions.verify( + bytes.fromhex(data["messageHash"].replace("0x", "")), + bytes.fromhex(data["signature"].replace("0x", "")), + bytes.fromhex(chain["appSignature"].replace("0x", "")), + bytes.fromhex(chain["kmsSignature"].replace("0x", "")), + bytes.fromhex(chain["derivedPubkey"].replace("0x", "")), + app_pubkey, + "ethereum" + ).call() + + return result + +def main(): + print("TeeOracle Anvil Test") + print("=" * 60) + print(f"Oracle: {ORACLE_URL}") + print(f"Anvil: {ANVIL_RPC}") + print(f"KMS Root: {KMS_ROOT}") + print() + + # Check anvil is running + w3 = Web3(Web3.HTTPProvider(ANVIL_RPC)) + if not w3.is_connected(): + print("Anvil not running. Start with: anvil &") + return False + print(f"Anvil connected, block: {w3.eth.block_number}") + + # Fetch oracle data + try: + data = fetch_oracle() + print(f" Price: ${data['statement']['price']}") + print(f" App ID: {data['appId']}") + except Exception as e: + print(f"Failed to fetch oracle: {e}") + return False + + # Recover app pubkey + app_pubkey = recover_app_pubkey(data) + print(f" App Pubkey: {app_pubkey.hex()[:20]}...") + + # Deploy contract + try: + contract_addr = deploy_contract(data["appId"]) + except Exception as e: + print(f"Deploy failed: {e}") + return False + + # Test verification + try: + is_valid = test_verify(contract_addr, data, app_pubkey) + except Exception as e: + print(f"Verify failed: {e}") + return False + + print() + print("=" * 60) + if is_valid: + print("SUCCESS: On-chain verification passed") + print(" - KMS signature verified") + print(" - App signature verified") + print(" - Message signature verified") + return True + else: + print("FAILED: On-chain verification returned false") + return False + +if __name__ == "__main__": + exit(0 if main() else 1) diff --git a/tutorial/04-onchain-oracle/test_local.py b/tutorial/04-onchain-oracle/test_local.py new file mode 100644 index 0000000..5f9f1cb --- /dev/null +++ b/tutorial/04-onchain-oracle/test_local.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Local test for TEE Oracle signature chain verification. + +Prerequisites: + pip install eth-account eth-keys requests web3 + phala simulator start + docker compose up (oracle running on localhost:8080) + +Optionally for on-chain testing: + anvil & (local ethereum node) + forge script to deploy TeeOracle.sol +""" + +import json +import requests +from eth_account import Account +from eth_utils import keccak +from eth_keys import keys + +# Simulator KMS root address (recovered from k256_signature in appkeys.json) +# Note: k256_key in appkeys.json is the APP key, not KMS root +# The KMS root is whoever signed that app key +KMS_ROOT_ADDRESS = "0x8f2cF602C9695b23130367ed78d8F557554de7C5" + +ORACLE_URL = "http://localhost:8080" + +def fetch_oracle_price(): + """Fetch signed price from oracle""" + print("📡 Fetching from oracle...") + resp = requests.get(f"{ORACLE_URL}/price", timeout=10) + resp.raise_for_status() + return resp.json() + +def verify_signature_chain(data, expected_kms_root): + """ + Verify the complete signature chain: + 1. App key signed derived key + 2. KMS root signed app key + 3. Derived key signed the message + """ + print("\n🔐 Verifying Signature Chain") + print("=" * 50) + + chain = data["signatureChain"] + derived_pubkey = bytes.fromhex(chain["derivedPubkey"].replace("0x", "")) + app_signature = bytes.fromhex(chain["appSignature"].replace("0x", "")) + kms_signature = bytes.fromhex(chain["kmsSignature"].replace("0x", "")) + message_hash = bytes.fromhex(data["messageHash"].replace("0x", "")) + message_signature = bytes.fromhex(data["signature"].replace("0x", "")) + + # Get app_id from oracle info + info_resp = requests.get(f"{ORACLE_URL}/", timeout=10) + app_id = info_resp.json()["appId"] + app_id_bytes = bytes.fromhex(app_id.replace("0x", "")) + + print(f"App ID: {app_id}") + print(f"Derived Pubkey: {derived_pubkey.hex()[:20]}...") + print(f"Expected KMS Root: {expected_kms_root}") + + # Step 1: Verify app signature over derived key + # Message format: "{purpose}:{derived_pubkey_hex}" + purpose = "ethereum" + app_message = f"{purpose}:{derived_pubkey.hex()}" + app_message_hash = keccak(text=app_message) + + # Recover app key from signature + app_sig_obj = keys.Signature(app_signature) + app_pubkey = app_sig_obj.recover_public_key_from_msg_hash(app_message_hash) + app_pubkey_compressed = app_pubkey.to_compressed_bytes() + app_address = app_pubkey.to_checksum_address() + + print(f"\n✓ Step 1: App signature") + print(f" App Address: {app_address}") + + # Step 2: Verify KMS signature over app key + # Message format: "dstack-kms-issued:" + app_id + app_pubkey_sec1 + kms_message = b"dstack-kms-issued:" + app_id_bytes + app_pubkey_compressed + kms_message_hash = keccak(kms_message) + + kms_signer = Account._recover_hash(kms_message_hash, signature=kms_signature) + + print(f"\n✓ Step 2: KMS signature") + print(f" Recovered KMS: {kms_signer}") + print(f" Expected KMS: {expected_kms_root}") + + if kms_signer.lower() != expected_kms_root.lower(): + print(" ❌ KMS signature FAILED!") + return False + + print(" ✅ KMS signature verified!") + + # Step 3: Verify message signature + # Uses EIP-191 personal sign + eth_message = b"\x19Ethereum Signed Message:\n32" + message_hash + eth_hash = keccak(eth_message) + + message_signer = Account._recover_hash(eth_hash, signature=message_signature) + + # Get expected signer from derived pubkey + derived_key_obj = keys.PublicKey.from_compressed_bytes(derived_pubkey) + expected_signer = derived_key_obj.to_checksum_address() + + print(f"\n✓ Step 3: Message signature") + print(f" Recovered signer: {message_signer}") + print(f" Expected signer: {expected_signer}") + + if message_signer.lower() != expected_signer.lower(): + print(" ❌ Message signature FAILED!") + return False + + print(" ✅ Message signature verified!") + + return True + +def main(): + print("🚀 TEE Oracle Local Test") + print("=" * 60) + print(f"Oracle URL: {ORACLE_URL}") + print(f"KMS Root: {KMS_ROOT_ADDRESS}") + print() + + # Fetch oracle data + try: + data = fetch_oracle_price() + print(f"✅ Got price: ${data['statement']['price']}") + print(f" Source: {data['statement']['source']}") + print(f" TLS Fingerprint: {data['statement']['tlsFingerprint'][:30]}...") + except Exception as e: + print(f"❌ Failed to fetch oracle: {e}") + print("\nMake sure the oracle is running:") + print(" docker compose run --rm -p 8080:8080 \\") + print(" -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock app") + return False + + # Verify signature chain + if verify_signature_chain(data, KMS_ROOT_ADDRESS): + print("\n" + "=" * 60) + print("🎉 All verifications passed!") + print(" ✅ KMS signed the app key") + print(" ✅ App key signed the derived key") + print(" ✅ Derived key signed the oracle message") + print("\nThis oracle output can be verified on-chain.") + return True + else: + print("\n❌ Verification failed!") + return False + +if __name__ == "__main__": + main() diff --git a/tutorial/04-onchain-oracle/test_phalacloud.py b/tutorial/04-onchain-oracle/test_phalacloud.py new file mode 100644 index 0000000..0ffe653 --- /dev/null +++ b/tutorial/04-onchain-oracle/test_phalacloud.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +Production test for TEE Oracle on Phala Cloud. + +Prerequisites: + pip install eth-account eth-keys requests web3 + +Usage: + # Set your CVM URL after deploying + export ORACLE_URL="https://-8080.dstack-base-prod7.phala.network" + python test_production.py + + # Or pass as argument + python test_production.py https:// +""" + +import json +import os +import sys +import requests +from eth_account import Account +from eth_utils import keccak +from eth_keys import keys +from web3 import Web3 + +# Phala Cloud KMS contract on Base +KMS_CONTRACT_ADDRESS = "0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C" +BASE_RPC_URL = "https://mainnet.base.org" + +def get_kms_root_from_contract(): + """Get KMS root address from the on-chain KMS contract""" + print("🔑 Getting KMS Root from Contract") + print("=" * 50) + + w3 = Web3(Web3.HTTPProvider(BASE_RPC_URL)) + print(f"🔗 Base connection: {w3.is_connected()}") + + kms_abi = [{ + "inputs": [], + "name": "kmsInfo", + "outputs": [ + {"name": "k256Pubkey", "type": "bytes"}, + {"name": "rsaPubkey", "type": "bytes"} + ], + "type": "function" + }] + + contract = w3.eth.contract(address=KMS_CONTRACT_ADDRESS, abi=kms_abi) + k256_pubkey, _ = contract.functions.kmsInfo().call() + + print(f"📋 KMS Contract: {KMS_CONTRACT_ADDRESS}") + print(f"📋 K256 Pubkey: 0x{k256_pubkey.hex()[:20]}...") + + # Derive address from compressed public key + pubkey = keys.PublicKey.from_compressed_bytes(k256_pubkey) + kms_root_address = pubkey.to_checksum_address() + + print(f"✅ KMS Root Address: {kms_root_address}") + return kms_root_address + +def fetch_oracle_price(oracle_url): + """Fetch signed price from oracle""" + print(f"\n📡 Fetching from {oracle_url}/price...") + resp = requests.get(f"{oracle_url}/price", timeout=30) + resp.raise_for_status() + return resp.json() + +def fetch_oracle_info(oracle_url): + """Fetch oracle info including app_id""" + resp = requests.get(f"{oracle_url}/", timeout=10) + resp.raise_for_status() + return resp.json() + +def verify_signature_chain(data, app_id, expected_kms_root): + """Verify the complete signature chain""" + print("\n🔐 Verifying Signature Chain") + print("=" * 50) + + chain = data["signatureChain"] + derived_pubkey = bytes.fromhex(chain["derivedPubkey"].replace("0x", "")) + app_signature = bytes.fromhex(chain["appSignature"].replace("0x", "")) + kms_signature = bytes.fromhex(chain["kmsSignature"].replace("0x", "")) + message_hash = bytes.fromhex(data["messageHash"].replace("0x", "")) + message_signature = bytes.fromhex(data["signature"].replace("0x", "")) + app_id_bytes = bytes.fromhex(app_id.replace("0x", "")) + + print(f"App ID: {app_id}") + print(f"Expected KMS Root: {expected_kms_root}") + + # Step 1: Verify app signature over derived key + purpose = "ethereum" + app_message = f"{purpose}:{derived_pubkey.hex()}" + app_message_hash = keccak(text=app_message) + + app_sig_obj = keys.Signature(app_signature) + app_pubkey = app_sig_obj.recover_public_key_from_msg_hash(app_message_hash) + app_pubkey_compressed = app_pubkey.to_compressed_bytes() + app_address = app_pubkey.to_checksum_address() + + print(f"\n✓ Step 1: App signature recovered") + print(f" App Address: {app_address}") + + # Step 2: Verify KMS signature over app key + kms_message = b"dstack-kms-issued:" + app_id_bytes + app_pubkey_compressed + kms_message_hash = keccak(kms_message) + + kms_signer = Account._recover_hash(kms_message_hash, signature=kms_signature) + + print(f"\n✓ Step 2: KMS signature") + print(f" Recovered KMS: {kms_signer}") + print(f" Expected KMS: {expected_kms_root}") + + if kms_signer.lower() != expected_kms_root.lower(): + print(" ❌ KMS signature FAILED!") + return False, None + + print(" ✅ KMS signature verified!") + + # Step 3: Verify message signature + eth_message = b"\x19Ethereum Signed Message:\n32" + message_hash + eth_hash = keccak(eth_message) + + message_signer = Account._recover_hash(eth_hash, signature=message_signature) + + derived_key_obj = keys.PublicKey.from_compressed_bytes(derived_pubkey) + expected_signer = derived_key_obj.to_checksum_address() + + print(f"\n✓ Step 3: Message signature") + print(f" Recovered signer: {message_signer}") + print(f" Expected signer: {expected_signer}") + + if message_signer.lower() != expected_signer.lower(): + print(" ❌ Message signature FAILED!") + return False, None + + print(" ✅ Message signature verified!") + + # Return data needed for on-chain verification + return True, { + "app_pubkey_compressed": app_pubkey_compressed, + "derived_pubkey": derived_pubkey, + "app_signature": app_signature, + "kms_signature": kms_signature, + "message_hash": message_hash, + "message_signature": message_signature, + "app_id": app_id_bytes + } + +def main(): + print("🚀 TEE Oracle Production Test (Phala Cloud)") + print("=" * 60) + + # Get oracle URL + oracle_url = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ORACLE_URL") + if not oracle_url: + print("❌ No oracle URL provided") + print("\nUsage:") + print(" export ORACLE_URL='https://-8080.dstack-prod.phala.network'") + print(" python test_production.py") + print("\nOr:") + print(" python test_production.py ") + return False + + print(f"Oracle URL: {oracle_url}") + print() + + # Step 1: Get KMS root from contract + try: + kms_root = get_kms_root_from_contract() + except Exception as e: + print(f"❌ Failed to get KMS root: {e}") + return False + + # Step 2: Fetch oracle data + try: + data = fetch_oracle_price(oracle_url) + info = fetch_oracle_info(oracle_url) + app_id = info["appId"] + + print(f"✅ Got price: ${data['statement']['price']}") + print(f" Source: {data['statement']['source']}") + print(f" App ID: {app_id}") + except Exception as e: + print(f"❌ Failed to fetch oracle: {e}") + return False + + # Step 3: Verify signature chain + success, chain_data = verify_signature_chain(data, app_id, kms_root) + + if success: + print("\n" + "=" * 60) + print("🎉 Production verification passed!") + print(" ✅ KMS contract confirmed the root key") + print(" ✅ Signature chain verified") + print(" ✅ Oracle message authenticated") + print() + print("This output can be verified on-chain with TeeOracle.sol") + print(f"Deploy with: kmsRoot={kms_root}, appId=0x{app_id}") + return True + else: + print("\n❌ Verification failed!") + return False + +if __name__ == "__main__": + main() diff --git a/tutorial/05-hardening-https/README.md b/tutorial/05-hardening-https/README.md new file mode 100644 index 0000000..b998a8b --- /dev/null +++ b/tutorial/05-hardening-https/README.md @@ -0,0 +1,41 @@ +# Tutorial 05: Hardening HTTPS + +Strengthen TLS verification beyond browser defaults for DevProof applications. + +## Why Harden HTTPS? + +Browsers trust TLS certificates based on CA signatures alone. For DevProof applications, this isn't enough: + +- A CA could be compromised or coerced +- A certificate could be revoked but still accepted (delayed CRL propagation) +- A misissued certificate might not appear in CT logs + +TEE oracles fetching external data need stronger guarantees. + +## Reference Implementation + +The [phala-cloud-oracle-template](https://github.com/Gldywn/phala-cloud-oracle-template) is a production-ready oracle that implements these hardening techniques. It builds on the concepts from this tutorial: + +| This tutorial | Oracle template adds | +|---------------|---------------------| +| [01-attestation](../01-attestation) | ✓ Same TDX quote binding | +| [02-kms-and-signing](../02-kms-and-signing) | ✓ Same signature chain | +| [03-gateway-and-tls](../03-gateway-and-tls) | ✓ Same TLS basics | +| [04-onchain-oracle](../04-onchain-oracle) | ✓ Same on-chain verification | +| **HTTPS hardening** | OCSP, CRL, CT verification | + +For background on the hardening techniques: +- [hardened-https-agent BACKGROUND.md](https://github.com/Gldywn/hardened-https-agent/blob/main/BACKGROUND.md) + +## What the Hardened Agent Checks + +| Check | What it proves | +|-------|----------------| +| OCSP valid | Certificate wasn't revoked at fetch time | +| CRL checked | No delayed revocation issues | +| CT logged | Certificate was publicly issued (not secret/misissued) | + +## Next Steps + +- [06-encryption-freshness](../06-encryption-freshness): Advanced — encrypted storage with rollback protection +- [07-lightclient](../07-lightclient): Advanced — verified blockchain state diff --git a/tutorial/06-encryption-freshness/README.md b/tutorial/06-encryption-freshness/README.md new file mode 100644 index 0000000..b71c841 --- /dev/null +++ b/tutorial/06-encryption-freshness/README.md @@ -0,0 +1,105 @@ +# Tutorial 06: Encryption, Integrity, and Freshness + +> **Status**: This is an advanced tutorial, work in progress. + +Protect persistent data with encryption and detect rollback attacks. + +## The Problem + +TEE apps often need persistent storage (databases, files). But storage lives outside the TEE: + +``` +┌─────────────────┐ ┌─────────────────┐ +│ TEE │────▶│ External DB │ +│ (trusted) │ │ (untrusted) │ +└─────────────────┘ └─────────────────┘ +``` + +An attacker (or malicious operator) could: +1. **Read data** — if stored unencrypted +2. **Modify data** — if no integrity checks +3. **Rollback data** — restore old state to replay transactions + +## Encryption with Derived Keys + +Use KMS-derived keys to encrypt data at rest: + +```javascript +import { DstackClient } from '@phala/dstack-sdk' +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' + +const client = new DstackClient() +const { key } = await client.getKey('/encryption/db') + +function encrypt(plaintext) { + const iv = randomBytes(16) + const cipher = createCipheriv('aes-256-gcm', key.slice(0, 32), iv) + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + return { iv, encrypted, tag } +} +``` + +This ensures only this TEE app can decrypt the data. + +## Integrity + +AES-GCM provides authenticated encryption — tampering is detected: + +```javascript +function decrypt({ iv, encrypted, tag }) { + const decipher = createDecipheriv('aes-256-gcm', key.slice(0, 32), iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(encrypted), decipher.final()]) + // Throws if data was tampered +} +``` + +## Freshness (Rollback Protection) + +Encryption and integrity don't prevent rollback attacks. If an attacker restores an old database snapshot, the TEE can't tell. + +### Approaches + +| Approach | Trade-off | +|----------|-----------| +| **Monotonic counter** | Requires trusted counter storage (e.g., on-chain) | +| **Light client checkpoint** | Anchor state to blockchain block number | +| **Merkle tree on-chain** | Store state root on-chain, verify freshness | +| **Multi-party replication** | Multiple TEEs cross-check state | + +### Example: On-Chain State Root + +```javascript +// After each state change, post the root hash on-chain +const stateRoot = computeMerkleRoot(database) +await contract.updateStateRoot(stateRoot) + +// On startup, verify current state matches on-chain root +const onChainRoot = await contract.getStateRoot() +if (computeMerkleRoot(database) !== onChainRoot) { + throw new Error('State rollback detected') +} +``` + +## Access Pattern Leakage + +Even with encryption, access patterns leak information: +- Which records are accessed +- Access frequency and timing +- Size of records + +Mitigations: +- ORAM (Oblivious RAM) — expensive but hides access patterns +- Dummy accesses — add noise +- Batch operations — access patterns less granular + +## Next Steps + +- [07-lightclient](../07-lightclient): Use light client for freshness anchoring +- [08-extending-appauth](../08-extending-appauth): Custom authorization policies + +## References + +- [Intel SGX Sealed Storage](https://www.intel.com/content/www/us/en/developer/articles/technical/introduction-to-intel-sgx-sealing.html) +- [ORAM overview](https://en.wikipedia.org/wiki/Oblivious_RAM) diff --git a/tutorial/07-lightclient/README.md b/tutorial/07-lightclient/README.md new file mode 100644 index 0000000..3eccad2 --- /dev/null +++ b/tutorial/07-lightclient/README.md @@ -0,0 +1,111 @@ +# Tutorial 07: Light Client Oracle + +Read verified Ethereum state inside a TEE without trusting an RPC provider. + +## What it does + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ TEE │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌─────────┐ │ +│ │ Helios │───▶│ oracle.py│───▶│ Proof │ │ +│ │ Light │ │ │ │ (JSON) │ │ +│ │ Client │ │ sign + │ │ │ │ +│ └────┬────┘ │ quote │ └─────────┘ │ +│ │ └──────────┘ │ +│ ▼ │ +│ Untrusted RPC │ +└───────┬──────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────┐ ┌─────────────────────┐ + │ PublicNode │ │ beaconcha.in │ + │ (execution) │ │ (checkpoint sync) │ + └──────────────┘ └─────────────────────┘ +``` + +[Helios](https://github.com/a16z/helios) is an Ethereum light client that verifies block headers and state proofs. The TEE: + +1. Syncs Helios using a beacon chain checkpoint +2. Queries block data and contract state via verified light client +3. Signs the claim with a KMS-derived key +4. Gets a TDX quote binding `sha256(claim)` to `report_data` + +## Why this matters + +Unlike [04-onchain-oracle](../04-onchain-oracle) which fetches from off-chain APIs (CoinGecko), this reads directly from Ethereum state. Helios verifies state proofs internally, so you don't need to trust the RPC provider. + +Use cases: +- Attested `eth_call` results (token balances, contract state) +- Cross-chain bridges that verify source chain state +- Oracles for L2s that need L1 state proofs + +## Run + +```bash +docker compose build +docker compose run --rm app +``` + +With a custom RPC (for `eth_call` state proofs): + +```bash +ETH_RPC_URL="https://mainnet.infura.io/v3/YOUR_KEY" docker compose run --rm -e ETH_RPC_URL app +``` + +## Output + +```json +{ + "claim": { + "type": "lightclient_attestation", + "network": "mainnet", + "checkpoint_epoch": 416537, + "checkpoint_root": "0xbe1360...", + "block_number": 21492847, + "block_hash": "0xf180be...", + "state_root": "0x811128...", + "call": { + "to": "0x6b175474e89094c44da98b954eedeac495271d0f", + "data": "0x18160ddd", + "result": "0x00000000...db77394bd15356c736ab846" + } + }, + "claimHash": "a1b2c3...", + "signature": "...", + "pubkey": "...", + "quote": "BAACAQI..." +} +``` + +The example queries DAI's `totalSupply()` (`0x18160ddd`) but you can modify the contract call. + +## Verification + +Two things to verify: + +1. **TDX quote** — proves this claim came from a TEE running this code + → See [01-attestation](../01-attestation) for `dcap-qvl` + `dstack-mr` verification + +2. **Signature** — signed with KMS-derived key, verifiable on-chain + → See [04-onchain-oracle](../04-onchain-oracle) for signature chain verification + +The `checkpoint_root` can be cross-checked against any beacon chain source (e.g., [beaconcha.in](https://beaconcha.in)). + +## Limitations + +- **State proofs** require an RPC with `eth_getProof` support. The free PublicNode RPC doesn't support this, so `eth_call` requires setting `ETH_RPC_URL` to Infura/Alchemy. +- **Checkpoint trust**: Initial sync uses beaconcha.in's checkpoint service. The root is included in the claim for cross-verification. + +## Files + +``` +07-lightclient/ +├── docker-compose.yaml # Helios + oracle (self-contained) +└── README.md +``` + +## Next Steps + +- [08-extending-appauth](../08-extending-appauth): Custom authorization contracts diff --git a/tutorial/07-lightclient/docker-compose.yaml b/tutorial/07-lightclient/docker-compose.yaml new file mode 100644 index 0000000..19fa1e3 --- /dev/null +++ b/tutorial/07-lightclient/docker-compose.yaml @@ -0,0 +1,113 @@ +services: + app: + configs: + - source: run.sh + target: /root/run.sh + - source: oracle.py + target: /root/oracle.py + volumes: + - /var/run/dstack.sock:/var/run/dstack.sock + build: + context: . + dockerfile_inline: | + FROM ubuntu:24.04@sha256:b59d21599a2b151e23eea5f6602f4af4d7d31c4e236d22bf0b62b86d2e386b8f + RUN apt-get update && apt install -y curl python3 python3-pip + RUN pip3 install --break-system-packages ecdsa requests dstack-sdk + WORKDIR /root + # Helios 0.11.0 + RUN curl -L 'https://github.com/a16z/helios/releases/download/0.11.0/helios_linux_amd64.tar.gz' | tar -xzC . + CMD ["bash", "/root/run.sh"] + platform: linux/amd64 + +configs: + run.sh: + content: | + RPC=$${ETH_RPC_URL:-https://ethereum-rpc.publicnode.com} + echo "Using execution RPC: $$RPC" + + /root/helios ethereum \ + --network mainnet \ + --execution-rpc "$$RPC" \ + --fallback https://sync-mainnet.beaconcha.in \ + --rpc-bind-ip 0.0.0.0 & + + echo "Waiting for Helios to sync..." + for i in $$(seq 1 30); do + if curl -s localhost:8545 -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' | grep -q result; then + echo "Helios synced!" + break + fi + sleep 2 + done + + python3 /root/oracle.py + + oracle.py: + content: | + import json, hashlib, requests + from ecdsa import SigningKey, NIST256p + + HELIOS = "http://localhost:8545" + CHECKPOINT_API = "https://sync-mainnet.beaconcha.in" + DAI = "0x6b175474e89094c44da98b954eedeac495271d0f" + + # Try to connect to TEE environment + client = None + try: + from dstack_sdk import TappdClient + client = TappdClient() + key_result = client.derive_key("/oracle", "") + sk = SigningKey.from_pem(key_result.key) + print(f"TEE: using derived key (curve: {sk.curve.name})", flush=True) + except Exception as e: + print(f"WARNING: Not in TEE ({e}), using random key", flush=True) + sk = SigningKey.generate(curve=NIST256p) + client = None + + pk = sk.get_verifying_key() + + def rpc(method, params=[]): + r = requests.post(HELIOS, json={"jsonrpc":"2.0","method":method,"params":params,"id":1}) + resp = r.json() + if "error" in resp: + raise Exception(resp["error"]) + return resp.get("result") + + def get_checkpoint(): + r = requests.get(f"{CHECKPOINT_API}/checkpointz/v1/status", timeout=5) + data = r.json()["data"]["finality"]["finalized"] + return {"epoch": int(data["epoch"]), "root": data["root"]} + + # Fetch verified data via Helios + checkpoint = get_checkpoint() + block_num = rpc("eth_blockNumber") + block = rpc("eth_getBlockByNumber", [block_num, False]) + call_result = rpc("eth_call", [{"to": DAI, "data": "0x18160ddd", "gas": "0x100000"}, block_num]) + + claim = { + "type": "lightclient_attestation", + "network": "mainnet", + "checkpoint_epoch": checkpoint["epoch"], + "checkpoint_root": checkpoint["root"], + "block_number": int(block_num, 16), + "block_hash": block["hash"], + "state_root": block["stateRoot"], + "call": {"to": DAI, "data": "0x18160ddd", "result": call_result}, + } + + claim_bytes = json.dumps(claim, sort_keys=True).encode() + claim_hash = hashlib.sha256(claim_bytes).digest() + sig = sk.sign_deterministic(claim_hash, hashfunc=hashlib.sha256) + + quote = client.tdx_quote(claim_hash.hex()).quote if client else None + + proof = { + "claim": claim, + "claimHash": claim_hash.hex(), + "signature": sig.hex(), + "pubkey": pk.to_string().hex(), + "quote": quote, + } + + print(json.dumps(proof, indent=2)) diff --git a/tutorial/08-extending-appauth/README.md b/tutorial/08-extending-appauth/README.md new file mode 100644 index 0000000..a40486e --- /dev/null +++ b/tutorial/08-extending-appauth/README.md @@ -0,0 +1,179 @@ +# Tutorial 08: Upgrades and Custom Authorization + +Extend the `AppAuth` contract with custom authorization logic for your dstack apps. + +## Overview + +dstack uses on-chain contracts to authorize which apps can access the KMS. The base `DstackApp.sol` contract provides simple compose-hash and device-id whitelisting. You can extend this with custom logic: + +- NFT-gated membership (1 NFT = 1 authorized node) +- Timelock governance (delay before new compose hashes activate) +- Multi-sig approval +- On-chain voting + +## The AppAuth Interface + +Every authorization contract implements `IAppAuth`: + +```solidity +interface IAppAuth { + struct AppBootInfo { + bytes32 appId; + bytes32 instanceId; + bytes32 composeHash; + bytes32 deviceId; + bytes32 mrAggregated; + bytes32 mrSystem; + bytes32 osImageHash; + string tcbStatus; + string[] advisoryIds; + } + + function isAppAllowed(AppBootInfo calldata bootInfo) + external view returns (bool isAllowed, string memory reason); +} +``` + +The KMS calls `isAppAllowed()` when an app requests keys. Your contract decides if the app should receive them based on whatever logic you implement. + +## Base Implementation: DstackApp.sol + +The default contract checks two things: + +1. **Compose hash whitelist** — Is `bootInfo.composeHash` in the allowed set? +2. **Device whitelist** — Is `bootInfo.deviceId` allowed (or `allowAnyDevice` enabled)? + +```solidity +function isAppAllowed(AppBootInfo calldata bootInfo) + external view returns (bool, string memory) +{ + if (!allowedComposeHashes[bootInfo.composeHash]) + return (false, "Compose hash not allowed"); + + if (!allowAnyDevice && !allowedDevices[bootInfo.deviceId]) + return (false, "Device not allowed"); + + return (true, ""); +} +``` + +Source: [Dstack-TEE/dstack/kms/auth-eth/contracts](https://github.com/Dstack-TEE/dstack/tree/master/kms/auth-eth/contracts) + +## Extending with Custom Logic + +### Example: NFT-Gated Cluster + +The [dstack-nft-cluster](https://github.com/Account-Link/dstack-nft-cluster) project extends authorization with NFT membership: + +```solidity +contract DstackMembershipNFT is ERC721, IAppAuth { + mapping(uint256 => bytes32) public tokenToInstanceId; + mapping(bytes32 => string) public instanceToConnectionUrl; + + function isAppAllowed(AppBootInfo calldata bootInfo) + external view returns (bool, string memory) + { + // Check if instanceId is registered to an NFT + if (!isInstanceRegistered(bootInfo.instanceId)) + return (false, "Instance not registered to NFT"); + + // Verify signature chain from KMS + if (!verifySignatureChain(bootInfo)) + return (false, "Invalid signature chain"); + + return (true, ""); + } + + function registerInstance(uint256 tokenId, string calldata name) external { + require(ownerOf(tokenId) == msg.sender, "Not token owner"); + // ... + } +} +``` + +This creates a "1 NFT = 1 node" model where token holders control cluster participation. + +### Example: Timelock Upgrades + +Add a delay before new compose hashes become active: + +```solidity +contract TimelockAppAuth is DstackApp { + uint256 public constant DELAY = 2 days; + mapping(bytes32 => uint256) public pendingComposeHashes; + + function proposeComposeHash(bytes32 hash) external onlyOwner { + pendingComposeHashes[hash] = block.timestamp + DELAY; + } + + function activateComposeHash(bytes32 hash) external { + require(pendingComposeHashes[hash] != 0, "Not proposed"); + require(block.timestamp >= pendingComposeHashes[hash], "Too early"); + allowedComposeHashes[hash] = true; + delete pendingComposeHashes[hash]; + } +} +``` + +### Example: Multi-Sig Approval + +Require multiple signers before adding compose hashes: + +```solidity +contract MultiSigAppAuth is DstackApp { + uint256 public threshold; + mapping(bytes32 => mapping(address => bool)) public approvals; + mapping(bytes32 => uint256) public approvalCount; + + function approve(bytes32 hash) external { + require(isSigner[msg.sender], "Not a signer"); + require(!approvals[hash][msg.sender], "Already approved"); + approvals[hash][msg.sender] = true; + approvalCount[hash]++; + + if (approvalCount[hash] >= threshold) + allowedComposeHashes[hash] = true; + } +} +``` + +## The AppBootInfo Fields + +| Field | Description | +|-------|-------------| +| `appId` | Hash of app configuration (compose-hash) | +| `instanceId` | Unique identifier for this running instance | +| `composeHash` | SHA-256 of app-compose.json manifest | +| `deviceId` | Hardware identifier of the TEE | +| `mrAggregated` | Combined measurement of firmware + OS | +| `mrSystem` | System-level measurement | +| `osImageHash` | Hash of the dstack OS image | +| `tcbStatus` | Intel TCB status (UpToDate, OutOfDate, etc.) | +| `advisoryIds` | List of applicable Intel security advisories | + +Use these fields to implement sophisticated authorization policies. For example, reject apps running on outdated firmware: + +```solidity +if (keccak256(bytes(bootInfo.tcbStatus)) != keccak256("UpToDate")) + return (false, "TCB not up to date"); +``` + +## Deployment + +1. Deploy your custom contract to a supported chain (Base, Ethereum, etc.) +2. Configure the KMS to use your contract address +3. Deploy apps — they'll be authorized via your contract + +For Phala Cloud's on-chain KMS, see [Cloud vs On-chain KMS](https://docs.phala.network/phala-cloud/key-management/cloud-vs-onchain-kms). + +## References + +- [IAppAuth interface](https://github.com/Dstack-TEE/dstack/blob/master/kms/auth-eth/contracts/IAppAuth.sol) +- [DstackApp base contract](https://github.com/Dstack-TEE/dstack/blob/master/kms/auth-eth/contracts/DstackApp.sol) +- [dstack-nft-cluster](https://github.com/Account-Link/dstack-nft-cluster) — NFT-gated authorization example +- [Key Management Protocol](https://docs.phala.network/dstack/design-documents/key-management-protocol) + +## Next Steps + +- [01-attestation](../01-attestation): Understand attestation verification +- [02-kms-and-signing](../02-kms-and-signing): How apps derive keys from KMS diff --git a/tutorial/README.md b/tutorial/README.md new file mode 100644 index 0000000..9b15122 --- /dev/null +++ b/tutorial/README.md @@ -0,0 +1,136 @@ +# Dstack Tutorial: Building DevProof Applications + +This tutorial teaches you to build **DevProof** (or "unruggable") applications using Dstack — apps where even the developer can't cheat users. + +## Why DevProof? + +If you follow typical Dstack guides, you'll get an ordinary server where you (the admin) can still "rug" your users. The app runs in a TEE, but the developer retains backdoors. + +**DevProof** is a different threat model: we assume the developer themselves might be malicious, and design the system so they *can't* betray users even if they wanted to. + +This is what smart contracts and DeFi aspire to, but TEEs let us apply it to practical, general-purpose code — not just on-chain logic. + +### Examples of DevProof reasoning + +| Application | DevProof property | +|-------------|-------------------| +| Oracle for prediction markets | Developer can't manipulate how bets settle | +| Verifiable credentials (zkTLS) | Developer can't forge credentials | +| User consent collection | Developer can prove they collected N consents | +| Data handling | Developer can prove no user data was exposed | + +### Analogies from Smart Contracts + +Smart contracts achieve DevProof design through: +- Open source code +- On-chain codehash compared against verifiable builds +- Users expected to DYOR (do your own research) +- Auditors verify source and on-chain deployment match +- Immutable by default; upgrade mechanisms become audit surfaces +- Upgrade policies with on-chain "due process" (timelocks, multisig) + +TEE apps need similar patterns — this tutorial shows how. + +## Running Example: TEE Oracle + +Throughout this tutorial, we build a **price oracle** for prediction markets: +1. Fetches prices from external APIs +2. Proves the data came from a specific TLS server +3. Signs results with TEE-derived keys +4. Verifiable on-chain + +Each section adds a layer until we have a fully DevProof oracle. + +--- + +## Development Environment + +**You can complete the entire tutorial without TDX hardware.** The simulator provides everything needed to develop and test locally. + +### Requirements + +| Tool | Purpose | Install | +|------|---------|---------| +| Docker | Run apps | [docker.com](https://docker.com) | +| Phala CLI | Simulator + deploy | `npm install -g @phala/cloud-cli` | +| Python 3 | Test scripts | System package | +| Foundry | On-chain testing (04) | [getfoundry.sh](https://getfoundry.sh) | + +### Local Development (Simulator) + +```bash +# Start the simulator (provides mock KMS + attestation) +phala simulator start + +# Run any tutorial section +cd 02-kms-and-signing +docker compose build +docker compose run --rm -p 8080:8080 \ + -v ~/.phala-cloud/simulator/0.5.3/dstack.sock:/var/run/dstack.sock \ + app + +# Run tests +pip install -r requirements.txt +python3 test_local.py +``` + +The simulator provides: +- `getKey()` — deterministic key derivation +- `tdxQuote()` — mock attestation quotes +- Signature chains verifiable against simulator KMS root + +### On-Chain Testing (Anvil) + +For [04-onchain-oracle](./04-onchain-oracle), use anvil for local contract testing: + +```bash +anvil & # Local Ethereum node +forge create TeeOracle.sol # Deploy verification contract +``` + +### Production Deployment + +```bash +# Phala Cloud (managed TDX) +phala deploy -n my-app -c docker-compose.yaml + +# Self-hosted TDX +# See https://docs.phala.com/dstack/local-development +``` + +> **Note:** A DevProof design minimizes dependency on any single provider. The verification techniques work regardless of where you deploy. + +### SDK Options + +| Language | Install | Docs | +|----------|---------|------| +| JavaScript/TypeScript | `npm install @phala/dstack-sdk` | [sdk/js](https://github.com/Dstack-TEE/dstack/tree/master/sdk/js) | +| Python | `pip install dstack-sdk` | [sdk/python](https://github.com/Dstack-TEE/dstack/tree/master/sdk/python) | + +--- + +## Tutorial Sections + +### Core Tutorial + +1. **[01-attestation](./01-attestation)** — Build a TEE oracle and verify its attestation end-to-end + - **[01a-reproducible-builds](./01a-reproducible-builds)** — Make builds verifiable for auditors +2. **[02-kms-and-signing](./02-kms-and-signing)** — Derive persistent keys and verify signature chains +3. **[03-gateway-and-tls](./03-gateway-and-tls)** — Self-signed TLS with attestation-bound certificates +4. **[04-onchain-oracle](./04-onchain-oracle)** — AppAuth contracts and multi-node deployment +5. **[05-hardening-https](./05-hardening-https)** — OCSP stapling, CRL checking, CT records + +### Advanced + +6. **[06-encryption-freshness](./06-encryption-freshness)** — Encrypted storage, integrity, rollback protection +7. **[07-lightclient](./07-lightclient)** — Verified blockchain state via Helios light client +8. **[08-extending-appauth](./08-extending-appauth)** — Custom authorization contracts (timelocks, NFT-gating, multisig) + +--- + +## References + +- [Dstack Documentation](https://docs.phala.com/dstack) +- [Phala Cloud](https://cloud.phala.network) +- [trust-center](https://github.com/Phala-Network/trust-center) — Attestation verification +- [dstack GitHub](https://github.com/Dstack-TEE/dstack) diff --git a/tutorial/SESSION-NOTES.md b/tutorial/SESSION-NOTES.md new file mode 100644 index 0000000..32d551a --- /dev/null +++ b/tutorial/SESSION-NOTES.md @@ -0,0 +1,85 @@ +# Tutorial Session Notes - 2025-12-23 + +## What Was Created + +### tutorial/01-attestation-oracle/ +Merged tutorial covering: +- **Oracle app**: Fetches BTC price from CoinGecko, captures TLS fingerprint, binds to TDX quote via report_data +- **4 verification options**: + - A: Hosted (trust.phala.network) + - B: Local script (attest.sh from attestation/configid-based) + - C: Programmatic (trust-center API) + - D: Python script (verify_full.py included) +- **Key concepts explained**: + - ConfigID-based verification (v0.5.1+) - compose-hash in mr_config_id + - app-compose.json manifest (includes pre_launch_script) + - trust-center vs attest.sh differences + - Complete verification chain: hardware → OS → compose → report_data → TLS fingerprint + +### tutorial/02-persistence-and-kms/ +- Explains `getKey()` for deterministic key derivation +- KMS holds root keys, derives child keys per path +- Same key across restarts/migrations +- Example: persistent wallet address + +### tutorial/03-gateway-and-ingress/ +- Short README linking to custom-domain/dstack-ingress +- Explains certificate evidence chain (quote.json → sha256sum.txt → cert.pem) +- When to use default gateway vs dstack-ingress + +### tutorial/04-upgrades/ +- Extends `AppAuth.sol` with custom authorization logic +- Covers `IAppAuth` interface and `AppBootInfo` struct +- Three extension examples: + - NFT-gated clusters (1 NFT = 1 node) + - Timelock upgrades (delay before compose hash activation) + - Multi-sig approval (threshold of signers) +- References [dstack-nft-cluster](https://github.com/Account-Link/dstack-nft-cluster) as real-world example +- Full `AppBootInfo` field reference for custom policies + +### Main README updated +- Added Tutorials section linking all 4 tutorials with descriptions +- Positioned above Use Cases section + +## Key Technical Decisions + +1. **ConfigID-based verification** (not RTMR3 event chain) + - Simpler: mr_config_id = "01" + sha256(app-compose.json) padded to 96 chars + - No event log replay needed + +2. **trust-center is hybrid** + - Uses Phala Cloud API for app discovery + - Runs verification (dcap-qvl, dstack-mr) locally + - Downloads OS images from GitHub + +3. **Pre-launch script matters** + - Included in compose-hash + - Must audit full app-compose.json, not just docker-compose.yaml + - Fetch via `phala cvms attestation ` or trust-center + +4. **Verification tools are open source** + - dcap-qvl: github.com/Phala-Network/dcap-qvl (Rust) + - dstack-mr: github.com/kvinwang/dstack-mr (Go) + - dstack OS: github.com/Dstack-TEE/meta-dstack (Yocto) + +## Files Modified/Created + +- tutorial/01-attestation-oracle/docker-compose.yaml (oracle app) +- tutorial/01-attestation-oracle/README.md (merged tutorial) +- tutorial/01-attestation-oracle/verify_full.py (from old 01-attestation) +- tutorial/02-persistence-and-kms/README.md +- tutorial/03-gateway-and-ingress/README.md +- tutorial/04-upgrades/README.md (AppAuth customization) +- README.md (added Tutorials section, updated quick-start to use tutorial) + +## Removed + +- tutorial/01-attestation/ (merged into 01-attestation-oracle) +- attestation-with-sdk/ (subsumed by tutorial/01-attestation-oracle) + +## References Used + +- refs/trust-center/ - verification implementation +- refs/primus-network-startup/ - example of dstack deployment +- attestation/configid-based/ - standalone verification script +- custom-domain/dstack-ingress/ - TLS/custom domain solution